Compare commits

...
377 Commits
Author SHA1 Message Date
John O'Keefe f7dc8189a1 feat(css): cross-document view transitions
Release / build-and-push (push) Successful in 3m15s
@view-transition { navigation: auto; } lets Chrome 126+ / Safari 18+
crossfade same-origin navigations — sidebar links and the series
page's library-switch navigation get a subtle fade with zero
JavaScript, softening the switch to full SSR navigation. Pure
progressive enhancement: Firefox and older browsers simply navigate
instantly. style.css regenerated via build:css:prod.
2026-10-19 12:40:44 -04:00
John O'Keefe a8641ab343 chore(ui): bump the main.js cache-bust stamp to v6
web/static/*.js is gitignored and built at image-build time, so the
hardcoded ?v= on the module script tag is the ONLY mechanism that
evicts a stale bundle from browsers. Shipping the series twin deletion
without a bump would let cached main.js (with the deleted renderer)
keep running on prod, making the paginator fix appear not to work.
Found while shipping; easy to miss because the bundle never appears in
git status.
2026-10-19 12:40:31 -04:00
John O'Keefe 1ce66b2213 chore(templ): warn against change handlers on the bookshelf library select
The bookshelf select (#library-select) sits inside the htmx filter
form and the SSR route honors ?offset= — wiring a change handler or
hx-trigger="change" on it would let header.ts's synthetic restore
event re-fetch the grid at offset 0, the same stomp class that broke
the /series paginator. Comment-only guard; regenerated
bookshelf_templ.go carries it into the emitted markup.
2026-10-19 12:40:13 -04:00
John O'Keefe 18f52d240b docs(agents): record the dashboard sections rendering exception
renderDashboardCollections/renderSectionHTML re-render dashboard
sections from /api/dashboard/sections JSON on library switch, settings
save, and refresh — deliberately kept in TypeScript to preserve the
switch fade on the app's most interactive page. First-paint section
markup remains templ (Dashboard handler in internal/router/frontend.go).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Kanagawa's UI accent uses crystalBlue (#7e9cd8) instead of Omarchy's
foreground-as-accent so links stay distinguishable from text.
2026-10-18 12:08:07 -04:00
John O'Keefe 4f913da0b1 chore(db): schema.go searches three levels up — replaces the tests-dir symlink
Release / build-and-push (push) Successful in 3m8s
Host-side test runs use CWD=cmd/server/tests, three levels below the repo
root; the location search only went up two, so it never found
database/schema/schema.sql from there and init() fatal-exited. That gap
was being papered over by an untracked local symlink
(cmd/server/tests/database -> ../../../database) hidden behind a bare
`database` line in .git/info/exclude — which also broke `git add` under
database/. Fix the search list instead and drop the filesystem trick:
no symlink to commit, no exclude hack, works on any platform.

Verified: contract pins green from cmd/server/tests with no symlink
present (init resolves via the new entry); parse test green.
2026-10-17 22:51:55 -04:00
John O'Keefe 0799cca3e6 fix(schema): ship the two Batch-A upgrade ALTERs that 1.15.0 missed
audio_metadata (media_items) and audio_offset_seconds (reading_progress)
were declared only inside their CREATE TABLE blocks — fresh installs got
them, upgraded installs never did, because columns reach existing
databases only via the idempotent ALTER block that schema.sql re-runs on
every boot. On prod (v1.15.0) this 42703'd every media-existence check
and the archive pass (1817/1817 scan errors), and every progress save,
since the shared progress upsert names audio_offset_seconds.

Both ALTERs match the declared shapes exactly (nullable JSONB; INTEGER
CHECK (audio_offset_seconds >= 0)); idempotent, applied on every boot.

Verified against a scratch database built to the v1.14.0 shape: overlay
the patched schema -> both columns created; re-run (every boot) skips
cleanly; probes resolve reading_progress.audio_offset_seconds integer
and media_items.audio_metadata jsonb. Parse test and a real container
boot (38 tables, 6 functions verified) green.
2026-10-17 22:46:01 -04:00
John O'Keefe 45b8542e25 fix(api): honor the request's origin as the writer label — app rows stop wearing "web"
Release / build-and-push (push) Successful in 3m36s
Every web-API write endpoint hardcoded Source:"web", so the Android
app's own pushes landed with last_sync_source/last_modified_source
"web" — the app was indistinguishable from the web SPA in progress
conflicts, device_sync_data, and annotation bookkeeping. The app CAN
say who it is (bookmark create already carried origin → origin_source);
the server just ignored it for labeling.

All six write sites (progress PUT, bookmark/note/highlight create+PUT)
now parse an ADDITIVE optional "origin" key, normalized through one
gate: "android"/"ios" pass through, anything absent/unknown stays
"web" (the label keys conflict claims and device_sync_data — free-form
client strings would pollute them). Precedence on progress: a paired
X-Device-Token still outranks origin. Bookmark origin_source now rides
the same gate. The web SPA sends no origin — its rows are unchanged;
KOReader/Kobo handlers keep their own labels.

Wire discipline: additive request keys only (rule 2), response shapes
untouched, pins changelog widened in-commit, locked by
TestOriginWriterAttribution (android lands, default stays web, unknown
normalizes). Full battery: only the two known pre-existing failures
(TestOPDSLibraryFolders rot, TestWebSocketPingPong — both reproduce
with the change stashed).
2026-10-17 21:44:27 -04:00
John O'Keefe 8f05f1116e fix(css): native select popups follow the theme — color-scheme per scheme + themed option entries
The stats page's Library-tab switcher (and every other native
<select>) rendered its dropdown LIST in the OS light scheme in dark
themes: the closed control is themed by its own classes, but the
popup list is UA-rendered and ignores page CSS unless the document
declares color-scheme (Chrome path) or the options carry explicit
colors (Firefox path). Both are now in place:

- :root { color-scheme: dark } (the default palette — Tokyo Night —
  and all eleven themes except latte are dark),
- .theme-catppuccin-latte { color-scheme: light },
- select option { background-color: var(--bg-secondary); color:
  var(--text-primary) } as the belt for engines where the popup
  escapes the document cascade.

style.css regenerated via build:css:prod; verified served by the
rebuilt container.
2026-10-17 20:12:54 -04:00
John O'Keefe 454938c31f refactor(vis): H4.5 steps 4-5 — statistics and the three reader gates on the canonical resolver; core goes package-level
VisibleLibrarySet becomes a package-level function taking the queries
(db-only services like statistics reach it without new wiring); the
LibraryService method stays as the ergonomic entry point. Statistics'
ids-shaper and the reader pages' three hand-rolled membership loops
(empty-rows fallback then linear scans) now delegate; the gates keep
their 403-on-invisible semantics (>null set membership, never silent
pass).

Census addendum from this pass: two OPDS sites call
GetUserVisibleLibraries directly but consume FULL ROWS (per-library
folder enumeration) — a third legitimate pattern alongside set/ids and
resolveLibrary's active-selection; documented, untouched. The library
LIST endpoint and its SSR data helper are row consumers too, by design.
2026-10-17 17:35:43 -04:00
John O'Keefe 8dbaa8f810 refactor(vis): H4.5 step 3 — collections endpoint and the SSR helper on the canonical resolver
collections.userVisibleLibraryIDs keeps its identity adapter and
delegates; the router's visibleLibraryIDsFor likewise (its identity
source is the session user_id string, the site-specific part it keeps).
The SSR site made the unification slog-proof: resolveLibrary — the SSR
one-of-a-kind (active selection + switcher rows + offline detection) —
stays untouched per the semantic diff.
2026-10-17 17:32:11 -04:00
John O'Keefe d2d92efd10 refactor(vis): H4.5 step 2 — series endpoints on the canonical resolver
Series' visibleLibrarySet was the third hand-rolled set builder (with
its own FromBytes-error idiom, the fourth idiom in four copies).
SeriesService.VisibleLibraries — a pure pass-through to
GetUserVisibleLibraries — now has zero callers and dies with the
migration.
2026-10-17 17:31:23 -04:00
John O'Keefe 1d12a64915 refactor(vis): H4.5 step 1 — LibraryService.VisibleLibrarySet, the canonical resolver; media endpoints migrated
One core for "which libraries can this user see": family-lens default-
allow stays where it always was (the GetUserVisibleLibraries SQL);
the core adds the shared set+ids shaping that four endpoints each
hand-rolled. Media's visibleLibrarySet is now a thin identity adapter
(JWT user object -> resolved id) and the download-permission gate uses
the set instead of a manual membership loop.

Semantic diff (H4.5 step 1, in the plan): zero family-lens divergence
found across all six sites — the rule lives in one query; the variation
was shape-level (identity adapter, set-vs-ids, UUID idiom).
2026-10-17 17:30:11 -04:00
John O'Keefe 29a546229d refactor(opds): one acquisition-entry builder for the catalog and search feeds
The search feed carried a ~55-line inline copy of the acquisition-entry
skeleton, over a DIFFERENT sqlc row type (SearchMediaItemsRow vs
ListMediaItemsByLibraryRow) — the row-type split is why it stayed a
twin. The shared builder now takes opdsEntryInput, the narrow
ten-field subset the feed actually renders (deliberately not a row
converter), and acquisitionEntryOpts makes the two legitimate
differences explicit instead of incidental:

- labelLibraries: unscoped search labels entries with their library.
- includePdfAlternate: catalogs offer the PDF alternate for reflowable
  books; search historically does not. Kept as-is — making search match
  catalogs would be a behavior change, flagged in the opts doc.

The four OPDS behaviors tests are green in the test-runner container.
Flagged, not from this change: TestOPDSLibraryFolders fails identically
with the change stashed — pre-existing rot worth its own look (it never
ran in the hardening batteries because the -run patterns didn't match
it).
2026-10-17 17:23:33 -04:00
John O'Keefe 6b3bdef052 refactor(reader): one comic-likeness predicate — panel detection was a byte-identical twin
shouldEnablePanelDetection and ShouldTreatAsComic had identical truth
tables (manga/comics library x fixed_layout/comic_archive) under
different names — a drift trap: adding a library type to one silently
missed the other. One predicate now gates both features, with the
re-split instruction in its doc comment should the features ever
legitimately diverge.
2026-10-17 17:16:11 -04:00
John O'Keefe 2ba1e0960b refactor(hash-conflicts): one usage-counts reader for the API and SSR surfaces
The pending-hash-conflict walk existed twice (JSON admin API emitting
numeric counts, SSR page emitting a formatted summary) — deliberately
different shapes, but both duplicated the counts-or-zero lookup and
the five-field total. MediaItemUsageCountsTotal is the shared read;
the per-surface shaping stays local on purpose.
2026-10-17 17:15:14 -04:00
John O'Keefe 010c0cb287 test(contracts): whitelist the unified row's two new keys on search rows
The H4 row-completion commit made abridged/audio_metadata emit on the
search wire (the endpoint serializes the row directly) — exactly what
the pin exists to surface. Whitelisted per the additive rule with the
changelog entry.
2026-10-17 16:57:20 -04:00
John O'Keefe c538efe722 feat(db): SearchMediaItemsUnifiedRow carries abridged + audio_metadata
The unified search row lagged the table (survey overstated it —
narrators was already there). Both columns come from the
already-scanned media_items side of the joins: EXPLAIN before/after is
plan-identical (same Nested Loop shape, no new scans). Row-completeness
only — no client surface changes; the hand maps select their own keys.
2026-10-17 16:55:04 -04:00
John O'Keefe 87fc89a816 refactor(router): one WeekStatsData mapper for the dashboard page and week-widget fragment 2026-10-17 16:54:21 -04:00
John O'Keefe 77cf0b827b refactor(db): GetCollectionItemsForDashboard embeds the media row; excluded filter moves into SQL; third converter dies
SELECT mi.*, ci.excluded forced every caller to filter tombstone
exclusions in Go over a 69-field row, then a 45-field hand converter
(getCollectionItemsRowToMediaItems) copied it field-by-field into
MediaItems for BuildSections. The query now filters
(ci.excluded IS NOT TRUE) itself and returns sqlc.embed(mi) — callers
consume .MediaItems directly, the Go filter loops die in both
collection-detail ladders (JSON + SSR), and the converter is deleted.

Pins, H1/H3 tests, unit suites green.
2026-10-17 16:51:30 -04:00
John O'Keefe 18727fef93 refactor(collections): rule evaluators take the real row; both 45-field converter twins deleted
checkRulesAgainstBook/evaluateRule and the service's EvaluateRules
existed in two parallel universes: one over database.MediaItems (what
every query actually returns) and one over ListMediaItemsRow (what two
hand-written 45-field mediaItemsToListMediaItemsRow converters
produced — verbatim twins in the handler and the dashboard service,
with a URL-resolution side effect baked into a "converter"). The rule
fields (genre/series/author/language/publisher/copyright_year/tags)
all exist on MediaItems, so the evaluators now take the real row and
the converters, their three call-site loops, and the dead
ApplyAutoAssignRules entry point (zero callers) are gone.

The rules surface is wire-covered by the collections rules scenarios;
handlers+services suites and the full pin/H1/H3 battery green.
2026-10-17 16:48:39 -04:00
John O'Keefe b47a2b86a7 refactor(conflicts): one ConflictDetail builder for the three row shapes
ListConflicts, GetConflict, and the SSR media-detail active-conflict
lookup each hand-mapped their sqlc row (list-by-user, get-by-id,
list-by-media-item) into ConflictDetailResponse, triplicating the
optional resolution tail — including the silently-swallowed
ResolutionData unmarshal error, three times. NewConflictDetail is the
single mapper; a malformed resolution blob now logs instead of
vanishing.

Also extracts koreaderSyncEndpoints (the KOReader URL map duplicated
verbatim between token rotation and device approval; kobo's two URL
sets stay local because they deliberately differ).

Verified in the test-runner container (host conflict tests fail
environmentally on the container-only uploads path — baseline behavior).
2026-10-17 16:42:37 -04:00
John O'Keefe 58fbe372e2 refactor(devices): one Devices->DeviceInfo mapper for the five hand-built copies
The same pgtype unwrap-and-fill block (sync_enabled/auto_sync validity,
frequency fallback, pointer timestamps) existed at five DeviceInfo
construction sites, two of which also carried AuthToken.
deviceToInfo(device, includeAuthToken) replaces all five; token-bearing
lists pass the flag explicitly so the surface that exposes the token
stays a deliberate choice.
2026-10-17 16:39:12 -04:00
John O'Keefe 082a3e0c3c fix(sync): offline detector wiped device name, auto_sync, and metadata on every transition
setOfflineMode/setOnlineMode only mean to flip sync_enabled, but they
called the full-row UpdateDevice with Go zero values — which wrote
DeviceName: "" and NULLed auto_sync/sync_frequency_minutes/
device_metadata on the device row at every offline->online flip. New
targeted SetDeviceSyncEnabled query touches exactly that column; the
real settings update keeps using UpdateDevice.

Caught as part of the H4 helper-census pass (plan §5.4 erasure list).
2026-10-17 16:38:00 -04:00
John O'Keefe 188ae076ab refactor(utils): one PgTextString for the four pgtype.Text unwrappers
handlers textToString, router textToString, router getText, and utils
normText were four copies of the same NULL-to-empty unwrap (99 call
sites). One exported utils.PgTextString replaces them all; the private
normText (comparison normalizer) stays but is now the odd one out.

Harness fix found on the way: setupTestServer built a full wiring
literal for route registration and then RETURNED a second, sparser
literal that never copied AnnotationService — setup.AnnotationService
was always nil on the struct every test receives. Nobody noticed until
cross_library_move_test became the first caller (it previously used
the now-retired plain CreateMediaHighlight query); it now exercises
the real SaveHighlight path through the wired service.
2026-10-17 16:36:58 -04:00
John O'Keefe 00305ffc7b chore(db): retire the nine dead annotation plain queries + duplicate SQL line
H3 leftover: the annotation handlers route everything through the sync
service (dedup/LWW/tombstones), leaving the plain Create/Update/Delete
queries for media_notes/media_highlights/media_bookmarks with zero
callers repo-wide (handlers, services, tests). Queries.sql drops them
so H4's row work doesn't carry dead weight; sqlc regen compiles clean,
proving nothing referenced the generated stubs.

Also removes the literal duplicate `AND mi.missing_scan_count = 0`
line in the library-stats query (harmless but confusing).
2026-10-17 16:30:38 -04:00
John O'Keefe edb4e60679 fix(annotations): LWW applies coalesce omitted fields — partial payloads no longer erase stored locators
Caught by the live round-trip sweep: a dedup-key re-save (a device echo
or a client re-POSTing a bookmark it already had) carried no locator
fields, and the full-column sync update wrote zeroes — which
pgFloat8/pgInt4 turn into NULLs — erasing the stored
percentage/page/cfi on every echo. The highlight stack had string
coalescing for exactly this reason (its locatorRefresher doctrine
comment); the numeric columns and the two cloned stacks never got it.

One primitive now carries the whole doctrine: coalesce[T comparable]
(zero value -> stored value), applied at the single write point of
each LWW apply — highlight (percentages, color, note_text,
chapter_reference added to its existing string coalescing), bookmark
(page/chapter/percentage/epubcfi/chapter_reference added to its
cfi/position pair), note (all locators). The redundant pre-coalescing
in the id-targeted resolution blocks is removed — the apply is the one
coalesce point, so dedup-path and id-path edits behave identically.

Verified live on the rebuilt container: dedup re-save and legacy
3-field PUTs preserve every stored locator on all three stacks; an
explicitly-sent field still lands. Pins, H1/H3 tests, unit suites
green.
2026-10-17 16:03:49 -04:00
John O'Keefe 11609d13d1 refactor(annotations): one LWW decision core + one tombstone gate + typed queue decoders
The consolidation half of H3 — shared LOGIC with thin per-type remains:

- lwwDecision: the three compareIncoming* functions were the same
  skeleton (client timestamp -> strictly-newer wins; no timestamp ->
  any content difference wins) drifting apart only in per-type
  equality and the schema-driven fallback timestamp (bookmarks carry
  no updated_at). The decision now lives in one function; each compare
  keeps only its equality expression.
- idTargetOwnership + tombstoneWins: the id-targeted resolution blocks
  (HighlightID/BookmarkID/NoteID) shared the ownership check and the
  deleted-row gate — now one gate each instead of three copies of the
  resurrect semantics.
- queue.go: syncNote/syncHighlight/syncBookmark decoded device payloads
  through three 10-key if-type-assertion chains; typed map accessors
  (mapStr/mapF64/mapI32/mapDeviceSyncData) replace them — same
  missing-key-is-zero semantics, declarative field lists. The progress
  decoder is untouched (H1 territory, separately tested).

Per-type remains are the things that genuinely cannot merge: request
structs, dedup triples, the per-table sqlc param builders, and the
KOReader/Kobo payload adapters. Pins, H1/H3 tests, both unit suites
green.
2026-10-17 15:56:24 -04:00
John O'Keefe 7d698ea0d8 fix(annotations): bookmark and note PUTs land through the sync service (LWW by id)
H3 census bugs #2 and #3: both web PUTs used plain queries that
bypassed the sync-aware layer entirely — no LWW, no source bookkeeping,
and locator fields the client couldn't even send. The highlight PUT was
the only one already routed (via HighlightID); the other two now mirror
it:

- SaveBookmarkRequest gains BookmarkID, SaveNoteRequest gains NoteID:
  id-targeted resolution reads the exact row, verifies ownership,
  LWWs under its stored dedup key, and resurrects tombstones when the
  edit is newer (same doctrine and comment structure as the highlight
  path).
- Fields the PUT omits are coalesced from the stored row before the
  full-column sync update — the legacy web PUT sends only
  title/notes/position, and zeroing cfi/percentage/page on every edit
  would have been data loss. Verified both directions by
  TestH3_BookmarkPUTPreservesLocators (omitted survives, explicit
  lands).
- The bookmark PUT's wire DTO gains the create endpoint's locator keys
  as optional fields (documented additive request keys in
  contract_pins.md); the note PUT keeps its content+position wire.

UpdateMediaBookmark/UpdateMediaNote plain queries are now uncalled
(the generated queries stay until the H4 query sweep).
2026-10-17 15:53:12 -04:00
John O'Keefe c579179fee refactor(annotations): AnnotationService is a required constructor dependency
H1's doctrine applied to the annotation service, which had been left on
setters: NewMediaHandler/NewKoboHandler/NewKOReaderHandler/
NewSyncQueueProcessor(WithConfig) now take it as a parameter; the four
SetAnnotationService setters are deleted; wiring updated (main.go,
router/sync.go, test harness).

With the service unskippable, the 18 nil-guards and their plain-query
fallbacks die:
- media.go: the create fallbacks for notes/highlights/bookmarks (the
  bookmark one was the H3 census's Bug #1 — it dropped
  percentage/chapter_reference the sync path carries), the highlight
  PUT fallback, and the three delete-path plain deletes (tombstone is
  now the only delete semantics).
- kobo.go: both markup-ingest annotation loops (the bookmark fallback
  wrote the text into media_notes — a category error the fallback
  never should have had).
- koreader.go: all three push-stack guards.
- queue.go: the three "annotation service not available" errors in
  syncNote/syncHighlight/syncBookmark.

Behavior note: the deleted highlight create/PUT fallbacks were the only
paths passing note_id to the plain query — the sync service has no
NoteID field, so the web's highlight→note link was already being
dropped on the live path; this commit makes that pre-existing
limitation explicit rather than papering it over (surfacing note_id on
SaveHighlightRequest is left for a behavior commit that needs it).

Pins (now 20 incl. the 3 annotation CRUD suites) + H1 tests + both
unit suites green.
2026-10-17 15:47:57 -04:00
John O'Keefe ff029f1ad5 fix(annotations): note LWW re-save 500'd — UserID missing from the sync update params
Pin-first batch for H3: freeze web annotation CRUD response shapes
(create 201 / update 200 / delete 204; full sqlc row models) before
the stack consolidation touches anything.

The new pins immediately caught a live server bug the 28 unit tests
could never see (annotations_test.go is pure-unit: keys and compare
logic, no DB): applyNoteLWW built UpdateMediaNoteForSyncParams without
UserID, so the query's WHERE id=$1 AND user_id=$13 compared against
zero — every dedup-hit note re-save (same content+position) returned
500 "update note: no rows in result set" and the update never landed.
The bookmark twin passes UserID and works; the note stack is the clone
that dropped the field.

Fix: pass UserID in the params (one line). Pins go green and become
the regression net; changelog records the new surfaces.
2026-10-17 15:38:47 -04:00
John O'Keefe b2b89ba789 feat(cards): stamp active conflicts on collections, series, and preview JSON
The last behavior gap in the H2 batch: has_conflict was part of the
frozen card shape but stamped false-forever on these three JSON
surfaces (only dashboard sections, search, and the SSR pages had it),
so every conflict-driven card action — the resolve-conflict CTA in
BookCard — never fired there. MarkActiveConflicts now runs after the
builder on GET /api/collections/:id, series books, and smart-collection
preview (all user-scoped; the endpoint already authenticates).

Key set is unchanged — has_conflict was always emitted, this only
makes its value truthful. All 15 contract pins + H1 tests green; the
pin's bookInfoKeys freeze unaffected.
2026-10-17 14:08:45 -04:00
John O'Keefe a13b268adc refactor(cards): SSR collection detail ladder through the builder
All five branch shapes of GET /collections/:id now build cards via the
cards.go adapters: smart (BookCardsFromMediaItems), system-query (same
— gains format_group/badge it previously dropped), scoped
(BookCardsFromCollectionDashRows with the Excluded filter kept), and
the all-libraries branch whose rows distinguish ID vs MediaItemID
(BookCardsFromCollectionItemRows). LibraryTypeMap hoisted to once per
page; both []BookInfo{} error seeds die with the adapters' non-nil
empties.
2026-10-17 14:06:39 -04:00
John O'Keefe bdad40e211 refactor(cards): bookshelf SSR through the builder — badges return to the bookshelf
The ExecuteSearch-driven bookshelf page fed SearchMediaItemsUnifiedRow
— which HAS FormatGroup and LibraryTypeName — into a bare 4-field
literal that dropped both, so bookshelf cards lost the media-type
badge the shelf once had. BookCardsFromUnified emits badge/format on
this surface (card-marking rides on; the SQL column strategy means
zero extra queries). audiobook shelves regain the corner glyph.
2026-10-17 14:05:35 -04:00
John O'Keefe 8c56c9da55 refactor(cards): SSR series and tag pages through the builder
Both BrowseDetail surfaces hand-rolled bare 4-field literals (badges
dropped) and patched the nil-slice with []BookInfo{} seeds after.
BookCardsFromMediaItems feeds GetSeriesBooks/GetBooksByTag rows —
cards gain format_group + badge on the series and tag pages, and the
two seed blocks die with the adapter's non-nil empties. Conflict
stamping at these sites was already adjacent and is preserved.
2026-10-17 14:05:09 -04:00
John O'Keefe 0971e7debb refactor(cards): template search grid through the builder
router/search.go (the templ BooksGrid fragment surface) now uses
BookCardsFromUnified — the unified row's LibraryTypeName column gives
the badge with no LibraryTypeMap query; also drops that site's
needless pgtype.UUID byte-roundtrip on the cover URL.
2026-10-17 14:04:36 -04:00
John O'Keefe 5adfb459d7 refactor(cards): BuildSections through the builder
The dashboard section builder — feeding both the dashboard JSON pin
surface and the SSR dashboard — now calls BookCardsFromMediaItems on
ds.Items. libTypes already arrives as a BuildSections parameter
(once per request); the literal only duplicated the badge lookup.
2026-10-17 14:04:14 -04:00
John O'Keefe 2f9d5b8e45 refactor(cards): series books JSON through the builder
GET /api/series/detail?name=… books array via BookCardsFromMediaItems.
Series had the third LibraryTypeMap call INSIDE its per-item loop —
ListLibraries ran once per book on the page; now once per request.
2026-10-17 14:03:41 -04:00
John O'Keefe c07dba4eff refactor(cards): smart-collection preview through the builder
POST preview-feed had the ladder's 14th hand-rolled literal. Now
BookCardsFromMediaItems + one LibraryTypeMap per request; the response
carries format_group/badge values for the first time (all seven keys
were already emitted as zero-value JSON, so this is a value change on
a pin-frozen shape, not a key change).
2026-10-17 14:02:43 -04:00
John O'Keefe 23765934ef refactor(cards): collections JSON detail + preview-adjacent ladder through the builder
GET /api/collections/:id books — all four branches now build cards via
the cards.go adapters. LibraryTypeMap hoisted to once per handler (it
was 1 query per item in the smart and no-library branches). The system
branch — which before stamped cards BARE (no format_group, no badge —
the ladder's only JSON gap) — now emits the same card shape as its
siblings by feeding section.Items through BookCardsFromMediaItems,
matching what BuildSections produces for the identical data. The
collection-error seed dies via the adapters' non-nil empty slices;
the no-matching-section nil result is preserved as-is (that branch
never seeded — today's 'items': null stays today's 'items': null).

Pins: CollectionsJSON, DashboardSections, MediaListBothBranches green.
2026-10-17 14:02:16 -04:00
John O'Keefe 1ce1b79489 refactor(cards): H2 foundation — cards.go, the one BookInfo construction site
BookInfo, LibraryTypeMap, and MediaBadgeKind move verbatim out of
collections.go into handlers/cards.go (the card package surface, same
package: no import-graph or templ changes), joined by the H2 builder:

- buildBookCard (unexported): the ONLY place BookInfo is constructed,
  fed by cardSource; cover URL via ResolveMediaURL, badge via
  MediaBadgeKind — both inside the builder, not per call site.
- Four exported adapters normalize the four real feed shapes:
  database.MediaItems (sections/smart/series/tags/preview),
  GetCollectionItemsForDashboardRow (collection dashboard branch),
  GetCollectionItemsRow (collection-items joins — MediaItemID field),
  SearchMediaItemsUnifiedRow (badge rides its LibraryTypeName column,
  so no LibraryTypeMap query at all).
- Adapters return non-nil empty slices, which retires the per-site
  []BookInfo{} empty-seed assignments when call sites migrate.

No call site changes in this commit — the 20 literals and 5 seeds are
migrated one surface per commit next, each with the H0 pin suite.

Verified: build, vet (only the two pre-existing flagged saveCoverImage
warnings), and 14/14 contract pins — the key freeze is byte-identical.
2026-10-17 14:00:34 -04:00
John O'Keefe de9e66636a docs(agents): test-failure triage discipline — suspect the test first, but test edits stay asymmetric
2026-10-17 lessons from the hardening session: most test/bruno failures
were harness/schema/environment drift (drifted body keys, truncated
payloads, container-only paths), found by triaging the test before the
server. Codified as: triage order (test schema -> environment -> cheap
probe), with the guard against "writing tests to work" — test/contract
edits are asymmetric with code fixes: only harness plumbing fixes,
additive pin widenings with a changelog entry, or deletions of tests of
removed behavior. Pins were green at a known commit; git history
arbitrates stale-vs-changed. A right-looking test that fails is prima
facie a server bug: stop and flag.

contract_pins.md rule 4: narrowing/removing a pinned key requires the
user's explicit sign-off — a pin freezing buggy behavior is a ledger,
not a license.
2026-10-17 12:39:59 -04:00
John O'Keefe 14cdbe70eb test(contracts): CFI/context winner must land intact through conflict resolution
Book-side twin of the audio-survival test, closing H1's verification
list. Scenario: web push carries percentage + epubcfi + context_text;
a koreader-style percentage-only push from a second source within the
window detects a conflict; the user resolves with the CFI-carrying
side. The funneled resolution must land the winner's ENTIRE locator
pair — percentage 0.5, epubcfi, context_text together (the old direct
writer NULLed context_text and character_offset, breaking the coherent
pair the far-anchor work established) — and must not spawn a duplicate
pending conflict.
2026-10-17 12:22:36 -04:00
John O'Keefe 09b6693505 fix(bruno): convert drifted body keys to body.data — CLI scenario runs now carry bodies
Follow-up to the sweep route audit, correcting its diagnosis: the CLI
DOES read .yml bodies — via OpenCollection's `body.data` string form
(the NewDevDBSetup script always worked because it used it). The
failing scenario POSTs used `body.json` (structured) / `body.jsonBody`
(string) — keys the CLI silently ignores, sending Content-Length: 0
with form-urlencoded content-type, so every server bind failed.

- 81 bodies converted to data-form (json maps serialized; jsonBody
  strings kept verbatim, {{vars}} intact).
- 6 pre-existing TRUNCATED payloads repaired: the original generator
  cut bodies at the first {{var}} (every SHA-256-identified scenario
  plus Sync Progress/Sync Bookmarks shipped mid-JSON and 400'd from
  day one). Rewritten whole, faithful to each file's documented
  purpose; book_sha256/book_uuid added to the environment.
- environments: opds_base_url correction from the sweep retained.

Verified live against the rebuilt dev container: the full koreader
scenario tree runs green through `bru run` — 14/14 (202s across
immediate/checkpoint/SHA-256 progress, 200s bookmarks, highlights,
notes; SHA-256 book identification exercised against a real
file_sha256).
2026-10-17 12:22:35 -04:00
John O'Keefe 63a20dcc95 chore(db): sqlc regen — querier interface comment for the flush-enumerator fix 2026-10-17 11:49:28 -04:00
John O'Keefe a85bc8af9b test(bruno): sweep the collection against the actual route table
Audited every request (247 .yml files) against the routes the server
actually registers (extracted from internal/router, group prefixes
resolved). Results:

- 44 stale requests: deleted where the endpoint no longer exists
  (universal-progress GET/PUT /api/progress/:id — routes never existed,
  the handlers were deleted in H1; queue stats/clear-failed/process;
  admin profile page; auth/account) and FIXED where the endpoint exists
  under a different path (kobo token-in-path scheme: every
  /api/sync/kobo/<seg> and /api/v1/kobo/<seg> URL now carries
  {{kobo_device_token}}; /sync/* missing their /api prefix; koreader
  /api/v1/koreader/sync/* -> /api/sync/koreader/*; link-book and
  unlinked-books moved to /api/devices/:id/sync/*).
- koreader Sync Highlights/Sync Notes pointed at endpoints that never
  existed with a body shape to match — rewritten against POST
  /api/sync/koreader/bookmarks (the annotations funnel carries
  highlights and notes arrays).
- 9 files the GUI refused to load: 4 had bruno-invalid script type
  "tests" (converted to after-response), 4 were info/type:collection
  doc files with bogus http blocks (converted to folder.yml folder
  docs), 1 had a dead pre-YAML schema and no route (deleted).
- devices/koreader requests now carry request-level bearer auth
  ({{koreader_device_token}}) — they inherited the collection's user-JWT
  bearer, which device endpoints rightly reject — and their bodies
  reference {{media_item_id}} instead of dead placeholder uuids.
- environments: opds_base_url was empty (every OPDS request missed the
  /opds prefix); set to http://localhost:8765/opds and un-doubled the
  prefix in opds/scenarios.

Known limitation, for the handoff: bru CLI cannot execute .yml request
bodies (the collection is GUI OpenCollection format; the CLI runner
reads bodies from .bru files only). A .bru migration is the remaining
bruno work if CLI runs are wanted.
2026-10-17 11:49:28 -04:00
John O'Keefe 6808c1babb fix(sync): the offline queue flush never processed anything — fix the enumerator + the dropped payload
Found while verifying H1's "app offline-play -> flush -> position lands"
with the emulator path: the flush has been dead code in two independent
ways, invisible because the first break made the second unreachable.

1. ListPendingSyncQueueItems matched `device_id = $1` with a NULL $1
   (the processor passes DeviceID{Valid:false} to enumerate all devices;
   uuid = NULL matches no rows). The ticker therefore processed nothing,
   ever. The WHERE now treats NULL as "every device" (the same
   narg::uuid IS NULL idiom the visibility queries use; EXPLAIN'd — seq
   scan on a bounded table, idx_sync_queue_status_priority_created
   fits). The processor also dropped its fake-pagination loop, which
   re-fetched the same first batch forever once the backlog hit the
   batch size (no OFFSET in the query).
2. dbItemToSyncQueueItem dropped SyncData — even with a working
   enumerator, every flushed item arrived with an empty payload and
   failed JSON unmarshal. Dead code harboring silent breakage, exactly
   the plan's thesis.

Callers updated for the renamed RowLimit param (queue handler, offline
detector). TestH1_SyncQueueFlush_LandsAndPreservesAudio drives the real
flow: web PUT seeds audio 900s, a pending checkpoint-style queue row
applies percentage 0.8 through SaveProgress, audio survives, queue row
completes.

Also fixes dashboard_test.go's stale BuildSections signature (the
package's test binary has been unbuildable) — user-approved: tests
designed against old shapes are not worth keeping.
2026-10-17 11:45:05 -04:00
John O'Keefe d16ca4acbe fix(tests): scope the sync_queue cleanup wipe to the test devices
The cleanup in setupSyncTestDB ran DELETE FROM sync_queue WHERE true —
a table-wide wipe that discards REAL devices' pending offline entries
on every suite run. Same class as the 2026-10-14 reading_history
incident the standing rules ban. The test's own queue items cascade
from its 'Test %'-named device, so the wipe is redundant for cleanup —
scoped it to those devices (defense in depth ahead of the cascade).

Flagged during H1 verification: the line executed during an in-
container suite run this session; any device with pending offline
queue entries at that moment should re-push on its next sync.
2026-10-17 10:32:00 -04:00
John O'Keefe 721139dc16 chore: delete dead progress handlers, no-op scar, split fused helper line
Batch H1 step 4:
- Handler GetUniversalProgress (progress.go) deleted — no route mounts
  it; only /api/progress/:id/history (GetProgressHistory) and the SSR
  /progress page exist. Note: the bruno universal-progress suite
  documents GET/PUT /api/progress/:id + /api/progress/device/:device_id
  — those routes do NOT exist server-side; that suite is stale
  documentation, flagged in the plan.
- Handler GetAllProgress deleted — unregistered near-copy of
  GetAllProgressData (which the /progress SSR page uses and stays).
- series.go: dropped the `_ = libTypes` no-op (libTypes IS used by the
  Badge line right below — the assignment stays).
- router/helpers.go: visibleLibraryIDsFor's signature and first
  statement were fused onto one line; split for readability. No
  behavior change.
2026-10-17 10:29:00 -04:00
John O'Keefe 8fc74c908a fix(conflicts): resolution writes funnel through SaveProgress — audio position survives
Batch H1 step 3b — THE data fix. Conflict resolution was the one live
erasure writer: applyProgressResolution and its verbatim twin
applyResolution both wrote the winner via a raw UpdateUniversalProgress
whose param list omits audio_offset_seconds/context_text/viewports, and
the SQL's DO UPDATE SET ... = EXCLUDED... NULLed exactly those. The web
UI resolves through POST /api/conflicts/:id/resolve and
/api/conflicts/bulk-resolve (main.js resolveConflict/bulkResolve), and
the reader's mark-as-read fires a resolution first when the reader SSR
stamps data-conflict-id/-winner — so every resolution today erased the
audiobook position and reader context.

Now: ProgressService is a required ConflictHandler dependency; BOTH
call sites (single resolve + bulk resolve) share one
applyProgressResolution that funnels through SaveProgress (the verbatim
twin is deleted). Deliberate choices, per the plan:
- SkipConflictDetection on the resolution write: both flows apply
  BEFORE the conflict row is marked resolved, so the funnel's LWW
  detector would otherwise file a duplicate pending conflict
  (HasRecentConflictResolution is false at apply time).
- Source "conflict_resolution" — the row's last-write attribution; the
  winner stays durably recorded in resolution_data.winner.
- Broadcast: true — live devices jump to the winning position.
- The winner snapshot maps percentage/epubcfi/context_text/chapter/
  character/page/total_pages; a winner without a CFI clears the locator
  pair (ClearLocator) instead of re-pairing with the loser's stale
  anchor. Everything the snapshot doesn't carry SURVIVES via the LWW
  seed: audio_offset_seconds above all. chapter_progress keeps its
  stored value (was overwritten with the book percentage), an absent
  percentage survives (was written as 0%).
- Accepted: the resolution captures one reading_history session
  (attributed to conflict_resolution; the 2h idle ceiling applies).

Verification: TestH1_ConflictResolutionPreservesAudio drives the real
flow — web push with audio 300s, diverging push from a second source
detects a conflict, resolve with the second source as winner, GET shows
percentage 0.3 AND audio_offset_seconds 300 (previously NULLed), and no
duplicate pending conflict remains. Full contract pin suite green.
The host-run TestConflict*/TestProgress* failures are environmental
(container-only /app/uploads path + db hostname; fail identically at
baseline b4ae423) — the same suites pass in the test-runner container.
2026-10-17 10:26:22 -04:00
John O'Keefe e83c00f869 refactor(progress): ProgressService required at construction; delete the 9 unreachable erase-fallbacks
Batch H1 step 3a. The ceiling census (exhaustive UpdateUniversalProgress
grep) found 9 nil-guarded `if progressSvc != nil { funnel } else { raw
erase-write }` fallbacks — the survey's "four" under-counted kobo.go,
which has FOUR sites (Markup ReadingSync, Markup last-read-place,
AnalyticsGettests, SyncFromServer), plus one each in media.go, queue.go
and koreader.go. All unreachable: the service is created unconditionally
at main.go:127 and wired into every handler and the queue processor; the
test harness wires them too.

The erasure pattern is now unrepresentable:
- NewMediaHandler / NewKOReaderHandler / NewKoboHandler / both
  NewSyncQueueProcessor flavors take the ProgressService as a REQUIRED
  constructor arg; the SetProgressService setters are gone.
- Every fallback body (raw UpdateUniversalProgress with explicit-NULL
  pgtypes for viewport/scroll/panel/reading_mode/zoom and no
  audio_offset_seconds/context_text) is deleted; each write funnels
  through SaveProgress with the LWW seed, so fields the writer doesn't
  know survive.
- Removed the now-dead koreader pg-type helpers (textPtrToPgText,
  intPtrToPgInt4, int64PtrToPgInt8) and their self-tests.

Behavior deltas (stored-row content, no wire shape changes —
KOReader/Kobo response pins stayed green):
- KOReader fallback used to write explicit NULL viewports and drop the
  context_text the koplugin sends; the funneled mainline already maps
  book.ContextText.
- Queue flushes no longer overwrite chapter_progress with the book
  percentage (bogus data; preserved via the seed).
- A web PUT without percentage no longer writes 0% via the deleted
  fallback; percentage seeds from the stored row.

Contract suite: KOReader×4 + Kobo×3 pins green.
2026-10-16 17:28:06 -04:00
John O'Keefe 28bd323505 refactor(progress): SaveProgressRequest becomes the canonical PUT payload; GET serializes from the row
Batch H1 steps 1-2 of the hardening plan. Wire contracts frozen; the
pinned shapes are unchanged modulo the whitelisted additive GET keys
(docs/contract_pins.md).

1. One canonical request struct. The web PUT binds SaveProgressRequest
   directly (identity/attribution fields are json:"-" so they stay
   server-owned) and copies its server fields once — the hand-rolled DTO
   and the 20-line field-copy block are gone. Parse set for existing
   clients is IDENTICAL to the old DTO (verified in web/src: the ebook
   reader sends percentage/current_page/total_pages/reading_mode/epubcfi/
   context_text/chapter/zoom_level — reader.ts:3113-3143; the audiobook
   reader sends percentage/chapter/audio_offset_seconds/elapsed_seconds/
   reading_mode — audio.ts:518-520).

2. GET /api/media-items/:id/progress now serializes through one
   row-struct-driven builder instead of a hand-map, so a row column
   cannot be silently forgotten again (the audio_offset_seconds
   omission). Existing keys are verbatim; the row already carried
   audio_offset_seconds/reading_mode/viewport_x-y/zoom_level/
   scroll_position_x-y/panel_number and they surface as WHITELISTED
   additive keys. The web audiobook reader already reads
   audio_offset_seconds (audio.ts:219) — with the key absent,
   durationless books resumed at chapter start (offset always 0); they
   now resume correctly. The conditional context_text bolt-on and the
   server-derived css_selector/anchor_href block are unchanged —
   restore precedence stays percentage-first, never re-derived.
2026-10-16 17:22:32 -04:00
John O'Keefe a2ea34232c test(contracts): pin wire shapes before the canonical-types migrations
Batch H0 of the hardening plan: freeze the JSON key SETS of every
load-bearing surface so H1-H7 can only change them additively.

- cmd/server/tests/contract_pins_test.go: 14 tests / ~30 subtests pinning
  progress GET+PUT, the KOReader plugin surfaces (progress push/pull,
  library, bookmarks), the Kobo adapter shapes (initialization, markup,
  sync-from-server), collections detail all four branches + preview,
  series JSON, search rows, dashboard sections, media list BOTH branches
  (raw sqlc rows vs resolved 38-key map), and SSR smoke for
  bookshelf/collections/series/tags.
- docs/contract_pins.md: the pinned-shape inventory + the rule that a
  batch widening a shape must widen the pin and this doc in one commit.

Suite is green on the unmodified tree at b4ae423. KOReader/Kobo/progress
pins are the hard freeze (the plugin is an external client).
2026-10-16 16:02:14 -04:00
John O'Keefe b4ae42326a fix(stats): unambiguous chart x-labels + unit-carrying tooltips
The daily charts' x-axis labels were bare MM-DD ('10-15', '10-16') — on
the Listening per Day chart (y in hours) those read as hour values, which
is exactly the '10-16 hours a day' misread that prompted this. Labels now
spell the month ('Oct 15') and every chart's tooltip carries its unit
(min / pages / hrs). main.js?v=5
2026-10-16 11:53:56 -04:00
John O'Keefe ce366b1604 feat(stats): Library tab — per-library inventory stats + audio-aware Reading tab
The /stats page gains a second tab (Reading | Library) implementing the
Audiobookshelf-style library dashboard (HANDOFF item 9): media_items
INVENTORY aggregates — items, hours/pages, distinct authors, size,
distinct narrators, Top Genres / Top Authors bars, Longest / Largest
items — for one library or the All-libraries aggregate.

- GET /api/stats/library-usage?library_id=<uuid|omit>; family lens
  enforced: a library_id outside the requester's visible set is a 403,
  never a silent pass (AGENTS.md standing rule; 431fec0 history)
- six sqlc queries with the visibility pattern + healthy-item filter
  (archived_at IS NULL AND missing_scan_count = 0); hours_mode lets the
  client swap the Hours/Pages card and the Longest ranking per scope
- Reading tab unit audit (HANDOFF item 3, settled 2026-10-16): minutes
  are the merged currency everywhere honest; only the PAGES unit splits
  — Pages card goes mode-aware (Pages · text / Listening Time in an
  audio-only pool) and the charts row gains Listening per Day (hrs)
  when audio minutes exist in the range; summary daily buckets gain
  audio_minutes split by the item's library type
- tab state in the URL (?tab=library&library_id=…); selector fed by the
  existing /api/libraries/visibility endpoint; main.js?v=4
- integration tests: family-lens 403 pin, hidden-library exclusion
  (differential), seeded text+audio aggregate correctness, auth gate
- docs/developer/api/stats/get_library_usage.md

Known-remaining suite failures are environmental: TestConflictList_Empty
+ TestWebSocketPingPong collide with the concurrently-running app session
(live emulator pushes + scans on the shared dev DB), and
TestAdminCreateUser's timezone expectation drifted from live system
settings — all three fail on the clean tree too.
2026-10-16 10:20:17 -04:00
John O'Keefe a222ef5c27 fix(media-items): normalize NOT NULL narrators on every write path
A2 made narrators/narrators_search NOT NULL DEFAULT '{}' (86f0af6), but an
explicit NULL in an INSERT/UPDATE column list bypasses the column default
— the API media-create path (POST /api/media-items without narrators)
500'd on every call, taking the whole integration suite down with it
(ISBN tests, capture seeds, all direct-DB seeds).

- set_library_type_name() trigger now coalesces NULL narrators/
  narrators_search to '{}' at the row level, and its update trigger
  widens from BEFORE UPDATE OF library_id to BEFORE UPDATE so every
  write path is covered
- defense in depth at the callers: handler create/update normalize nil
  to empty slices (normalizeNarrators), scanner seeds the same
  (nilToEmpty) for files that carry no narrator credits
- analytics device-usage tests move to the dedicated per-test user
  (the shared admin's device list accrues live rows mid-suite — the
  documented testuser flapping class)
2026-10-16 10:14:12 -04:00
John O'Keefe 73ae1789c5 feat(reader): fullscreen mode; revert stacked volume; fix manga badges + ghost sidebar
Reader fullscreen: the topbar gains a fullscreen toggle (icon state + f
shortcut + help row; f is guarded so Ctrl/Cmd+F stays find-in-page).
Enter requests the Fullscreen API with fallbacks (desktop/Android get a
true full display; iOS Safari has no page-fullscreen API and falls back
to the soft visual mode only). Margins: the foliate paginator's closed
shadow root only yields through its observed 'margin' attribute, so
fullscreen fits a comfortable ratio (6% of the short side, floored at
48px) and floors it by the measured env insets — Android reports true
cutout insets in fullscreen, so portrait phones keep text clear of the
camera band; iOS letterboxes are already env-padded chrome. Fixed-layout
renderers skip it (fxl/webtoon pack pages edge-to-edge by design).
Exits the user makes outside the toggle (Esc, browser UI) sync via
fullscreenchange(+webkit) and restore the ordinary 48px margin; a
position-keeping re-render follows the ResizeObserver re-entry path.
Verified on-device: enter -> true fullscreen + icon swap + camera band
clear, exit -> chrome back, margins restored, position kept.

Volume: revert the stacked panel — the popup returns to the liked shape
(mute button left, horizontal dragger right, dialogue above the strip);
one-line flex restore of the pre-89665fb layout.

Manga badges: MediaBadgeKind makes the library type authoritative for
the comic/manga split — manga volumes ship as fixed_layout scans as
often as comic_archive (and manga_type is unreliable across sources:
unknown/NULL on real rows), so the old comic_archive+manga mapping left
the scan volumes unbadged. Anything in a manga library reads manga;
anything in a comics library reads comic. Verified: /dashboard lands 24
title="Manga" cover badges for the Manga library and the sections API
reports badge=manga for a fixed_layout item.

Ghost sidebar: the >=1024px media block applied body
padding-left:var(--sidebar-w) unconditionally, so pages that never
render the sidebar (logged-out pages, the audio player, the reader
itself) carried a phantom 16rem left margin that pushed content off
center on desktop widths. The body:has(.app-sidebar) rule below was
already the correct conditional source — it is now the only one.
Verified in the built stylesheet: exactly one such rule remains.

Infra: main.js ?v=3 stamp bump (reader.ts changed; hashed chunks churn
their URLs themselves).
2026-10-15 20:27:37 -04:00
John O'Keefe 89665fb2bc feat(annotations + player): bookmark rows in the banner, stacked volume panel, badge parity for comics/manga
Annotations banner: audio/text bookmarks join notes and highlights —
third row kind (bookmark glyph indicator, H:MM:SS / Page N / Chapter N
friendly labels), edit-note/delete actions branch by kind, and the
banner's export now covers all annotations. The archived ledger
(ListDeletedAnnotationsForBook) already tombstoned bookmarks equally
and Restore/Purge take annotation_type=bookmark — verified live:
delete → restore brings the row back with its note intact.

Volume: the dragger sits BELOW the mute icon in the panel (stacked
flyout shape) with the horizontal slider from the last round.

Badges: the real fix for comics/manga — format_group only knows
'comic_archive' (manga_type is unreliable across sources), so the
comic/manga split is resolved from the LIBRARY type server-side.
handlers.MediaBadgeKind + BookInfo.Badge at every construction site
(dashboard SSR + sections API, collections, series, and the bookshelf
search grid — which was also missing FormatGroup entirely, so no card
ever badged there). Both renderers (templ + dashboard.ts) switch on
the resolved badge. Verified: All-Books grid renders 2 Comic + 3
Manga badges, dashboard the audiobook one.

Infra: header.templ main.js src gains ?v=<stamp> — browsers keyed an
intermediate bundle by URL and served it across rebuilds (the stale
'Add note' the user saw); bump the stamp when JS stale-shadows.
2026-10-15 19:33:18 -04:00
John O'Keefe 2c714dcb60 fix(audio player + dashboard): markdown bookmark notes, horizontal volume, transport glyphs, client-side badges
Bookmarks: notes are markdown now — capture and drawer edit are
textareas (raw source), display renders through the reader's
markdown-it + DOMPurify pipeline (renderNoteHtml) as x-html; the
edit textarea drops the enter-to-save handler so newlines survive.

Volume: the panel dragger is horizontal (mute button beside it,
7.5rem slider) per user feedback — vertical orientation read as a
scrubber clone at a glance.

Play/pause: unicode glyphs replaced with SVG — the triangle is
optically centered (2px right-bias) and the pause bars are longer
and thicker rounded rects (4.2×15 at rx 1.7).

Dashboard: the client-side card renderer (dashboard.ts — the path the
JSON sections actually render through) now mirrors the templ BookCard:
cover-corner badges for audiobook/comic/manga (books unbadged) and the
hover action shows headphones with 'Listen to …' labels for audiobooks.
BookInfo type + Go struct carry format_group end to end.

Verified on-device: pause glyph centered/thicker, horizontal volume
panel, markdown note ('**bold**' + list) rendered in the bookmarks
drawer, badges on dashboard cards, sections JSON carrying
format_group.
2026-10-15 18:49:42 -04:00
John O'Keefe 5cf34102d6 feat(audio player): canonical audiobook transports + dropdown settings + volume panel
Transport row is now the five-button audiobook shape the user asked
for: previous-chapter · circular-skip-back (seconds displayed inside
the ring) · play/pause · circular-skip-forward · next-chapter — the
ring arrows are stroke SVGs (rotate-ccw/rotate-cw) with the number as
an overlay so it stays upright and follows the skip setting.

Settings strip collapses to four controls: Skip dropdown, Speed
dropdown, Bookmark (capture popover unchanged), Volume. Volume opens
a panel with a vertical dragger (writing-mode:rtl slider) and a
mute/unmute button; the strip icon is dynamic — 1–3 arcs by level (low
&lt;34%, mid &lt;67%, high) and crossed when muted or zero. Volume/mute
persist in localStorage and restore with the element; unmute floors
zero-volume to 50% so unmuting is audible.

Verified on-device: dropdowns open upward with current value
highlighted, mute flips both icons to the crossed state, slider
drag changes arc count, chapter buttons jump (pill → 15/34) and the
caption tracks the drawer's chapter labels.
2026-10-15 18:35:39 -04:00
John O'Keefe 86f0af632e feat(audiobooks): A2 — narrator metadata, audio-aware UX, player polish, bookmarks
A2a (metadata round): narrators TEXT[] + narrators_search (tags
pattern: accumulator + GIN pairs) + abridged bool on media_items;
scanner fills from the Libation/ABS sidecar (narrators verbatim,
abridged flag) with audio-payload promotion; editorial fields ride
the real columns while audio_metadata stays scan-derived (duration/
codec/chapters). Detail/edit/save paths carry the fields; the web
edit dialog grows an Audio section (Narrator(s) comma-input, Abridged
toggle, scanned duration read-only); search weights + narrator filter
(word_similarity over narrators_search) + narrator autocomplete added.

A2c (per-media-type wording): copy helpers keyed on format_group —
Listen Now, Mark Complete/Not Complete, Listening Progress, Your
Listening Stats, Time Listening, Time listened (pages tile swapped for
audio), Position H:MM:SS / total H:MM:SS on detail + progress pages
(duration parsed from audio_metadata). Dashboard: system collections
swap to Continue Listening / Recently Listened at DISPLAY time when
the audiobooks library is selected (stored names unchanged; restores
keep working); week card = Listening Time / Sessions / Books Touched;
cover-corner badges identify audiobook (headphones) / comic / manga
cards on mixed grids — books stay unbadged; hover action icon follows
the media type.

A2b (player): the scrubber binds value to the playhead with an explicit
scrub guard (the handle previously froze at its initial max); header
row = reader-style back arrow + Chapter n/total launcher; chapters
open in a floating top-down drawer (current chapter highlighted, page
never shifts); Skip/Speed rows share one label-left layout; honest
wall-clock session accounting (playing-only deltas, paused flush).

A2d (bookmarks): note-bearing time bookmarks ride the existing
media_bookmarks substrate (notes column + sync-aware service already
there) — audio locator convention audio:<seconds>, chapter_number
filled, auto-title '<chapter> — H:MM:SS', capture popover with optional
note, drawer with jump/edit-note/delete. Zero server changes needed.

Verified live on the Libation sample: narrators backfilled by rescan;
PUT edit persisted + overrides detected (narrators, abridged); force
rescan preserved the user edit; reset-to-scanned-of-defaults restored
truth; narrator filter/autocomplete scoped by library; dashboard
listsening wording + badges render; bookmark save with note deduped to
one row.
2026-10-15 18:03:35 -04:00
John O'Keefe 902cbe5915 feat(audiobooks): Batch A — foundation, m4b ingest, playback sync, web player
Phase 1 (foundation): 'audiobooks' library type seeded (schema + DB);
audio_metadata JSONB on media_items (the kobo_metadata precedent —
only audio paths read it); reading_progress.audio_offset_seconds for
the honest {chapter, offset} locator; extensions/mimes registered for
ALL breadth formats (m4b/m4a/mp3/flac — deliberate per the DOC/LIT
doctrine, m4b scans richly first); classifyFormatGroup 'audiobook'
case (gotcha 39 recomputes on rescan); Go mime table extended at boot
so media elements accept the streams; ffmpeg/ffprobe in the image
(8.1.2 — also the xHE-AAC identification tripwire).

Phase 2 (ingest): sidecar-first (Libation/ABS metadata.json —
narrators/ASIN/curated chapters), ffprobe fallback/filler (duration,
bitrate, codec/profile, embedded chapters). Fixed three real
parser brittlenesses the Libation sidecar exposed: series accepts
string OR array-of-objects, publishedYear accepts number OR string,
ffprobe chapter timings accept number OR string (flexFloat). Folder-
structure fallback now runs on the UPDATE path too (rescans were
clearing sidecar-absent series/author); audio facts re-derive on
every rescan (gotcha 39).

Phase 3 (playback + sync): GET /api/media-items/:id/chapters (the
format-abstraction list); streaming rides the existing cookie-authed
Range /uploads path; PUT progress carries audio_offset_seconds +
elapsed_seconds (honest wall-clock playing time) — the reading-
history capture prefers player-reported elapsed over the inter-push
delta, same 2h ceiling; reading_mode='audio' marks the row.

Phase 4 (web player): dedicated audio reader page (foliate not
involved) — cover, chapter title, whole-book scrubber, skip ± (chips
1/5/10/15/30s, default 10s), speed (0.75×–2×), chapter list with
jump-to, Media Session lockscreen/notification transport, periodic
playing-only progress pushes, position restore on open.

Verified live: ingest of the Libation sample (Gardens of the Moon —
title/author/series/cover/date from sidecar+folders, 34 chapters,
26h duration, aac/LC); chapters endpoint; the reader route serving
the player; playback with position restore (0:57 round-trip);
progress rows in audio mode; wall-clock session rows in
reading_history.
2026-10-15 16:14:34 -04:00
John O'Keefe a093529b80 chore(deps): pin foliate-js f622841 — manual touch scrolling in scrolled layouts
Release / build-and-push (push) Successful in 2m56s
Completes the reader-chrome UX batch (handoff item 7): the gitea
mirror now carries fork 0.5.4, so the pin replaces the temporary
node_modules patch the verification build used. Rebuilt and re-verified
live (chrome starts hidden; flick-down reveals).
2026-10-15 14:33:30 -04:00
John O'Keefe f2fd4db81c feat(web): reader chrome UX — intent-based reveal, reading actions dismiss (handoff item 7)
The chrome previously woke on ANY activity: pointermove (mouse drift
and, worse, the touch drag of a page turn itself), every keypress, and
every touch tap toggled it. Now reveal requires explicit UI intent and
reading actions suppress:

- Desktop: cursor into the top 10% / bottom 8% band raises the chrome
  (screenY-based so content iframes classify identically); content
  pointer movement never does. Mouse center-clicks no longer toggle.
- Touch: a vertical flick (dominant + fast) reveals; while visible,
  flick down OR up dismisses. Plain taps are fully inert — a stray tap
  can never flash the UI.
- Keyboard: page keys never poke; turning (keys, mouse edge zones, or
  a touch swipe via the snap-relocate) actively dismisses visible
  chrome.
- Highlight saves return to a clean screen: the selection flow owns
  the screen and the post-save pointer/keyboard pokes are gone.
- Chrome starts hidden; always-visible pin and auto-hide timer remain
  (bands use a shorter 1.2s fade).

Pairs with the foliate-js 0.5.4 paginator change (scrolled layouts
scroll manually so the flick gesture is observable in webtoon); the
fork pin bump follows once the gitea mirror shows f622841.
2026-10-15 13:44:19 -04:00
John O'Keefe 2517baedea feat(koreader): device bridge for ratings, reviews and reading-time stats
Release / build-and-push (push) Successful in 3m4s
Extends the device-authed koreader surface with the handoff item 4
Batch B contract:

- GET/POST /api/sync/koreader/rating — the device mirrors the .sdr
  summary's 5-star rating against the 1-10 server scale (POST 0 clears).
- GET/POST /api/sync/koreader/review — raw-markdown review mirror
  (summary.note); empty body clears, matching the first-party PUT.
- POST /api/sync/koreader/stats — the device pushes its CUMULATIVE
  total reading seconds; the server watermark (highest total ever
  converted, read from the koreader sessions' own device_metadata)
  converts only the delta into a reading_history session attributed
  to the koreader device. Reinstalls cannot double-count; sub-minute
  deltas ride forward (no session, no watermark movement); a lagging
  device clamps to zero instead of dragging the total back.

Book identity resolves through the existing priority chain (uuid →
sha256 → alias → title/author). GET binding uses query tags; POST
endpoints bind the ref+payload once (an earlier resolve-then-bind
draft drained the body).

Live-verified against the dev server with the koreader device token:
rating set/get/clear, review mirror, and the stats sequence
900→session, 960→delta session, 970→rides forward, 500→clamps; the
book's stats card and the By Device panel both pick up the split.
2026-10-15 11:26:47 -04:00
John O'Keefe 2117ec6181 feat(web): book reviews — markdown, sibling of ratings, full API + page surface
media_reviews table mirrors media_ratings' shape (unique per user+item,
cascade deletes, updated_at for LWW) but standalone: a review without a
rating must work. Body is RAW MARKDOWN — storage and every payload carry
the source; rendering happens only at display sites (same doctrine as
notes, same sanitization pipeline: markdown-it + DOMPurify).

API: GET/PUT/DELETE /api/media-items/:id/review (PUT upserts; an empty
body deletes — clearing via an editor is a delete, not an empty row).
Book-detail page payload now carries the review beside the rating.

Page: the review renders above the synopsis as a scrolling card (same
max-height box as the summary), hydrated client-side through
renderNoteHtml; 'Write review'/'Edit review' sits next to the rating
stars and opens a raw-markdown editor dialog (save / delete / cancel).

sqlc regenerated; schema applied to the dev DB. Live-verified: upsert,
get, empty-delete, page markup, and the app round-trip against the dev
server.
2026-10-15 10:53:53 -04:00
John O'Keefe fd6f0e3353 fix(web): scan progress — correct percentages, no layout shift, refresh-survivable
Bug: the admin 'Scanning Libraries' widget fed the server's 0-1
progress straight into percent strings and bar widths, so the overall
and per-library percentages capped at '1%' and the bars never filled.
Both now scale by 100 (matching the setup page, which was already
correct).

Bug: scan state only reached clients via websocket events, so a page
opened (or refreshed) mid-scan showed nothing until the next tick —
and the admin widget never re-attached at all. The worker now exposes
active jobs (with their library_id so rows keep their names), served
as GET /api/scanner/active (admin, re-attaches the widget + polling)
and GET /api/scanner/active-summary (any user, restores the sidebar
indicator on load). Idle hides both, exactly as before.

Annoyance: the sidebar's 'Scanning… N%' was a block element between
the logo and the nav, shoving every menu entry down while a scan ran.
The percent now lives inside the fixed-height logo row next to the
spinner (spinner last, so growing digits extend leftward); nothing
below it moves.
2026-10-15 08:29:04 -04:00
John O'Keefe 9bc149f3d9 style(dashboard): tighten the gap between the week widget and the first shelf (4rem → ~1.25rem)
Release / build-and-push (push) Successful in 2m52s
2026-10-14 14:12:53 -04:00
John O'Keefe f981e72ff8 fix(dashboard): week widget left the wiped container — it now survives library switches
The htmx self-refresh was bound to an element that was itself being
destroyed: the widget rendered inside #collections-container, and the
switcher's renderDashboardCollections replaces that container's whole
innerHTML on every library switch — listener and all — before any
refresh could fire. Hence 'widget disappears instead of updating'.

The widget now renders in its own strip between the switcher subbar
and the sections container, which the switcher never touches. The
week-refresh htmx re-fetch (with the current #library-select value)
then has a living target and re-scopes the numbers per library.
Verified: the subbar's refresh button drives the identical
re-render path and the widget persists with its data.
2026-10-14 14:00:44 -04:00
John O'Keefe 199d82c613 fix(tests): sync-integration cleanup wiped the shared reading_history table
sync_integration_test's cleanup ran DELETE FROM reading_history WHERE
true — harmless for years while the table sat empty, and a silent
stats-data destroyer the moment session capture started feeding it:
every suite run erased the shared dev accounts' captured reading
(the user's manual flip-through test data included).

The cleanup is now scoped: the sync tests' sessions belong to their
test-sync users, and deleting those users cascades their rows. Any
future table-wide cleanup DELETE is off the table (AGENTS.md test
rules updated).
2026-10-14 13:53:51 -04:00
John O'Keefe 0914c935d9 feat(dashboard): collapsible This Week widget, library-scoped + switch-surviving; title into the subbar
Three dashboard complaints, one build:

- The widget collapses to a single summary row (This Week · minutes ·
  pages · sessions · books · streak · Full stats) on every screen size
  and expands to the full grid via a details/summary toggle — the
  carousel sections start immediately below it again.
- The 'Your Library' heading moved into the library switcher subbar
  (LibrarySwitcher gains an optional title param; collections/series
  keep the plain 'Library' label). The standalone heading block is
  gone.
- Root-caused the JS-switch vanishing: renderDashboardCollections
  replaces ALL of #collections-container's innerHTML, so anything
  server-rendered inside it (title, widget) died on the first library
  switch. The widget now re-fetches ITSELF: a /dashboard/week-widget
  fragment (same resolveLibrary family-lens rules as the page) is
  pulled by htmx whenever the switcher dispatches week-refresh —
  carrying the current #library-select value — so the widget both
  survives switches and RE-SCOPES to the switched library. Initial SSR
  renders the same scope the sections use.

ThisWeek (and the streak calendar) gained an optional library filter.
Tests: TestReadingStatsWeekLibraryScope — per-library totals, scoped
streaks, invisible-library isolation, malformed id 400. Full suite
PASS.
2026-10-14 13:46:18 -04:00
John O'Keefe 5a2c1585ae refactor(stats): 'Web / unsynced' bucket label → 'Web'; analytics tests isolated from the live reader
The bucket name read as confusing — 'unsynced' implied data loss when
the push is always captured; it only lacks a device credential. The
bucket is just 'Web' now (service fallback, stats.ts fallback, panel
copy, tests).

Also: analytics_test exact-zero assertions (EmptyHistory,
NoReadingHistory) ran against testuser, which is a LIVE reader — real
sessions accrued on it mid-suite and both flapped. They now run under
the fresh regular user, like the stats tests; the 'testuser is a live
reader' rule is recorded in AGENTS.md.
2026-10-14 13:24:30 -04:00
John O'Keefe 7a604c2fec feat(stats): per-device reading breakdown + device attribution on progress syncs
- Attribution: registered clients may now send their device token in
  X-Device-Token on PUT /api/media-items/:id/progress (the user JWT
  stays primary). The token must map to a device OWNED BY the caller —
  foreign/invalid/absent tokens soft-fail to plain web attribution so a
  stats decoration can never break a sync. Attributed pushes capture
  device_id (FK-safe), device_metadata.source = the registered device
  name ('Pixel 9', 'Kindle Paperwhite'), and last_sync surfaces show
  that device. Side effect worth having: app-vs-web pushes used to be
  LWW-invisible (both sources read 'web'); they now surface as
  legitimate cross-source progress conflicts when they diverge.
- /stats gains a By Device panel alongside By Library (grid 3-wide):
  reading minutes + sessions + books per registered device, plus one
  Web / unsynced bucket for device-less pushes so the panel always
  reconciles with the headline totals. Range-aware, family-lens
  scoped; renderBars shared between panels in stats.ts.
- The device identity IS the registered platform: devices rows already
  carry the name/type the client registered (app sends Build.MODEL +
  'mobile' at /api/devices/register, KOReader/Kobo likewise) — no new
  identity plumbing on the server.

Tests: capture attributed to the device (row device_id + source
metadata + last_sync_source), foreign/nonexistent tokens soft-fail,
device buckets reconcile (80 attributed + 75 unsynced = 155 total).
Full suite PASS.
2026-10-14 13:04:34 -04:00
John O'Keefe be10adc24b docs(agents): stats-build gotchas — end-of-day anchoring (2nd occurrence), dev-DB probe pollution, DeviceID is not always a device 2026-10-14 12:46:54 -04:00
John O'Keefe 6457fbaf72 feat(stats): reading statistics — session capture wired + /stats page + widget + per-book card
The design parked in the handoff (multi-tier stats off reading_history;
bookorbit-style in data, not looks) is in. The real prerequisite work
was data: reading_history existed but NOTHING wrote to it — the whole
stats feature was sitting on an empty table.

- CAPTURE (no client changes, no repo-boundary crossings): every
  successful progress write through SaveProgress (KOReader + app + web
  + Kobo + offline queue — the one chokepoint) now records one
  reading_history session row: span = the interval since the PREVIOUS
  write instant, pages = positive page delta when both sides know the
  page index, marker rows for first contact. Spans over 2h are kept as
  zero-second markers (overnight suspend→resume, offline flushes must
  not inflate totals) — the honest 'sync cadence, not a stopwatch'
  caveat stays. device_id only when a REAL device row matches (the
  web/app progress path historically stuffs the USER id into DeviceID
  — probe instead of trusting it; FK violation would silently drop
  every capture). Source preserved in device_metadata.

- API: /api/stats/summary?range=7|30|90|year (totals, zero-filled
  daily arrays, streaks, most-active day/weekday, top books, library
  breakdown — one call), /api/stats/this-week (widget: rolling 7d vs
  previous + streak), /api/stats/books/:id (lifetime + recent
  sessions). All bucketed in the user's timezone; finished ≡ any push
  ≥0.995; visible-library lens enforced at source. Legacy
  /api/analytics/* kept untouched. Range windows anchor end-of-day to
  the START of today (blasting the clock time into tomorrow resolved
  'today' a calendar day late — found by the streak test).

- /stats page (sidebar Stats between Progress and Devices, previously
  /analytics was an orphan): range selector, five stat cards, two
  Chart.js v4 charts (daily minutes bar, pages/day line — chunked via
  dynamic import so main.js stays untouched), per-library breakdown,
  most-read list, empty-state explainer. /analytics now 301s to /stats.
  The orphaned analytics.templ + analytics.ts removed (their containers
  and the page JSON never matched; the page was astub).

- Dashboard 'This Week' widget (server-rendered, suppressed on service
  failure) and the book detail 'Your Reading Stats' card (lifetime
  totals of the viewer's own sessions) complete the tiers: per-user
  default, per-library breakdown inline, per-book on the page,
  per-system/admin aggregate parked as before.

Tests: statistics_test.go (capture semantics at the chokepoint,
tz/day buckets, zero-fill, streak math, finished detection, range
validation, auth gate, widget, per-book lifetime). Full suite PASS.
2026-10-14 12:46:38 -04:00
John O'Keefe 8de8883acf docs(agents): templ attribute-interpolation, settings-cache, collection-routing gotchas
Release / build-and-push (push) Successful in 2m55s
From the smart-collections build: templ emits quoted attribute values
literally (whole-attribute expressions required — the rules page
shipped '/api/collections/{ collection.ID }' until proven via request
logs); SettingsRegistry only reloads through the settings API (direct
SQL writes look ignored); and user collections must never route through
the dashboard-section path (query_type='filter' is a schema DEFAULT,
not a query type — the 7ce7321 static-detail regression).
2026-10-14 12:00:33 -04:00
John O'Keefe 0a7fbd6f48 feat(collections): smart (dynamic) collections — rules evaluated live at render
Type is DERIVED, not stored: a saved ruleset makes a collection SMART,
no rules = STATIC (stored collection_items links, unchanged behavior).
No schema change — collections.auto_assign_rules JSONB already carried
the config; user's decision 2026-10-14 (handoff: live-at-render, not
refresh-on-scan; create-time Static/Smart toggle; v1 NO hybrid).

- Rule gains an enabled flag (absent = enabled, legacy rules unaffected).
- Rule CRUD: POST/PUT(DELETE) /api/collections/:id/rules[/:ruleId] — the
  survivor dead effort of the rules-builder UI finally has real
  endpoints (rules were never editable after creation; nothing ever
  re-evaluated them). First rule converts a user collection to smart;
  removing the last reverts it to static. UpdateCollectionRulesJSON
  edits ONLY the rules doc so rule CRUD cannot clobber metadata.
- Live evaluation: candidates from GetVisibleLiveMediaForRules —
  visible-library set (family lens enforced at source) + archived IS
  NULL AND missing_scan_count = 0 — any enabled rule matches, title
  order. Zero refresh hooks by design: always current by construction.
- Membership mutations (add/remove/bulk-add) are 400 on smart — v1 no
  hybrid; collection_items.excluded stays the reserved future hook.
- Rules builder page (/collections/:id/rules) first time ever routable;
  templ whole-attribute interpolation ("{ expr }" inside a quoted attr
  value is literal text, as theme-{ user.Theme } silently proved).
- UI: create modal Static/⚡Smart cards (smart create redirects into the
  rules builder), ⚡ badge + real per-card counts on the grid (smart =
  live matches, static = stored-link count), smart detail renders
  matched books with no hand-pick actions + an Edit Rules button.
- API: GET /:id gains is_smart + live books; GET /: keeps the Android
  app's default exclusion, include_auto=true surfaces smart rows with
  live counts; TestRules previews against the SAME candidates the live
  render uses (visible + live, was raw first-1000).
- FIXED pre-existing regression from 7ce7321: static collection detail
  with NO library_id returned zero books — QueryType != '' caught user
  rows ('filter' default) and routed them to dashboard sections that
  never contain them; now only real system query types go there
  (handlers + SSR). App-visible symptom: /api/collections/:id without
  library_id came back books:[] for every static collection.

Tests: collections_smart_test.go (conversion, disabled rules, mutation
guards, revert-to-static, validation, system-collection prohibition,
include_auto semantics). Full offline integration suite PASS.
2026-10-14 11:59:38 -04:00
John O'Keefe df556d8208 feat(auth): data-driven password requirement checkers
Release / build-and-push (push) Successful in 3m4s
GET /api/auth/password-rules (public, pre-auth) exposes the ACTIVE
complexity config from the settings registry; password_validation.ts
now fetches it and generates the checklist rows per active rule —
inactive requirements no longer linger as fake tasks, and the min
length mirrors the admin's setting instead of a hardcoded 8.

Surfaces: register, setup, admin create-user (Users page), profile
password change (self + admin variants, now gated on checklist pass;
misleading minlength=6 dropped — the server was always the enforcer).

Verified live: flip min_length=12 / number+special off via the
settings API → endpoint + register/profile/admin checklists re-render
row-for-row; enforcement follows (short rejected, rule-valid accepted
without digits/specials); restore defaults → checklists return.
2026-10-14 11:08:12 -04:00
John O'Keefe d95814ded3 fix(make): restore .PHONY declaration dropped by the guidelines cleanup
Release / build-and-push (push) Successful in 3m1s
The sed that removed the verify-guidelines lines also deleted the
.PHONY line (it contained those words), so 'make release' saw the
./release wrapper script as an up-to-date target and silently did
nothing. Restored with the verifier targets omitted.
2026-10-14 09:48:40 -04:00
John O'Keefe 17274bb868 fix(nas): refuse deletes while library storage is unreachable; real file removal; stat cache
Three changes from the network-drive audit:

1. Delete-ordering fix. Deleting a media item cascades its annotations,
   so the delete must never proceed while the item's library storage is
   unreachable — the DB row died, the file survived as an orphan, and
   the next scan re-imported it bare (annotations gone forever).
   resolveMediaStorage stats the library's folders: reachable → delete
   proceeds and the file is removed by ABSOLUTE path (the old code
   passed a library-relative path to os.Remove, which never resolved —
   file deletion has been a silent no-op); every folder unreachable →
   409 with 'storage unreachable', row and annotations intact.

2. Latency: resolveLibrary stats every library folder on every SSR
   render; folder reachability is now cached with a 30s TTL.

3. README: Network Storage section documenting the outage hysteresis
   (mark → archive → auto-restore), the 90-day retention purge
   (ARCHIVE_RETENTION_DAYS, 0 = never), refused deletes while storage
   is down, and watch mode not firing on network mounts.

Integration tests: storage-down bulk delete refuses with the row
intact; real-file delete removes the file from disk.
2026-10-14 09:00:08 -04:00
John O'Keefe f42720ac64 docs: retire PROJECT_GUIDELINES.md — library-visibility rule now in AGENTS.md + dev docs
PROJECT_GUIDELINES.md had drifted out of reality (podman-only rule
while the stack runs docker; verifier scripts wired to Makefile targets
nobody runs). Trashed it, its two verifier scripts, and the Makefile
targets; the rules that still matched reality (docker runtime, schema
in place until release, server binary only via Docker, services layer,
integration-test pool rules) are absorbed into the new AGENTS.md.

The library-visibility family-lens rule now has a real home:
- AGENTS.md: the standing rule agents auto-discover (new media
  endpoints MUST thread the visible-library set; 403, never silently
  pass; history of the 2026-10-13 leak).
- docs/developer/library-visibility.md: the full explanation (helpers,
  SQL pattern, per-item content gates, series per-library), linked
  from the developer docs index.
Stale doc links updated (docs index, contributing, development).
2026-10-13 20:18:42 -04:00
John O'Keefe 320f31086d fix(web): field-mapping + fragile-hx-vals sweep across profile and library surfaces
Field-by-field audit of the profile surface (set → save → reopen →
read back, driven through the emulator browser via CDP):

- getTemplateUserWithTheme (every SSR page + the /profile self-edit
  form) never mapped FirstName/LastName/CreatedAt — stored names
  rendered as empty inputs. Now mapped (timezone was already mapped).
- Admin profile-modal Timezone mapping fixed earlier this round held:
  all seven fields verified set → 200 + HX-Refresh → reopen → correct
  values, DB cross-checked.
- Role promote/demote via the card select verified against the DB (the
  PUT answers HX-Refresh, so the page reload shows the new role).
- Admin password reset verified end-to-end: new password logs in, old
  one 401s, original restored.
- admin_library folder-remove hx-vals was string-concatenated JSON —
  a folder path containing a quote or backslash made it invalid and
  the remove button silently died under htmx 2 (the same class as the
  profile-save bug). Now built with json.Marshal behind the js:
  prefix. Verified the generated attribute on the live page.
- Other hx-vals/hx-headers sites audited: constants and uuids only
  (safe charset), left alone.
2026-10-13 18:25:56 -04:00
John O'Keefe dc353b0f3f fix(web): profile modal always displayed UTC — Timezone never mapped
The admin profile-modal route hand-rolled its templates.User without
the Timezone field, so every selected?={ user.Timezone == … } in the
timezone select was false and the first option (UTC) rendered selected
for every user on every open. Saves worked fine — the modal just never
displayed what was stored, which read as 'not saving'. The route's
GetUser query already returns the column; it just wasn't mapped.
Verified end-to-end in the emulator browser: modal opens showing the
stored zone, save → HX-Refresh → reopen shows the new zone.
2026-10-13 15:05:40 -04:00
John O'Keefe 2a6e4b9ae5 fix(web): admin users cards redesigned for mobile readability
The single-line card squeezed identity, created date, role select, and
two buttons into one row — on a phone the username truncated to t…,
the email to te…, and the created date wrapped vertically one fragment
per line (10- / 13- / 2026 / …). Restructured into stacked full-width
rows: identity (avatar + username + email), created on its own line
(a full date never wraps), a labeled Role row under a divider, and
Edit/Delete as side-by-side flex-1 buttons (thumb-sized on mobile,
right-aligned sm+). Last-admin protections render identically in both
controls. Verified on the emulator (Pixel 9, Brave): full names/emails
visible, no wrapping, modal + role controls functional.
2026-10-13 15:00:59 -04:00
John O'Keefe 505fc48b23 fix(web): profile save silently died under htmx 2 — invalid-JSON hx-headers
Reproduced on the emulator via CDP: clicking Save Changes fired the
submit event, something preventDefault-ed it, and htmx aborted while
parsing hx-headers — 'Bearer ' + localStorage.getItem('token') is a JS
expression, and htmx 2 requires valid JSON (or a js: prefix) there.
The PUT never left the browser: zero htmx lifecycle events, no console
error, no toast. My earlier HX-Refresh fix was correct but unreachable
— the request died before it could ever be answered.

The header was htmx-1.x legacy and redundant: the browser already sends
the token cookie, which the auth middleware accepts. Removed hx-headers
from the profile form and both password-reset buttons.

Verified end-to-end in the emulator browser via CDP: Edit → change
timezone → Save → PUT 200, HX-Refresh reload, DB updated. The tab
reload mid-probe is the fix working.
2026-10-13 14:52:19 -04:00
John O'Keefe 57d83fd9af fix(web): profile save gives visible feedback (HX-Refresh)
The admin/user profile modal PUT succeeded but the response was a bare
JSON body swapped into a small div at the bottom of the still-open
modal — no close, no toast, nothing moved. It read as 'the button did
nothing' even though the save landed. HX-Request calls now answer with
HX-Refresh (re-render shows the saved values everywhere and closes the
modal) plus a visible confirmation. API callers keep the JSON body.
Silent-failure display already exists site-wide (toast.ts listens for
htmx:responseError), so failures were never the silent case.
2026-10-13 14:19:06 -04:00
John O'Keefe 0772f06d25 fix(tz): JWT context stub carries the user's timezone
The middleware's per-request user lookup (ghost-session guard) fetched
the full row and discarded it, then built the context stub without a
timezone — so every server-side conversion via the context user fell
back to UTC no matter what the handlers did. The stub now carries
Timezone from that same lookup (fetchLiveUser).

Live-verified: progress page renders 02:03 PM for a 14:03 New York
instant; an analytics session at 02:00 UTC buckets to 10-12 (10pm NY
the previous day) instead of 10-13.
2026-10-13 14:03:44 -04:00
John O'Keefe 1601548e6c fix(tz): server-side timezone conversion for all user-facing time displays
The audit showed only 5 of ~11 time-display surfaces converted to the
user's timezone; everything else rendered pgx-decoded UTC. The server
owns conversion — clients (web/app/KOReader) receive display-ready
strings or machine-readable instants and never convert.

- Progress page: LastUpdated formatted in the requesting user's tz
  (was raw UTC).
- Analytics: date-range defaults, day buckets, most-active-day, device
  last-sync, and popular-book last-read all computed in the user's tz —
  evening sessions no longer land on the next UTC day. Also fixed the
  end-date boundary: the query bounds with <= and the end bound was
  midnight, so the end date's own sessions (including 'today') never
  appeared.
- Queue page: new created_at_display field (admin tz) rendered by the
  template; created_at stays RFC3339 for API consumers.
- Admin hash-conflicts SSR bridge: detected date rendered in the
  admin's tz.
- OPDS: .UTC() before the Z-suffixed feed timestamps (honesty fix —
  correct today only because the session zone happens to be UTC).
- KOReader annotation datetimes deliberately untouched (device owns its
  display).
2026-10-13 13:59:49 -04:00
John O'Keefe 740213f0a4 fix(auth): new users inherit the system-wide default timezone
default_timezone existed as a system setting (admin-editable, exposed
via SettingsRegistry.DefaultTimezone) but nothing read it at user
creation — the CreateUser query didn't even carry the column, so every
new user got the DB default UTC regardless. Both creation paths
(public register + admin create) now seed the user's timezone from the
setting, validated with time.LoadLocation and falling back to UTC when
unwired/empty/invalid.

Test-infra fixes surfaced by the run: TestUpdateProfileSelfEdit was
self-editing the SHARED admin (renaming it past the harness cleanup's
preserve-list, leaking a second admin row that broke the last-admin
tests) — it now edits a dedicated user with a unique name; the
last-admin tests get an ensureSingleAdmin precondition helper; the
websocket collection name is run-unique (non-test names persist in the
shared DB and UNIQUE(user_id,name) 409s on rerun).
2026-10-13 13:17:12 -04:00
John O'Keefe bc6ce41762 fix(web): password requirement indicators on registration and admin create-user
The /register checklist was dead markup: initPasswordValidation() was
only ever called on the setup page, so the requirement rows never
updated and the Register button stayed disabled forever — web
self-registration was effectively broken. The register Alpine component
now initializes it.

The validator is parameterized (field/submit ids, confirm + username/
email checks optional) so the admin Create User form reuses it: confirm
field, live requirement rows, and the Create button gated on all checks
passing. Requirements mirror the server's passwordcomplex rules
(8+ chars, upper/lower/number/special — the hardcoded defaults; server-
tuned rule values are a future nicety, the server remains the enforcer
either way).
2026-10-13 12:38:26 -04:00
John O'Keefe f3c9d4b4e8 fix(web): admin users list reworked to the library-management card pattern
The md:-flipped table/card split still broke at tablet widths and with
the nav sidebar open — a viewport breakpoint cannot see the sidebar
eating content width. Replaced the table entirely with the same fluid
card list admin_library uses (flex + min-w-0/truncate identity block,
fixed action cluster, flex-wrap): it reflows continuously at every
width instead of stepping between two layouts. The duplicate-DOM-id
suffixing from the previous two-rendering approach is gone with it.
2026-10-13 12:29:43 -04:00
John O'Keefe 300866ca67 fix(web): viewport meta on all admin/profile pages; admin users page reworked for mobile
- The desktop-on-Android bug: 8 page templates (admin, admin_settings,
  admin_users, admin_library, admin_archived, admin_hash_conflicts,
  admin_processing_issues, profile) lacked the viewport meta, so mobile
  browsers fell back to a ~980px layout viewport and rendered the
  desktop layout. Added the same meta the other 21 pages already had —
  partials without a head were skipped deliberately.
- Admin Users page: the create-user form no longer dominates the page —
  it is hidden behind an explicit '+ Create User' button (Alpine
  x-show/x-cloak, the site-wide convention) with a Close control.
- The user list is responsive: table on md+, stacked per-user cards
  below. Role/delete controls are shared partials with DOM-id suffixes
  so both renderings keep unique swap targets (last-admin protections
  preserved in both).
2026-10-13 12:24:12 -04:00
John O'Keefe 431fec05ad feat(security): library visibility enforced at the source; per-library series; annotation ownership; admin user creation
The family-lens hardening: library_visibility was only enforced where
file bytes flowed (download/reader/OPDS) — metadata surfaces leaked
hidden libraries to any authenticated account, and registration was
open by default with no admin-side user creation.

- Series are grouped PER LIBRARY now (same name in two libraries =
  two entries: manga adaptations / LN splits no longer merge);
  /api/series aggregates only the requester's VISIBLE libraries and
  /api/series/books REQUIRES a visible library_id (breaking contract;
  web + app clients updated in the same change).
- /api/media-items, item detail, and the reader content endpoints
  (chapters/panels/outline/THUMBNAILS — page images leaked unguarded
  next to a gated GetPage) all enforce the visible set; resolveLibrary
  no longer honors a hand-typed library_id outside the visible set.
- Annotation/note/bookmark mutations (update/delete/tombstone, sync
  LWW paths incl. conflict resolution) carry ownership predicates:
  another account's bare row id is a 404, not a silent delete.
- Admin user creation: POST /api/auth/users (AdminMiddleware) + a
  Create User card in the admin Users UI — accounts stay creatable
  with self-service registration OFF, which is now the schema default
  (fresh installs; the first-admin bootstrap exception is unchanged).
- Tests: series contract re-pinned (per-library, visibility 403s),
  new ownership + admin-create integration suites, OPDS all=1 test
  now passes an explicit page size (shared-DB growth made the fresh
  books fall off page 1), device-cap helper switched to the admin
  endpoint.
2026-10-13 12:03:01 -04:00
John O'Keefe 97fbdf19fb pin foliate-js 7796b05 — pdf text-layer dedupe on re-render (v0.5.3) 2026-10-13 10:39:51 -04:00
John O'Keefe a36cee4f85 chore: ignore local export directory
Release / build-and-push (push) Successful in 2m54s
The export/ directory holds database extracts (annotations, reading
positions, book metadata) pulled from production for debugging. The
contents are personal data and must never be committed.
2026-10-12 20:47:05 -04:00
John O'Keefe 14947608be chore(scripts): add synthetic torture EPUB generator
scripts/build_torture_epub.py deterministically builds a small EPUB
exercising every CRE-xpointer to CFI conversion hazard observed in the
wild, one chapter per hazard class: multi-text-node paragraphs (verse
spans, inline markup), whitespace-only inline spans and comments,
drop caps and footnote anchors, br-separated poetry with speaker and
line-number spans, nested blockquotes/lists/tables/definition lists,
surrogate pairs and ZWJ emoji, Hebrew niqqud, Arabic RTL, combining
diacritics, invisible characters, HTML entities, CJK ruby, a 400-
paragraph chapter with heavy phrase repetition for search
disambiguation, and edge constructs (span-first paragraphs, empty
spans, adjacent same-tag spans, deep same-tag nesting).

Default output lands in the Ebooks library for real-device testing
via OPDS; the script takes an output path argument for use as a test
fixture.
2026-10-12 20:46:40 -04:00
John O'Keefe 03a6040360 test(sync): add manual repro harness for CRE-to-CFI highlight conversion
TestManualCleanRepro replays a real device highlight export through the
same conversion path the sync handler uses, diffs the output against
the CFIs stored at ingest time, and computes a ground-truth oracle by
locating each selection in the flattened document itself. Classifies
every row as healthy / diff-base / backwards for both the converter
and the stored data. Skips automatically when fixture files are not
present on the machine (manual test, like cfi_verify_manual_test.go).

REPRO_VERBOSE=1 prints per-row detail; REPRO_EMIT_SQL=<path> emits
idempotent UPDATE statements that heal database rows written by the
old converter from the preserved raw device positions - used to
backfill dev and validated against production data.
2026-10-12 20:46:08 -04:00
John O'Keefe 93a468b435 fix(reader): emit two-subpath range CFIs for multi-node highlights
toRangeCfi required stored start and end CFIs to share a base path and
otherwise fell back to a bare point CFI, which resolves to a collapsed
range and paints nothing - or painted from the wrong anchor after end
repair. A legitimate selection crossing inline elements anchors at two
different text nodes by definition, so requiring a shared base path
discarded correct endpoints.

Build the range parent from the endpoints' longest shared step prefix
(down to the bare spine step when needed) and emit each divergent
remainder per the EPUB CFI range grammar, epubcfi(parent,start,end).
Degenerate-end repair (missing end, or end before start) now only
applies when both sides terminate on the SAME node - equal remaining
step count with different step values is a valid two-node range, not a
degenerate pair; document order is verified for those instead.

Verified with foliate's own parser against the real chapter XHTML:
ranges resolve to exactly the selected text for same-node, two-node,
and repaired-degenerate cases.
2026-10-12 20:45:41 -04:00
John O'Keefe bb3aef517c fix(sync): anchor device highlight ranges to selection text
KOReader highlights converted to wrong or unresolvable web CFIs
whenever a selection crossed text-node boundaries. Three compounding
defects: ParseCREXPointer discarded the text()[N] node index and
applied .offset as a global block offset; the end anchor converted
with an empty context (blind structural landing, zero verification);
and the block-containment check accepted wrong-node landings because
a selection's text always occurs somewhere in its own block. pos0 and
pos1 were converted independently, yielding mismatched start/end base
paths and backwards ranges - highlights stored but never painted, or
painted at the wrong location, in the web reader.

Add ConvertHighlightRange: both endpoints are anchored by locating the
selection text in the flattened document text (whitespace-normalized,
crossing block boundaries), with the device locators demoted to
disambiguation hints. A single document match yields a same-spine,
ordered start/end pair by construction. Falls back to the legacy
per-endpoint path when the text cannot be located, so behavior is
never worse than before.

Also fix the shared flatten stream: zero-width format characters
(ZWSP/ZWNJ/BOM) and whitespace-only inline spans (<span> </span>)
now act as word separators instead of fusing adjacent words, which
prevented text matching in real books.

Verified against 28 real device highlights from production: converter
output now matches the text-search oracle on all of them (previously
8/28 correct, 18 with mismatched base paths, 2 backwards).
2026-10-12 20:45:10 -04:00
John O'Keefe cbdddc095e fix(web): PDF adjust — remove the leftover line-band filter from the endpoint drag
Release / build-and-push (push) Successful in 2m39s
The "full 2D" commit (5a0d9e0) added the tip aim but never removed the
y-band candidate filter from pdfAdjustHandleMove — vertical handle
drags still snapped to the boundary rect's own line, so up/down
movement was impossible (the user's original report). With the filter
gone: the end handle drags down a line and the extent extends with
clean text — verified with real touch on the emulator (down one line,
release, PUT applied: 6 per-line rects).
2026-10-11 20:44:32 -04:00
John O'Keefe 5a0d9e0e17 feat(web): endpoint handles drag vertically — full 2D tip-aimed targeting
User report after the lifted-handle change: "I cannot go up or down,
just side to side" — a consequence of the line-band pin added with the
lift (the floating finger sits above the text; a band-constrained scan
ignored vertical movement entirely).

The endpoint drag is now full 2D: the finger position (plus the
~15px body-center-to-tip offset) maps to page fractions and the
nearest glyph re-anchors the grabbed end — up/down crosses lines,
side-to-side moves along them, clamped so the extent never inverts.
The boundary rect's own band constraint is gone; sliver rects remain
structurally impossible (rects rebuild from the char-table span as
per-line unions).

Direct-fraction probes verified: (0.4, 0.098) → mid line 1; (0.2,
0.117) → line 2 head; (0.72, 0.117) → line 2 mid — spans cluster into
the right per-line boxes. The dot-listener plumbing (pixels → map →
fractions → scan) verified by instrumented probe on the same load.
Final desktop finger pass pending (emulator input pipeline wedged).
2026-10-11 18:15:34 -04:00
John O'Keefe 19619ef817 fix(web): raise the adjust teardrops — tips rest above the glyph tops
User report: the teardrop tip touched the letters. Raised the handle
boxes 8px (tip now rests at the line-box top, ~4px above the glyph
tops) — matches the Android handle spacing the app leg shipped.
2026-10-11 17:57:03 -04:00
John O'Keefe a28ae5c88d feat(web): adjust handles float above the text with tips marking the boundary
User report: the handles covered the highlighted text. The dots now
hang ABOVE the extent's boundary corners (teardrop tip down at the
first rect's top-left / last rect's top-right), so the highlighted
line stays readable.

Same treatment as the app leg: the nearest-char scan in the endpoint
drag is band-constrained to the boundary rect's own line (the lifted
finger floats ABOVE the text — a whole-table scan re-anchored onto the
line above). Cross-line changes go through re-selection as before.
2026-10-11 17:25:58 -04:00
John O'Keefe 1c209408cc fix(web): PDF adjust — three compounding coordinate/table bugs, found by instrumented drag
User report (desktop Brave, fresh private window): the endpoint handle
"jumped several lines down and stuck". The instrumented drag exposed
the chain:

1. THE bug: the endpoint handleMove API takes PAGE FRACTIONS, but the
   dot's pointermove listener passed raw e.clientX/clientY PIXELS.
   Scanning for the nearest glyph to (296, 170) in a 0–1 fraction space
   picks the bottom-right-most glyph — the extent leaped to the page's
   end and never moved again (stuck). Engine-independent: failed
   identically on Chrome/Brave/Firefox desktops; my synthetic tests
   masked it by passing pre-converted fractions. handleMove now maps
   host px → fractions internally (one contract).

2. pdfPageMapping mixed coordinate spaces: the iframe-internal canvas
   rect with the host iframe rect. Whether that combination is valid
   depends on the engine's cross-document getBoundingClientRect
   semantics. Replaced with the page WRAPPER element (the iframe's
   parent — the same box the fork's rect-annotation overlay fills),
   measured in a single host context. Handles now align with the
   painted highlights by construction.

3. Char-table hygiene: per-char boxes are LINE-HEIGHT boxes (~one line
   gap tall). Dedupe at a full box height ate the line-below char in
   the same x-column (silent character loss); clustering at full box
   height never split lines (merged 0.45-tall "line" boxes). Dedupe
   now tolerates 0.75 of the SHORTER box; clustering breaks at 0.6 of
   the char's own height (inter-line gap ≈ 1.0× breaks, baseline
   variance ≈ 0.1× stays).

Verified end-to-end (CDP synthetic pointer path): drag to (0.72, 0.117)
→ span extends line 1 → line 2, 2 per-line rects saved, clean
multi-line text. Direct-fraction probes and the pointer path now agree
on the same load.
2026-10-11 13:01:16 -04:00
John O'Keefe 93f020f2b5 fix(web): PDF adjust handles — create once, reposition only
User report (desktop): the endpoint handles wouldn't move.
renderPdfAdjustHandles rebuilt the handle layer via innerHTML="" on
every preview frame — the first drag move triggered a repaint that
DESTROYED the dot being dragged. A removed element loses pointer
capture, so real mouse/touch drags died silently after one pixel; the
pointerup never reached the detached dot either, so nothing saved and
the session preview reverted — the same mechanism behind the earlier
"resets to the original selection" report. CDP tests masked it because
synthetic events dispatched directly at the detached node still ran its
listeners.

The two dots are now created once per session and every later call only
repositions them (style updates move nothing the listeners live on).
Grip-era code had this reuse pattern; the endpoint rewrite regressed it.

Real-touch verified on the emulator (adb-injected drag): the handle
tracks, the release saves a multi-line extent with clean recomputed
text (3 rects, PUT applied).
2026-10-11 11:21:51 -04:00
John O'Keefe a3ecac3507 fix(server): Cache-Control no-cache on /static — heuristic caching served stale bundles
The static handler sent no Cache-Control, so browsers fell back to
heuristic caching (10% of age since Last-Modified) and kept serving a
stale reader.js for hours after a deploy — fixed web bugs reported as
"not fixed". no-cache forces revalidation every load; the filesystem
Last-Modified/If-Modified-Since pair keeps unchanged loads cheap.

Found while closing out the PDF adjust v2 session: the user's Chrome
was running a pre-endpoint bundle, which read as "the web has not
been fixed".
2026-10-11 09:50:42 -04:00
John O'Keefe db3b5b3d62 fix(web): endpoint handles verified end-to-end — multi-line extent saved (5 rects, clean text); note: footnote markers between lines are geometrically included by design 2026-10-10 23:19:35 -04:00
John O'Keefe f69e659d94 feat(web): PDF adjust v2 — re-selection replaces the extent; vertical grip clamp
Multi-line partial extents ("line 1's tail + the first words of line
2") were inexpressible with one-rect-per-drag grips, and the vertical
drags produced sliver rects whose text recompute swept fragments. The
web's re-selection uses what creation already uses: a selection made
during a rect-edge session is intercepted in checkPDFSelectionFor —
rects rebuild from the selection's per-line client rects, text re-reads
via the deduped glyph boxes, saved immediately (grip release-to-save
parity), and the create popover is suppressed for that gesture.

Vertical grip drags clamp to the rect's OWN line box (band ±quarter
height) — multi-line changes go through re-selection, slivers are gone.

Also verifies clean under the duplicated text-layer span sets (the
fork's pdf.js render never clears .textLayer — open bug): the text
recompute dedupes by glyph overlap.

CDP-verified end-to-end: session → multi-line selection → 6-rect
extent + clean text persisted (PUT 200); create popover correctly
suppressed during the session.
2026-10-10 18:25:29 -04:00
John O'Keefe dbeefb395c fix(web): PDF adjust text recompute — overlap-based glyph dedupe
User report: extending a highlight doubled letters into the recomputed
selection text ("aauuthenticcity off ... ruunss iinnto"). The exact-
position dedupe key (toFixed(3)) only collapsed duplicate glyphs whose
re-render copies landed inside the same rounding bucket — pdf.js
re-render copies can sit SUB-PIXEL SHIFTED, so per character the jitter
sometimes straddled a boundary and both copies survived, partially.

Dedupe by overlap instead: a glyph is a duplicate when a kept glyph on
the same line (y0 within half a glyph height) overlaps more than half
the narrower glyph's width. Distinct glyphs — including tight pairs —
never reach that; shifted duplicate copies always do. The snap tables
share the source, so their boundary lists are clean too.

The root cause remains the fork's pdf.js render() appending a fresh
span set without clearing .textLayer (open bug, fork-cycle fix); this
is the defensive layer that makes the adjust feature correct regardless.

Repaired the affected highlight's stored text with the fixed recompute
(verified: the same live duplicated DOM now yields the exact line text
with zero doubling).
2026-10-10 14:25:48 -04:00
John O'Keefe 8acaeddda5 fix(web): detail banner — a failed annotation fetch must not wipe its rows
fetchBannerAnnotations published the rebuilt rows unconditionally, with
a failed endpoint's list defaulted to empty — so one failing request
(a single handler 500, an auth blip on one call) silently wiped that
annotation kind from the banner while the other kind stayed, and the
empty state could claim "No notes or highlights on this book yet" over
real data. A full outage was already safe (the thrown fetch takes the
catch path, which keeps previous rows and toasts) — the partial case
was the hole, spotted while mirroring the app's fetch-wipe fix
(bookhoard-app 07bbb4c) onto the web.

Both responses must now succeed before the rows are published;
otherwise the previous rows stay and the next banner open retries
(bannerLoaded only sets on success). Verified on the emulator: banner
renders 3 rows, a full outage keeps them with bannerError recorded,
and a post-recovery refetch re-renders from live data.
2026-10-10 13:56:32 -04:00
John O'Keefe 361fdaab39 fix(web): PDF rect-edge adjust — a start-edge drag pins the far edge
The left-edge drag kept the rect width instead of anchoring the opposite
edge, so shrinking from the left silently dragged the right edge along
and the recomputed text grew words at the far end (found by the Android
device test of the app leg). Same fix for the top edge / bottom pin.
The end-edge branches were already correct.
2026-10-10 11:47:41 -04:00
John O'Keefe 4d1fb05e49 feat(web): PDF highlight extent adjustment — rect-edge grips with word-token snap
The reflowable adjust (objective 9) had no fixed-layout counterpart: the
popover's Adjust button relied on adjustContext, which is only stashed by
the reflowable show-annotation path, so PDF rows showed a button that
silently did nothing. PDF highlights persist as page-fraction rect
payloads ({v:1,page,rects}), so their adjustment is geometry, not range
re-anchoring.

- startAnyAdjust routes the popover button by format; startPdfAdjust
  runs a rect-edge session: 4 grips per rect (all edges, one rect per
  drag), live preview through the renderer's own addRectAnnotation
  (replace-by-key — no fork change), commit via saveHighlightChanges.
- Snap vocabulary mirrors snapBoundary: maximal letter runs are atomic
  tokens (start edge takes the word start, end edge the word end), each
  punctuation glyph is its own token with both edges grabbable (a period
  or line-break hyphen can be taken or shed precisely). Horizontal edges
  snap to line top/bottom bands (own line + neighbours).
- selection_text is recomputed from the glyph boxes inside the final
  rects (reading order, lines joined with spaces) so the stored text
  stays honest with the extent.
- Cancel paths mirror the reflowable session: page turn (renderer
  relocate) discards and restores; a committed session skips the restore.
- getContents() from the fixed-layout renderer carries no index — page
  iframe docs are mapped through a WeakMap filled by the view's load
  events. Pointer mapping goes through the canvas's post-transform rect
  (the dpr denominator lesson) via the fork's own convertPoint ratios.
- pdfCharBoxes dedupes by box key: pdf.js re-renders append a second
  span set into the same .textLayer without clearing, and undeduped
  glyphs doubled the recomputed text.

Device-verified (emulator Chrome + CDP): popover route, grip placement,
word-atomic shrink ("But this" -> "this"), extension to the exact
line-break hyphen boundary (0.8777), far-drag clamp at the last token,
page-turn cancel, and the reflowable adjust still routing to handles.
2026-10-10 11:22:09 -04:00
John O'Keefe 679e0f9116 fix(server): ghost 401 must abort the chain — c.JSON's nil error let the request continue
Release / build-and-push (push) Successful in 3m21s
The guard returned c.JSON(401, ...), whose nil error told echo the
SuccessHandler succeeded — the pipeline then continued into the route
handler, which type-asserted the never-set "user" context value and
panic-recovered as a 500 appended after the 401 body. The status code
happened to survive (first write wins), which is why the pin test
passed, but every ghost request also executed the target handler with
no user — the exact unauthenticated-execution class the guard exists to
prevent.

Return echo.NewHTTPError(401, ...) instead: non-nil aborts the chain
and renders {"message":"user_not_found: ..."}; the pin test now asserts
that shape. Full suite: 219 groups, zero failures.
2026-10-09 22:41:21 -04:00
John O'Keefe 9cd6356892 feat(server): ghost-session guard — JWTs for deleted users stop authenticating
The JWT middleware validated signature + expiry and fabricated the
request user purely from token claims, so a signature-valid token for a
DELETED user kept full API access until token expiry — observed live on
hardware (a phone session survived the test suite's user-table
recreation and kept serving its true empty sets; open handoff bug 1).

An ensureUserExistsMiddleware already existed but was only wired on the
frontend SSR group (frontend.go) — every /api group used bare
createJWTMiddleware. The existence check now lives INSIDE
createJWTMiddleware's SuccessHandler so every protected group (SSR and
API alike) gets it:

- After claim validation, GetUser by the token's user_id; a missing row
  clears the token cookie and answers 401 user_not_found for API calls
  (the app's TokenAuthenticator then re-logins with stored credentials)
  or redirects to /login?session=invalid for page requests — same
  shape-aware split the error handler already uses.
- Cost: one primary-key lookup per authenticated request (~0.2ms local);
  instant revocation, no TTL semantics. The redundant SSR-only
  middleware is deleted (its redirect behavior is preserved inside the
  guard).

The guard exposed two processing-issues tests that were unknowingly
RELYING on ghost semantics: they nested a second setupTestServer whose
bootstrap deleted the first setup's regular user, then asserted 403
from the dead token. De-nested: a createLibraryDirect helper inserts
the library under the live setup's user, no second bootstrap.

New TestDeletedUserTokenRejected pins the contract: token works while
the user exists -> 401 user_not_found after deletion. Full suite: 219
groups, zero failures.
2026-10-09 22:33:30 -04:00
John O'Keefe 4f05a232fe chore(server): delete dead unrouted UpdateUniversalProgress handler
Handler.UpdateUniversalProgress (handlers/progress.go) had no route
registration anywhere (router/progress.go only wires the history route
to the scanner handler) and no callers — a legacy progress-write path
that bypassed the verify/heal pipeline entirely. Deleting it removes
the last bypass-shaped writer that could poison progress rows without
verification; conflict-resolution apply remains the only deliberate
direct write (user-approved winner rows).

Pure deletion: 115 lines, no route, no callers, no behavior change.
2026-10-09 18:22:23 -04:00
John O'Keefe 69516ae165 feat(server): progress locator coherence — percentage-only writes clear stale anchors
The verify/heal pipeline verified only INBOUND context-bearing writes;
stored-row coherence was never enforced. SaveProgress seeds missing
fields from the existing row, so every percentage-only write (KOReader
low-confidence conversion, KOReader no-pointer payloads, Kobo markup
ReadingSync / analytics / sync-from-server, locator-less queue entries)
silently re-paired its NEW percentage with the PREVIOUS writer's stale
CFI+context — the incoherent row shape that fed the client-side
far-anchor restore floor (and, via the read-side anchor handles, could
even hand a first-party client the OLD position's scroll address).

KOReader's converter already declared the right intent ("storing
percentage only — the row's percentage restores approximately"); the
seeding betrayed it. SaveProgressRequest gains ClearLocator, set by the
percentage-only call sites, and the seeding rule honors it: stale
epubcfi/context/character_offset clear (the upsert assigns columns
directly, so NULL sticks) unless the request carries its own. A
request-carried context survives so the verify/heal pass can still mint
a fresh anchor from it; the HTTP merge path (field-absent PUTs
preserve) and all anchor-bearing writes are untouched. The fixed-layout
branch already cleared locators unconditionally — unchanged.

Consumer audit: percentage — the only thing every sync round-trip
exchanges — is untouched on all paths; KOReader pulls on locator-less
rows already support the nil-CFI shape (builder guards
progress.Epubcfi.Valid); first-party restores go percentage-first
instead of floor-fighting a stale anchor.

Tests: TestProgressWeb_LocatorCoherence pins clear-on-declared,
request-carried-context survival, and merge-path preservation. Full
suite with the change: 218 groups, zero failures.
2026-10-09 18:22:15 -04:00
John O'Keefe f24a2d6c22 test(server): fixture collisions fixed, stale contract asserts re-anchored, gofmt — suite now fully green
Closes the remaining integration-suite failures (handoff open bug 3),
line by line:

- Fixture family (the upsert equivalence): tags_filter_test
  ("Science Fiction" found 1 of 3; "Fantasy" 404'd on zero results),
  media_item_isbn_test (normalization read back the first case's ISBN
  everywhere; edge cases saw a nil ISBN; pagination collapsed 5
  creates onto 1 row), search_unified_test (its 3 books were actually
  1 row + two no-op creates) — all POSTed a constant file_path into
  one library while CreateMediaItem upserts ON CONFLICT
  (library_id, file_path). Every fixture now uses unique per-item
  paths, the suite's universal convention elsewhere. TestTagsFilter
  confirmed same-family: counts came right with no second bug.
- New TestMediaItemCreateDuplicatePath pins the honest-create
  contract from da13898: 201 for the first create; 200 + duplicate
  flag + the EXISTING row (never a 201 lie), second payload not
  overwritten.
- library_browse_test: the admin subtest browsed the container's
  /tmp and required it non-empty — env-coupled (empty /tmp → "was
  []"). Now browses a self-created t.TempDir with two subdirs and a
  decoy file, asserting ONLY directories are listed.
- series_integration_test: three stale assertions re-anchored to the
  shipped contracts, each with a ground-truth comment: /dashboard/
  sections key identity on the collection NAME (BuildSections ID =
  CollectionName; the web/app reorder + hidden prefs persist those
  strings) — the kebab query_type is the storage key and is not
  exposed; /api/series/books is name-ONLY (the app client and the
  web SSR page both send name only; library_id is ignored, never
  validated); /api/series is optional-scope BY DESIGN (GetDistinct-
  Series: narg IS NULL OR library_id = narg aggregates across
  libraries when omitted). The former 400-on-missing-library_id
  expectations asserted validation that never shipped.
- gofmt rider on three untouched-in-this-arc test files
  (dashboard_integration, search, test_helpers).

Full suite with -parallel=1: 217 groups pass, zero failures.
2026-10-09 17:11:12 -04:00
John O'Keefe da13898046 feat(server): POST /api/media-items answers honestly on a duplicate file_path
The create endpoint shared the scanner's upsert (ON CONFLICT
(library_id, file_path) DO UPDATE SET updated_at = NOW() RETURNING *) —
so a POST naming an already-registered file returned 201 CREATED with
the OLD row while silently discarding everything the caller submitted.
Nothing depended on that lie: the app only GETs/PUTs media items, the
web only searches/purges/deletes, KOReader never calls it — the
endpoint's create path had zero production callers.

Split the API path from the scanner's idempotent upsert, which is kept
verbatim in CreateMediaItem (re-scan dedup remains its safety net):

- CreateMediaItemIfNew: same insert with ON CONFLICT DO NOTHING — the
  loser of a create race writes nothing and returns no row.
- GetMediaItemByLibraryAndPath: fetch the existing row for the
  duplicate response.
- Handler branch: on pgx.ErrNoRows → 200 OK with
  {"duplicate": true, "message": ..., "item": <existing row>} instead
  of a 201 pretending it created something. Real creates still answer
  201 with the item as before.

Bulk-import shape (the reason for 200+flag over 409): a re-run after a
mid-import failure is harmless and countable — each response tells the
caller exactly which of the two things happened.

Verified by the full integration suite: 217 groups pass, zero failures,
including a new pin of the duplicate contract (same file_path twice in
one library → 201 then 200/duplicate:true, second payload not
overwritten).
2026-10-09 17:10:56 -04:00
John O'Keefe 1a2d42a244 chore(server): delete dead ParseEbook 501 placeholder
Born dead in e7ce0cb (2026-04-03, backend reader infrastructure):
the "Phase 2.5" plan assumed MOBI/AZW3/DOCX/RTF would need server-side
parsing, so ParseEbook was written as the endpoint that would do it —
but it only ever returned 501 "not yet implemented", and no route in
router/reader.go ever pointed at it. No caller in Go, templates, or
web JS/TS ever existed either.

The eventual architecture never needed it: the web reader hands the
raw file to foliate-js and parses client-side (MOBI renders via the
fork's mobi.js — verified live; DOCX via mammoth; RTF/PDB shipped).
By now it was also stale — it 501'd DOCX/RTF as "not yet implemented"
while both work, and offered an AZW3 path that was dropped entirely.

Pure deletion: no route registration, no references, no tests, no
behavior change. go build ./... clean.
2026-10-09 15:05:44 -04:00
John O'Keefe 208fc2c4d6 fix(server): kobo markup panic — wire LibraryService into the test harness, guard typed-nil resolver
TestProgressWeb_KoboIntegration 500s'd on every markup with a
last-read-place bookmark: the panic-recovered stack was a nil pointer
dereference in LibraryService.ResolveMediaPath (s.db deref), reached
from KoboHandler.convertKoboCFIToStandard during kepub CFI conversion.

Two-layer bug:

1. Test harness (root cause): the router.Config built in
   test_helpers_test.go omitted LibraryService, so router/sync.go's
   SetLibraryService(cfg.LibraryService) stored a nil
   *services.LibraryService in the interface field. Production wiring
   (main.go:246) was always correct.
2. Latent trap (hardening): `h.libraryService != nil` at the
   convertKoboCFIToStandard call site can never catch a typed nil —
   an interface holding a nil *LibraryService is non-nil. ResolveMediaPath
   now degrades with a "library service not configured" error instead
   of panicking on a nil receiver; callers already handle the error
   by skipping CFI conversion.

Harness-only fix, zero production behavior change: the endpoint now
returns a clean 200 with the position saved wherever kepub conversion
is unavailable instead of a panic-recovered 500.

Verified: TestProgressWeb_KoboIntegration passes all three subtests
(401 unauthenticated, ReadingSync→last-read-place percentage
preservation, last-read-place without prior ReadingSync) and on
immediate rerun — previously it failed every run.
2026-10-09 14:53:48 -04:00
John O'Keefe 18969a0f01 test(server): comic negative-case assertions target display values, not admin-modal labels
The whole-body "should not display Manga Type / Scan Info" assertions
could never pass: every book-detail page for an admin carries the
edit modal, whose form labels ("Manga Type", "Scan Information") and
option texts ("Yes (Right to Left)") share vocabulary with the
metadata display block. This was pre-existing rot — the test never
ran to completion before today because an earlier panic killed the
process first.

Replaced with value-level checks that only the display block could
produce ("yes and right to left", "B&W"): absent for plain ebooks and
unknown-manga-type items, matching the templ guards in
book_detail.templ. Emoji-icon asserts dropped from the RTL/Story-Arc
positive steps — the redesigned comic badge renders text chips
without icons.
2026-10-09 11:04:39 -04:00
John O'Keefe 843f35bbfc test(server): runs leave no residue — auto-cleanup for every resource the suite creates
Symptom: repeated full-suite runs poisoned the dev database with
leaked rows (duplicate Prefs Scope libraries, colliding device names,
saved-filter name conflicts on rerun) because tests created rows but
never deleted them, and stale-binary runs left rows the new cleanup
could not match.

- test_helpers: library creation now registers t.Cleanup DELETE (the
  leak root cause — createTestLibraryWithFolder never cleaned up).
  Suite-wide cleanup passes for libraries/collections/devices/
  saved-filters run both at setup (pre-pass against legacy residue)
  and in t.Cleanup (no per-run residue). Preserve the admin's real
  libraries; delete only convention-named test rows.
- setup seeds canonical system collections every run keyed on
  query_type (idempotent — heals renames/stale tombstones), moved out
  of the preserved-admin branch so it runs for every suite start.
- dashboard_integration: scope-testing library renamed into the
  test-row convention; ordering assertions on the restored
  "Want to Read" collection preserved.
- filters_test: filter names moved into the convention (rerun-safe —
  name-uniqueness 409 no longer trips second runs); five  .(string)
  type assertions panic-proofed with require.True guards (a panic here
  used to kill the whole test process mid-run, which is why a stack of
  "never ran" failures surfaced at once later).
- device_test: device names moved into the convention (ILIKE %test%
  hit them; duplicate-device-name conflicts gone).
2026-10-09 11:04:34 -04:00
John O'Keefe 7ce73213ae feat(server): system collections become permanent — soft-delete + in-place restore + query_type-keyed provisioning
The old model hard-deleted system collections and re-created them at
the tail, resurrecting stale dedups and breaking ordering. Redesign
(user-directed B-final):

- schema.sql: collections.deleted_at TIMESTAMPTZ (idempotent ALTER —
  the whole schema is re-applied on every boot via executeSchema).
- queries.sql: every collection list/read query filters
  deleted_at IS NULL; new GetSystemCollectionByQueryType :one
  (unfiltered, sees tombstones), TombstoneSystemCollection :exec,
  ClearCollectionItems :exec, RestoreSystemCollectionRow :one
  (in-place metadata reset + un-tombstone; collections have no
  updated_at column — ResetUpdatedAt was a 500 caught by tests).
  sqlc regenerated.
- collection_service.DeleteCollection: system collections are
  tombstoned (preserving position/ownership); user collections keep
  the hard delete. No ErrNoRows to the client (delete is idempotent).
- dashboard_service.RestoreSystemCollection: resolve by query_type;
  found → in-place restore (ClearCollectionItems + metadata reset,
  un-tombstoned); missing → provision fresh. "Want to Read"/etc.
  stay at their home positions forever.
- handlers/collections.CreateCollection: name-taken is 409, not 500.
- handlers/auth.CreateDefaultCollectionsForUser: idempotent by
  (user_id, query_type) — update-in-place when a row for that query
  type exists (heals renames/tombstones), insert when missing; this
  also fixes the duplicate-system-collections regression at source.
2026-10-09 11:04:24 -04:00
John O'Keefe 31f939002f feat(web): 'a' keyboard shortcut toggles the annotations drawer
The drawer was the only top-bar tool without a keyboard shortcut, so the
shortcuts & gestures help modal had to describe it as '📝 button'. Bind
plain 'a' to toggleBookmarks(), following the same pattern as the
existing t/s/b letter shortcuts.

- reader.ts: new 'a' branch in handleKeydown, guarded against
  Ctrl/Cmd/Alt so Select All (a common reader action when selecting
  text to highlight/copy) is never intercepted; typing guard already
  protects note textareas, search box, and selects. Works in both the
  host page and book-content iframes since both keydown listeners route
  through handleKeydown.
- reader.templ: help modal row now shows <kbd>a</kbd> like the other
  letter shortcuts; 📝 button tooltip gains '(a)' to match its
  siblings (b), (/), (s), (?).
- reader_templ.go: regenerated.
2026-10-08 15:12:27 -04:00
John O'Keefe f83b36167e style(web): location-note rows lose the color bar — only highlights carry a color (user review) 2026-10-08 14:57:41 -04:00
John O'Keefe d5d8e3e173 style(web): drop the NOTE label from the expanded boundary — divider reads on its own (user review) 2026-10-08 13:42:25 -04:00
John O'Keefe 44c0e5640f fix(web): banner highlight-note dialog prefilled; explicit note boundary; pencil on every note row
- BUG: startBannerEdit extracted the note by splitting row.rich — the
  RENDERED HTML — on \n\n, which the concatenated markup never
  contains, so highlight-note dialogs always opened empty. Both row
  kinds carry the raw note on bannerSources; use that lookup.
- Expanded body now splits at the highlight→note boundary: the
  selection paragraph (left border in the row's highlight color), a
  divider + pencil NOTE label, then the note markdown — instead of the
  two texts flowing together.
- The note-indicator pencil (corner badge) now shows on ALL note rows,
  not just highlights with attached notes.

Verified in headless Chromium: badge on note and highlight rows;
expanded highlight shows the bordered selection, NOTE label, and note
markdown; the highlight edit dialog prefills with the note text.
2026-10-08 09:11:47 -04:00
John O'Keefe e7ccd7b222 test(dashboard): align test seed naming with the canonical Title-case set + fix stale expectations
The test helper createDefaultCollectionsForUser carried its own
kebab-case copy of the default system collections — drifted from the
canonical Title-case list the registration seed and
RestoreSystemCollection use. Every suite run against the shared dev DB
re-created a kebab set beside the canonical one (the duplicate sections
on the customize screens); the dev DB is cleaned and the helper now
uses the canonical list.

Two stale expectations corrected while re-running the suite:
TestGetSections_EndToEndFlow asserted the kebab ids (now asserts the
Title-case names — section ids ARE collection names), and
TestGetSections_MissingLibraryID expected a 400 the handler never
implemented — absent library_id is the All Libraries view by design
(now asserts 200 + aggregated sections).
2026-10-07 20:32:36 -04:00
John O'Keefe b1de0da512 feat(web): dashboard All Libraries view saves the global scope
The All Libraries entry in the library switcher could render the
cross-library dashboard but rejected its own settings: the modal
refused to open without a library ('Select a specific library...') and
a save sent library_id: '' (server 400). The server now stores a
global scope (separate table; null/absent library_id, empty string
still 400), so:

- openDashboardSettings allows the All view and fetches prefs without
  the library param (reads the global row; previously a loose NULL
  OR-clause matched an arbitrary library's row).
- saveDashboardSettings sends library_id: null from All Libraries.
- The settings modal gains a scope label (#dashboard-scope-label)
  showing which library — or All Libraries — a save will customize
  (user requirement: always know the scope).

Verified end-to-end in headless Chromium: reorder on All Libraries →
save (null) → reload → modal re-opens with the persisted order and the
All Libraries scope label; per-library save lands in its own row
without touching global (SQL-checked).
2026-10-07 19:51:59 -04:00
John O'Keefe 25d44452c0 feat(dashboard): global (All Libraries) preferences scope
The dashboard's All Libraries view could render sections across every
library but could not persist its own preferences: the PUT required a
UUID library_id, and the prefs GET-without-library_id matched an
arbitrary library's row through a loose NULL OR-clause.

Storage: a dedicated user_dashboard_preferences_global table (one row
per user, user_id PK → users CASCADE) instead of a NULL/sentinel
library_id — chosen over both because (a) the column's FK to libraries
rules out a sentinel UUID without dropping the constraint or minting a
fake library row, and (b) intent becomes structural: only the explicit
global path writes that table, so a buggy or outside-the-API write can
never masquerade as global. System and user collections are
user-scoped, so the order/hidden arrays are library-independent and
valid in this scope.

API: PUT /api/dashboard/preferences binds library_id as *string —
JSON null/absent = global, empty string = 400 (a lost value must stay
loud, not silently clobber the global scope), valid UUID = per-library
(unchanged). GET without library_id now returns the global row instead
of an arbitrary one; the per-library query's dead NULL OR-arm is
removed. GET /sections keeps requiring library_id for per-library
views and aggregates across libraries when absent (unchanged).

Tests: global save/update-dedupe/read round-trip, empty-string 400,
global-vs-per-library independence. Two suite failures are the
handoff's documented pre-existing rot (duplicate system collections in
the dev DB; a sections test expecting a 400 the handler never
implemented) — reproduced and confirmed unrelated to this change.
Live-verified on the dev stack: null save 200 x2 with a single row
updated, empty string 400, global read carries the second save.
2026-10-07 19:46:17 -04:00
John O'Keefe b8db1d109c feat(web): note-attached badge on banner highlight rows — corner stroke glyph
Highlights with an attached note get a small edit-note stroke SVG
(same note-pencil path as the selection popover's Note affordance) in
the card's upper-right corner — themed via var(--text-secondary),
pointer-events: none so taps pass through, header gains pr-8 on those
rows so wrapped text never runs under it. Placement matches the
Android banner's corner badge (user preference: never left of text).
Regenerated annotations_banner_templ.go from the repo root.
2026-10-07 15:30:26 -04:00
John O'Keefe bbecd1e5ec fix(web): rich markdown notes get block structure — paragraphs, quotes, lists, code
Tailwind preflight zeroes p/blockquote/list margins globally, so notes
rendered through renderNoteHtml collapsed flat: blank lines between
paragraphs vanished, blockquotes were indistinguishable from body text,
lists had no bullets/indent, code blocks had no padding. The parser was
always correct — the loss was CSS at the x-html display sites.

One shared scoped block (.note-rich, in @layer components so it
outranks base preflight): p+p margin-top, blockquote bar + indent +
secondary color, list markers + padding, pre/code padding + themed bg,
modest heading sizes, underlined links, table borders, em-based so it
scales with each site's text-xs/text-sm context. Applied at all three
display sites: reader notes-drawer rows (replaces the unused
reader-note-rich hook), the 📝 highlight note line, and the banner's
expanded body.

The regenerated _templ.go files also catch up two stale .templ changes
that had shipped without regeneration (the selection popover's Adjust
button, the banner delete ternary) — the Docker build regenerates at
image-build, so the served site always matched the sources.

Verified live (headless Chromium against the dev stack): .note-rich in
the served stylesheet; p+p computed marginTop 9.6px, blockquote 3px
solid border-left, ul disc + 22.4px indent, pre padded + themed bg.
2026-10-07 08:01:41 -04:00
John O'Keefe ffe690691b fix(web): facedown teardrop handles (standard Android selection fashion) 2026-10-05 19:16:28 -04:00
John O'Keefe 0708a1f414 fix(web): mirrored handle teardrops — end handle points right 2026-10-05 17:29:06 -04:00
John O'Keefe c8e85817aa feat(web): highlight extent adjustment — draggable handles with word/punctuation snap
Objective 9 (web half). The selection popover's edit mode gains an
Adjust button: the overlay's show-annotation event stashes the live DOM
range, start/end teardrop handles render over it (host layer above the
iframe), and pointer drags map into the iframe's caretRangeFromPoint.
Snapping: land next to punctuation → the boundary sits exactly there;
land inside a word → the word's edge in the drag direction; whitespace
trims toward the adjacent word. A live preview repaints through the
overlayer under a scratch key; relocation (page turn) cancels safely.

Release recomputes the range CFI + collapsed-end CFI (view.getCFI) and
saves through the popover's own saveHighlightChanges path (PUT +
remove-old/add-new overlay dance), keeping selection_text and the
anchors in lockstep.

Verified live E2E in Brave: extent extended by one word with the
comma/space snap behavior, saved to the server row, handles cleaned;
all snap paths unit-exercised in-page (word end/start, punctuation
exact on both sides, whitespace trim); the test highlight was restored
to its original extent through the same adjust flow (punctuation-precision
drag back to the comma).
2026-10-05 16:50:27 -04:00
John O'Keefe f45adc6362 fix(web): banner highlight delete; retention wording states the days
The book-detail banner's highlight rows gain Delete (tombstoned
server-side, restorable from the Deleted modal — same as the reader
drawers already offered). The deleted-annotations modal now says the
retention in plain language — 'kept for 30 days' from the
annotation_tombstone_ttl_days tunable (threaded through BookDetail →
NotesHighlightsModal; 1-day singular and retention-disabled variants
handled) — instead of referencing a settings name a reader cannot see.
2026-10-05 12:15:45 -04:00
John O'Keefe 5275f2ba38 feat(web): note editing at the reader slider and book banner; Deleted rename
Objective 8a (web half). Reader notes-drawer rows gain an inline editor
(mirroring the bookmark-rename pattern) that PUTs the new content — the
stored position rides along because the server's note PUT hard-sets the
position column (a probe note proved an omitted position NULLs the
anchor). Book-detail banner rows gain Edit/Delete for location notes
and Edit-note/Add-note for the note ATTACHED to a highlight (full-replace
highlight PUT; empty content clears the note; the highlight itself is
never edited from the banner). The edit dialog is centered with the
markdown caption.

The book-detail 'Notes' button becomes 'Deleted' (badge =
deleted-annotation count) and the modal title becomes 'Deleted
annotations' — that modal only ever showed removed annotations.

Verified live in Brave: drawer inline edit round-trips content and
position; banner note edit, highlight-note edit, and delete (tombstone
appears in the Deleted modal); rename renders with the count badge.
2026-10-05 11:36:14 -04:00
John O'Keefe 0b83db8fce feat(web): book-detail annotations banner — ?loc= reader jumps, single/zip markdown export
Release / build-and-push (push) Successful in 3m9s
Objective 7 (web half). The 'Annotations' button slides a right-hand
banner (annotations_banner.templ + annotations-banner.ts) fed by the
existing per-book GET endpoints: friendly labels (Section N parsed from
the CFI spine / Page N from the PDF envelope / NN% + first-words
fallbacks), capped highlight previews, rich x-html expansion through the
objective-6 markdown pipeline, and per-row 'Open reader here' jumps
riding /readers/:uuid?loc= (consumeLocJump: jump outranks stored
progress for that open; URL stripped before sync can echo it). WEB-ONLY
export: single .md or one-file-per-item zip via fflate — item format
identical both modes ('# friendly label' / '# selection text' headers,
raw markdown bodies, no raw CFIs anywhere).

Verified live in Brave: 12 restored highlights + a markdown note
labeled/expanded correctly; the jump navigated, landed on the right
chapter and consumed ?loc=; export produced the 13-item .md (friendly
headers only) and a valid 13-entry zip. Book-detail's existing
recently-deleted modal stays untouched (banner is additive).
2026-10-04 14:30:56 -04:00
John O'Keefe 4b5e193d9f feat(web): notes render as markdown — markdown-it+DOMPurify pipeline, rich paste
Objective 6 (web half). Storage/sync stay raw markdown (KOReader shows
literal source, accepted). Notes-grade syntax: headings, emphasis/
strikethrough, code, links, lists, blockquotes, GFM tables, reference
links, footnotes (markdown-it-footnote); images/raw HTML/math excluded
(html:false posture + FORBID img/style/form + default safe-scheme URI
check). Display sites: annotations-drawer note rows and highlight note
lines render pre-sanitized HTML (x-html); render-time sanitize only.

Rich paste: both note textareas intercept paste; a text/html clipboard
flavor converts via turndown (gfm tables incl. a headingless-table rule
emitting pipe syntax; images blanked) and inserts at the caret; plain
pastics fall through to the default paste unchanged.

Verified live in Brave: bold/italic/strike/code/lists/quote/table/
footnote-ref/reference-link render; <script> renders inert literal
text with no alert; javascript: hrefs absent from the DOM; paste of
rich HTML lands markdown at the caret; plain pastes untouched.
2026-10-04 13:25:17 -04:00
John O'Keefe b9f797789f feat: registration_enabled admin setting — hides signup surfaces, gates the API
Release / build-and-push (push) Successful in 3m9s
One boolean (Security > Public Registration) switches the whole public
signup lifecycle: front page + login links, the logged-out sidebar's
Create-an-account (via a package-level templates hook so shared page
templates keep their signatures), GET /register -> 302 /login, and an
early 403 on POST /api/auth/register. First-user exception preserved:
zero admins keeps every route and link reachable for bootstrapping —
same 'users exist' reasoning as the setup gate. Admin user creation is
unaffected by design.

Live E2E verified: flag off hides all surfaces and blocks the POST
(403), flag on restores them (201); the admin UI renders the row
automatically.
2026-10-03 23:54:07 -04:00
John O'Keefe 6aaefe210a pin foliate-js b7597d6 — paginator teardown-race guards (v0.5.1+0.5.2) 2026-10-03 23:10:21 -04:00
John O'Keefe fb21d5249c fix(rescan): updateMediaItem re-derives format flags — force rescan reclassifies rows 2026-10-03 22:40:40 -04:00
John O'Keefe b03ed9a2f2 fix(compose): publish db on loopback only — was reachable from any LAN
The unqualified 0.0.0.0 publish put Postgres on every interface, and
Docker delivers published ports through PREROUTING DNAT into the
FORWARD path — ufw's default deny incoming never sees those packets.
Result: the database was reachable from whatever network the laptop
joined (home, guest Wi-Fi, hotel), not just from the host.

Bind to 127.0.0.1/[::1] instead: with no DNAT matching LAN-destined
packets, they fall back to INPUT where the firewall actually applies.
Host-side tools keep working over the loopback publish (both families
bound because localhost may resolve to ::1 first); app↔db and tests↔db
are untouched — they use the db service name on the compose network,
which never traverses iptables on this host (br_netfilter not loaded).

If LAN access to the DB is ever wanted again, revert to an unqualified
publish and rely on DOCKER-USER home-subnet scoping instead of an
open binding.
2026-10-03 21:35:15 -04:00
John O'Keefe 72839921f2 drop DOC and LIT from the pipeline (deliberately unsupported)
.IMAGE  — extensions removed from AllowedExtensions (scan gate),
bookExtensions, MimeTypes and sync/format.go maps: .doc/.lit files
are no longer scanned or indexed. classifyFormatGroup gains the
RTF and PDB reflowable arms that were missing when those rungs
shipped (rows only reclassify on creation). Scanner docs updated.

DOC + LIT have no credible JS tooling (mammoth is docx-only; LIT
needs LZX and its DRM variants are dead) and text-extraction-only
support would misrepresent what the reader can do. Verified: probe
.doc/.lit files watched but never scanned; probe .txt control
scanned; TestClassifyFormatGroup extended and passing.

Docs change granted in-session by the user (extensions + format
docs).
2026-10-03 16:11:04 -04:00
John O'Keefe 25981fc762 pin foliate-js ac699e7 — RTF + PalmDOC support (fork v0.5.0) 2026-10-03 15:41:06 -04:00
John O'Keefe f2629942e4 fix(scanner): classify .docx as reflowable — was landing format_group unknown
.docx is accepted for ingest but the format-group switch had no case
for it, so every DOCX row stored format_group 'unknown' with
is_reflowable=false. Extract the classification switch into a pure
classifyFormatGroup helper with a table test, and classify .docx
reflowable like the other reflowable text formats (.rtf/.doc stay out
until they are readable client-side). Existing rows: dev alice (DOCX)
reclassified by hand; production rows reclassify on rescan.
2026-10-03 14:17:58 -04:00
John O'Keefe ad1ad11fb5 docs(bruno): document the 100k note content limit
Release / build-and-push (push) Successful in 2m49s
Create/Update Media Note request docs still said "1-10000 chars";
update both to match the raised validator cap.
2026-10-02 21:31:18 -04:00
John O'Keefe 66dd6c8096 feat(reader): note length counter and save guards in the reader UI
Oversized notes previously failed only at save time, surfacing the
raw backend validator error to the reader. The reader now handles
the limit inline and keeps oversized input editable:

- Live character counter under both note textareas (annotations
  drawer "Add a note" and the selection popover note editor),
  formatted "12,345/100,000" — muted normally, red once over
- Pasting is never truncated; the full text stays in the textarea
  so the reader can shrink it however they see fit
- The three save actions (Add Note, Highlight with note, Save
  note) disable while over the limit, with a toast fallback in
  addNote / createHighlight / saveHighlightChanges so the guard
  holds even outside the disabled-button path
- noteMaxLength mirrors the server-side cap (100,000, see
  internal/handlers/media.go) so client and server stay linked

Also truncate note rows in the drawer list — highlight rows
already truncated, but a long note previously stretched the
drawer body.

The resize handle needed no work: .reader-note-input already
ships resize: vertical in the compiled CSS; style.css is rebuilt
only for the new disabled:* utility classes.
2026-10-02 21:31:13 -04:00
John O'Keefe 1205526422 feat(notes): raise annotation text limit from 10,000 to 100,000 chars
The 10k validator cap rejected legitimate long-form notes — a
scholarly reading note with quoted passages and footnotes lands
around 10.1k chars and failed at save with the raw validator error.

Raise the cap to 100,000 on all six annotation text fields in the
media handler request structs:

- CreateMediaNoteRequest / UpdateMediaNoteRequest Content
- CreateMediaHighlightRequest / UpdateMediaHighlightRequest NoteText
- CreateMediaBookmarkRequest / UpdateMediaBookmarkRequest Notes

No other layer changes: the media_notes / media_highlights columns
are unbounded TEXT, and the KOReader + websocket sync paths never
had a length cap, so the REST API now matches the rest of the
system instead of being the strictest gate.

New integration test pins the boundary: 50k and 100k-char notes
return 201, 100,001 chars returns 400.
2026-10-02 21:31:03 -04:00
John O'Keefe 29ac420876 chore(deps): bump @bookhoard/foliate-js to 50f56ee — paginator guards for not-yet-loaded section documents 2026-10-02 16:40:05 -04:00
John O'Keefe 5ffa371963 chore(deps): bump @bookhoard/foliate-js to 8e1d5fc — DOCX support via mammoth 2026-10-01 14:30:50 -04:00
John O'Keefe 0be70f0051 docs: drop AZW3 from documented format expectations
AZW3/KF8 is a proprietary legacy Amazon format (the pipeline moved to
KFX in 2015; KDP dropped MOBI-family uploads in 2022) and does not
render in the web reader. The scanner still ingests .azw/.azw3 files
(code unchanged) — they are simply no longer documented as a supported
expectation.
2026-09-30 21:22:44 -04:00
John O'Keefe f07dfbfe74 chore(deps): bump @bookhoard/foliate-js to cd2fbf2 — plain-text (.txt) renderer
makeTextBook: UTF-8 decode, blank-line paragraph split, hard-wrapped
lines joined, HTML-escaped, ~150KB section cap at paragraph boundaries
(Part N TOC). Enables the web reader for .txt (previously ingested but
unreadable client-side). Positions for txt books are percentage-based
(sections get index-derived fake CFIs).
2026-09-30 15:48:55 -04:00
John O'Keefe b4b1deafb8 docs(user): Android app guide — LAN setup and the Android 16+ local-network permission
Android 16+ gates per-app access to local networks behind a runtime
permission; without it the app's LAN logins time out with zero packets
leaving the phone (browser works — it is not per-app-gated). The new
device guide covers the sideload install, first-run server URL, the
permission prompt (and manual re-grant path), and a triage table for
the login screen's error line. Per the user: overlay-VPN addresses are
not a consideration and stay undocumented.
2026-09-29 18:12:57 -04:00
John O'Keefe 217f411f74 fix(auth): GetProfile reads the user row — the JWT context stub has no theme
GetProfile returned the theme from the JWT-claims context stub, which
carries only id/email/username/role — user.Theme was always empty, so
the profile response omitted the field and the app's account-theme
read-back (SERVER sync mode) could never see the stored theme. GetProfile
now loads the full user row by id (h.db.GetUser).
2026-09-28 08:45:01 -04:00
John O'Keefe 4fde2a6d83 feat(auth): GetProfile returns the account theme
The Android app's app-chrome theme (UX pass item 6) reads the account
theme from GET /api/auth/profile and re-applies it at startup in
SERVER sync mode. PUT /api/auth/theme already persisted the value;
this completes the round trip on the profile read.
2026-09-28 08:34:11 -04:00
John O'Keefe 3d88f0b960 feat(search): publisher_filter for the media search endpoint
Adds publisher_filter to SearchMediaItems/SearchMediaItemsUnified
(fuzzy word_similarity against mi.publisher, mirroring genre_filter),
plumbs it through services.SearchParams and the search handler, and
extends the GREATEST relevance ranking to include publisher matches.
Serves the app's author/publisher/genre/tag click-through browses
(UX pass item 5 — publisher was the only facet without a server
filter).
2026-09-27 19:55:58 -04:00
John O'Keefe 6aea6a4e2c fix(sync): countTextCharsBefore multiplied the document by node depth
Release / build-and-push (push) Successful in 2m57s
countTextCharsBefore started its sibling walk at the target node itself
and recursed after counting the current node's subtree, so every
ancestor level re-counted everything accumulated so far — a node at
depth 4 in the single-document 1984 epub reported 2.38M chars before it
in a 589k-char document, producing healed percentages of 4.04 (>1) and
a 500 on the reading_progress percentage check constraint. Every
healed progress save from a fresh client failed; only exact-context
matches (no heal) stored.

The walk now starts at target.PrevSibling: strictly the characters
before the node, per the contract all three callers already assume
(healed book offsets, CRE convert percentage, kepub offsets).

Found by the real-phone validation pass: the phone's first healed
submission 500ed where the emulator's had matched context exactly and
never taken the heal path.
2026-09-26 22:01:35 -04:00
John O'Keefe b10bf3e8c7 docs(api): real progress endpoint contract; remove nonexistent-endpoint docs
The progress documentation described GET/POST /api/progress/:id —
endpoints that do not exist in the router — while the endpoint every
client actually uses (GET/PUT/DELETE /api/media-items/:id/progress) had
no field-level docs at all.

New:
- progress/position-contract.md: the canonical position model — server
  as position authority, the three-tier submission (percentage /
  context_text / epubcfi), ingest verification and healing, the restore
  handles, the OPF spine numbering hazard (canonical CFI spine steps
  include linear="no" items; clients resolve documents by anchor_href
  and land by css_selector + char_offset, never by spine step), the
  offset currencies (UTF-16 at the wire, runes internal), and
  context_text rules.
- progress/get_media_progress.md and update_media_progress.md: the real
  endpoints with full field tables, conditionality of the restore
  handles, the first-page anti-clobber guard, and healed-response
  semantics.
- progress/delete_media_progress.md: the real DELETE route.
- koreader/sync_progress.md and koreader/get_metadata.md rewritten to
  the actual payloads: the plugin sends a single-book array whose
  "epubcfi" field is a CRE xpointer; the metadata response navigates
  via koreader_xpointer (canonical CFI converted back to CRE), with
  page as the canonical locator for fixed-layout books.

Removed: the five files documenting the nonexistent /api/progress/:id
GET/POST/DELETE endpoints. Kept get_progress_history.md (that route
exists). The legacy developer/api-reference.md and the indexed
api/api-reference.md progress sections now match the wire and link the
new docs; the duplicate "Universal Progress" section points at Reading
Progress.
2026-09-26 21:27:44 -04:00
John O'Keefe 8c3273a0fc fix(sync): normalize character offsets to UTF-16 at the wire; refresh book offset on every verified save
Offset currency policy, now explicit: EPUB CFI terminals, CRE text()
offsets and the served char_offset handle are UTF-16 code units (the
EPUB CFI spec, and what foliate/readium/KOReader/Kobo clients actually
observe), while internal arithmetic — the book-wide character_offset
column and percentage fractions — stays rune-based, consistent with
TotalCharacters. For all-BMP books the currencies are identical, so no
stored value changes; astral-plane text (emoji, rare CJK) no longer
drifts.

Boundaries converted: resolveCFIToNode interprets incoming CFI terminal
offsets as UTF-16; textNodeAtUTF16Offset (née textNodeAtRuneOffset)
interprets CRE text() offsets as UTF-16; buildCFI and buildCREXPointer
emit UTF-16 terminals; blockCharOffset (the served char_offset) is
UTF-16.

Also fixes two character_offset column defects: heals wrote a BLOCK-
relative offset into the book-wide column, and verified-but-unhealed
saves (e.g. KOReader pushes) never refreshed it, leaving it stale
behind the anchor. VerifyProgressAnchor now returns the verified book-
wide rune offset and SaveProgress refreshes the column on every
verified save.

Tests: astral currency round trip (offset after an emoji must shift by
one unit between currencies, in both heal and exact-verify directions)
and book-offset ordering. The cmd/server/tests integration harness
failures under docker (library folder 400 during setup) reproduce on
the pre-change tree and are unrelated.
2026-09-26 20:18:48 -04:00
John O'Keefe aec226af1a feat(sync): server-side position authority — verify/heal progress anchors
Progress submissions now carry (percentage, context_text, epubcfi) and
the server becomes the position authority:

- VerifyProgressAnchor resolves the submitted standard CFI against the
  book's own XHTML, extracts the text at the anchor, and cross-checks it
  with the submitted context_text. A mismatch heals the anchor by text
  search (percentage disambiguates repeats) instead of storing a bad
  position.
- The anchor's block element is derived as a cssSelector plus a block-
  relative character offset, and served on progress GET alongside the
  anchor document's href — readium-native handles that let clients
  re-open a book without parsing CFIs themselves.
- context_text-only submissions (no CFI — the dumb-client tier) are
  anchored structurally from the context text.

Motivation: cross-client progress sync (web foliate CFIs, KOReader CRE
xpointers, readium-native apps) previously trusted each client's own
locator math; the app's EPUB restore drifted ±pages because readium's
paginator does not lay out far-from-viewport columns and the foliate-
ported CFI walk ran against readium's mutated WebView DOM. Server-side
verification heals both classes at ingest.
2026-09-26 14:43:34 -04:00
John O'Keefe 1c1f5cf1bb feat(version): bake release tag into binary; About card and /health field
Release / build-and-push (push) Successful in 3m1s
The version tag previously existed only in git and the Docker image tag —
the running app had no way to report what build it was. Version is now
injected at build time via -ldflags into internal/version.Version (defaults
to "dev" for local builds), passed by the release workflow as the
APP_VERSION Docker build arg from the pushed tag.

Surfaced in Admin → Settings → About (extensible card for future rows like
disk space) and in the /health JSON response, so deployments can be
verified with curl alone.
2026-09-21 20:21:43 -04:00
John O'Keefe 9e4cc4efd0 chore(templates): regenerate templ outputs and rebuilt static assets
Release / build-and-push (push) Successful in 2m42s
Regenerates all *_templ.go files after the templ generate pass (version
stamp updates across the board; functional changes only in
admin_archived_templ.go and admin_library_templ.go, which were committed
with their features). web/static/style.css and web/static/htmx.min.js are
refreshed outputs of npm run build (tailwind pass over the current
templates; htmx copied from the current node_modules version).
2026-09-21 18:10:47 -04:00
John O'Keefe 7b0b2793a6 test(infra): self-healing admin password; cross-library move/merge suite
setupTestServer reuses the shared dev admin (testuser@tests.bookhoard.internal)
instead of re-inserting it, but never reset its password — once any test
mutated the admin's password, every later test in the run failed to log in
with 401s until the database was manually wiped. The seeding step now
resets the password hash to the known test constant on reuse, so full
integration runs are repeatable against an existing database.

Adds cross_library_move_test.go, seven integration tests covering the
duplicate-content features end to end:

- TestCrossLibraryMovePreservesHistory: a book moved between two same-type
  libraries keeps its row ID — progress and annotations survive, no
  duplicate, no archived ghost, library_type_name stays truthful.
- TestCrossLibraryCopyStaysIndependent: deliberate copies in two libraries
  stay independent rows with isolated progress.
- TestCrossLibraryMoveRejectedForTypeMismatch: a reflowable EPUB is not
  repointed between manga libraries; a library_type_mismatch processing
  issue is recorded instead.
- TestListHiddenMediaItemsMatchesActiveTwin: archived rows expose their
  active same-SHA twin (and rows without one report no match).
- TestMergeArchivedItemIntoActiveTwin: merging moves progress and
  annotations onto the active copy and removes the archived row.
- TestMergeArchivedItemRejections: non-admin 403, active source, hash
  mismatch, missing target_id, self-merge, and archived target all fail
  closed.
- TestLibraryTypeNameTriggerOnLibraryChange: the UPDATE trigger refreshes
  library_type_name when a row changes libraries.

Tests create their own temp-dir library folders (host runs don't have
/app/uploads), use unique device identifiers (device_identifier is
UNIQUE and leftover rows broke reruns), and clean up via an explicit
defer that runs while the pool is still open — a t.Cleanup registered
for the same purpose silently no-ops because it executes after
setup.Close has closed the pool.
2026-09-21 18:10:37 -04:00
John O'Keefe 077afaca56 chore(docker): bake Go module cache into the test-runner image
The test-runner stage copied only /app from the builder, but the Go module
cache lives in /go/pkg/mod (GOMODCACHE) — the cache mounts used by the
builder target /root/go/pkg/mod, which the go tool ignores, so those mounts
never held anything. Every `compose run tests` / make test-integration
invocation therefore re-downloaded all dependencies from the network,
making integration runs slow and timeout-prone.

The builder now materializes /go/pkg/mod into an image layer
(go-module-cache) after the binary build, and the test-runner stage
restores it to /go/pkg/mod before running tests. Test-only stage; the
production final stage is unaffected.
2026-09-21 18:10:17 -04:00
John O'Keefe 388b62b277 fix(admin): delete-library warning names what is lost; modal now closes
Three fixes to the Delete Library flow on /admin/library:

1. Honest warning copy. The old text ("Media files will not be deleted")
   read as reassurance while deleting a library actually cascade-destroys
   every book record with it: reading progress, highlights, notes,
   bookmarks, and ratings are gone with no archive window and no undo.
   The modal now states this as a scannable list:
   - 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.
   Admins skim danger dialogs; the irreversible part now leads.

2. The dialog stayed open after confirming. The confirm button swaps
   #libraries-container via htmx (so the deleted row vanished) but the
   modal lives outside the swapped container and nothing closed it. An
   htmx:afterRequest listener now hides the modal on successful deletes
   and leaves it open on errors.

3. Latent ReferenceError in openFolderBrowser: the function parameter is
   targetInputId but the body referenced an undefined targetInput, so
   opening the folder browser threw and the picker never loaded.
2026-09-21 18:09:52 -04:00
John O'Keefe 5da97b2e5d feat(opds): library folder navigation and library-scoped search
The device catalog flattened every visible library into one list, so
duplicate copies of the same book each got an entry and search had no
library context. The root feed is now a standard OPDS 1.1 navigation feed
that mirrors the web UI's library model:

- Root /catalog: an "All Books" entry first (the previous flat cross-
  library behavior, also still served at ?all=1 for clients that want
  the single flat list), followed by one folder entry per visible
  library with live book counts from GetVisibleLibraryMediaCounts.
- New GET /library/:libraryId/catalog: acquisition feed scoped to one
  visible library (403 when the device owner cannot see it), paginated,
  with an up-link back to the root.
- Scoped search: each library feed's rel="search" OpenSearch template
  pins &library_id=<id>, so opening search from inside a library folder
  searches only that library — clients substitute only {searchTerms},
  so no client-side changes are required. Root search stays global.
- Global search entries now carry the owning library as a category (and
  as a fallback summary when the book has no description), so duplicate
  copies are distinguishable in unscoped result lists.

The acquisition entry builder is extracted into addAcquisitionEntries and
shared by the all-books and per-library feeds. bookhoard.koplugin needs
no changes: it only registers the root URL, and KOReader's stock OPDS
client renders navigation feeds natively.

Tests: TestOPDSLibraryFolders covers the nav-feed shape, flat ?all=1
mode, scoped catalog isolation, scoped/global search behavior, and 403s
for libraries outside the device's visibility. Library names avoid the
word "test" on purpose — setupDeviceTest re-runs setupTestServer's
setup-time cleanup, which deletes every library whose name contains it.
2026-09-21 18:09:30 -04:00
John O'Keefe 45e3a256e2 feat(collections): merge archived duplicates into their active copy
Covers the copy-then-delete-later workflow: a user copies books to a new
library, deletes the originals, and ends up with an active copy in the new
library plus an archived twin holding the real reading history. Until now
those twins could only be purged (destroying the history) or restored
(showing a permanently broken entry).

- POST /api/media-items/:id/merge (admin only, body {target_id}):
  validates the source is archived/missing, the target is active, and
  both share the same file_sha256; then re-parents every child row onto
  the target via the existing reparent_media_item_children function (the
  same machinery as hash-conflict resolution) and deletes the source row.
  Per-user collisions keep the active copy's data, mirroring that flow.
  Affected data: reading progress, speed, ratings, highlights, notes,
  bookmarks (including tombstoned deleted-annotation history), formats,
  collections, kobo shelves/entitlements, sync rows, panel data,
  processing issues, and device aliases.
- GET /admin/archived: ListHiddenMediaItems' match_* columns surface each
  row's best active twin; rows with a twin get a confirm-guarded "Merge"
  button (data-merge-source/-target) next to Restore/Delete, wired in
  web/src/admin.ts like the existing unarchive/delete handlers.
- templates.ArchivedItem gains MatchID/MatchTitle/MatchLibraryName;
  frontend.go populates them from the listing row.

After a merge the archived row is gone, so the retention purge can never
destroy the merged data. Deleted-annotation tombstones carry over and
remain restorable from the target book's "recently deleted" history.
2026-09-21 18:09:05 -04:00
John O'Keefe 64ef691d64 feat(scanner): repoint rows on cross-library moves of identical content
Previously the scanner's SHA-256 dedup was library-scoped: moving a book
between libraries created a duplicate row (new ID) while the old row went
missing and archived after two scans, orphaning reading progress and
annotations from the file that users still see.

processMediaFile now falls through to cross-library detection when the
same-library hash lookup misses:

- GetMediaItemsBySHA256AndSameLibraryType returns candidates in other
  same-type libraries; each candidate's file is stat'd through ITS OWN
  library's folders (the old existence check stat'd against the current
  scan's folder tree, which is meaningless across libraries).
- File gone -> it is a move: the candidate must pass the target library's
  type rules (ValidateMediaItemForLibrary), then the row is repointed via
  MoveMediaItemToLibrary with the recomputed relative path. Reading
  history, annotations, and collections follow automatically because the
  row keeps its ID. Incompatible formats (e.g. a reflowable EPUB into a
  manga library) are rejected with a processing issue instead of being
  force-imported; the upsert on (media_item_id, issue_type) keeps
  repeated scans from spamming duplicates.
- File still present -> deliberate multi-library copy: fall through to
  normal import so both libraries keep independent rows.

Candidate selection is factored into selectMoveCandidate (pure function,
unit-tested in media_scanner_move_test.go): input arrives pre-ordered
(archived first, then most missing scans, then oldest) and only
candidates whose file is verifiably gone qualify, so deliberate copies
are never repointed.
2026-09-21 18:08:43 -04:00
John O'Keefe a4dbe79d0b feat(data): duplicate-content queries — archive filters, cross-library moves, archived-twin matching
Three related query groups in queries.sql (sqlc regenerated; no generated
signatures changed, so no caller edits were needed):

1. Dashboard/collection archive filters. The smart-section and collection
   queries returned archived and missing items (visible only as broken
   covers once their files vanished). Added the codebase-standard
   "AND archived_at IS NULL AND missing_scan_count = 0" predicate to:
   GetContinueReadingItems, GetRecentlyAddedItems, GetRecentlyReadItems,
   GetNotStartedItems, GetCollectionItemsForDashboard, GetLibraryItems.
   Matches the existing convention in ListMediaItems, SearchMediaItems,
   the library counts, and GetContinueSeriesItems.

2. Cross-library SHA move detection support.
   - GetMediaItemsBySHA256AndSameLibraryType: finds identical content
     (same file_sha256) registered in a DIFFERENT library of the SAME
     library type, ordered archived-first, most-missing, oldest. Type
     scoping keeps ebooks from merging into manga/comics libraries.
   - MoveMediaItemToLibrary: repoints an existing row to the new library
     (library_id + recomputed file_path + file_size) and clears
     archive/missing state. The row keeps its ID, so reading progress,
     annotations, collections, and kobo shelves follow the book
     automatically; the new set_library_type_name_on_library_change
     trigger refreshes the denormalized type column.

3. Archived-twin matching. ListHiddenMediaItems (admin archived-items
   page) now LEFT JOIN LATERALs the best active same-SHA twin per hidden
   row — preferring same library type, then same library, then most
   reading progress, then oldest — exposing match_id / match_title /
   match_library_name so the UI can offer merging the archived row's
   reading data into its active duplicate. COALESCE on match_title keeps
   the no-match case NULL-safe.
2026-09-21 18:08:18 -04:00
John O'Keefe 1002cbca93 fix(schema): refresh library_type_name on library change; make trigger block re-runnable
schema.sql is re-executed in full on every application startup, so every
statement in it must be idempotent. Two related problems introduced with the
cross-library move feature, plus their fix:

Problem 1: media_items.library_type_name was only populated by a BEFORE
INSERT trigger. When the scanner repoints an existing row to a different
library (cross-library move detection), the denormalized library_type_name
went stale, mislabeling the item's type for validation and display.

Fix: add a second trigger, set_library_type_name_on_library_change, that
fires BEFORE UPDATE OF library_id and re-runs the same population function.

Problem 2 (outage): the new trigger EXECUTE FUNCTIONs set_library_type_name(),
but the pre-existing idempotency dance drops that function on every startup
before recreating it. On the second and later boots, DROP FUNCTION failed
with SQLSTATE 2BP01 (function still depended on by the trigger created by
the previous boot), aborting the whole schema transaction and crash-looping
the container.

Fix: drop BOTH triggers before dropping the function, and create both after
it. The ordering now survives any number of restarts on any database state.

Adds TestSchemaInitializationIsIdempotent, which replays the production
database.Initialize twice against the same database so this class of
"second startup" regression fails in CI instead of in production. Note the
test uses a dedicated pgxpool: Initialize holds the advisory lock on one
connection while executing the schema on another, which deadlocks against
the shared test pool's MaxConns=1.
2026-09-21 18:07:51 -04:00
John O'Keefe 65f6bdf075 chore: complete go.sum with test-graph dependency sums
go mod download all added sums for modules only reachable from test
builds (chroma, bluemonday, compress families); without them the
integration-test binary build fetches missing sums at compile time.
2026-09-18 18:09:20 -04:00
John O'Keefe 4faa217822 test: repair integration harness — wire SettingsRegistry, reuse preserved dev admin
Two pre-existing harness breaks found while running the device tests:

- RegisterRoutes dereferences cfg.Settings (auth rate limit) but
  setupTestServer never set it — every integration test nil-panicked at
  route registration. Wire database.NewSettingsRegistry(queries) the
  same way main.go does.
- The cleanup deliberately PRESERVES testuser@tests.bookhoard.internal
  (shared dev admin), but setup then blindly re-INSERTed that user, so
  every run after the first failed on users_email_key. Reuse the user
  when it exists; default collections are created only for a NEW user
  (the preserved admin already has its set).

Also documented the offline runner used on slow links: build the test
binary on the host (CGO_ENABLED=0 go test -c) and execute it inside the
bookhoard-tests image against the compose network — no in-container
module downloads, no image rebuild.
2026-09-18 16:06:14 -04:00
John O'Keefe 5cdfe5a8dd fix(devices): release pendingMu on the pending-status path of CheckRegistrationStatus
The mutex hardening added a fall-through 'pending' response path that
returned while still holding pendingMu — one status poll before approval
leaked the lock and every later register/approve/status request hung
forever (caught by TestDeviceRegistrationFlow's approve step hanging).
2026-09-18 16:06:03 -04:00
John O'Keefe 1cd8557b58 fix(devices): re-approving a known device rotates its token instead of 500
App reinstalls that preserve data (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 ApproveDevice's blind INSERT failed with a unique
violation and returned 500 'failed to create device' (reproduced via
curl: second approve with the same identifier = instant 500; the ~98s
in the original report was app-side retry/polling, not server wait).

ApproveDevice is now idempotent: look the device up by identifier
first; a row owned by the approving user gets its auth token rotated
via UpdateDeviceAuthToken (row id unchanged, so synced highlights/
bookmarks/progress anchored to it stay valid; fresh install = fresh
credentials, old token invalidated); a row owned by another user gets
409; unknown identifiers INSERT as before, with the 23505 race falling
through to the rotate path. DB failures are logged (they were silent).

Also guard the in-memory pendingRegistrations map with a mutex —
register/approve/reject/status/list all touch it from HTTP goroutines,
and a racing write is a Go runtime fatal, not an error. The approver's
credential publication and the status poller's approved-branch snapshot
now run under the lock so the token can never be read half-written.

Regression test: TestApproveDeviceReapprovalRotatesToken — register →
approve → re-register same identifier → approve (must be 200) → token
rotated, exactly one devices row, row carries the new token.
2026-09-17 23:09:02 -04:00
John O'Keefe b4c956aed4 docs(user): web reader guide — navigation, touch selection, highlights
User-facing documentation for the rebuilt web reader (§6.5 of the app
handoff): swipe-to-page / tap-for-menu touch navigation, long-press word
selection with drag extension and handles, the selection popover (color
dots, notes with save-together-on-create, copy incl. plain-HTTP LAN,
edit/delete by tapping a painted highlight), below-the-selection popover
placement rationale, PDF/comic behavior, and annotations drawer.
Linked from the user documentation portal and the docs index quick
links / quick-find tables.
2026-09-17 20:13:45 -04:00
John O'Keefe 7caa46c2da fix(build): exclude local package-lock.json from Docker build context
The lockfile is gitignored (each machine keeps its own), but
.dockerignore did not exclude it, so any stale local lock rode into
every docker build via `COPY package*.json`. Because the forked
foliate-js declares "version": "0.0.0" on every commit, npm treats
the git pin as already satisfied by name@version and never
re-resolves the new commit hash — silently installing and bundling
the old code. This bit both the host npm cache mount (documented at
Dockerfile:18-20) and, today, `make rebuild-app-force`: a fresh
no-cache image was built with the pre-feature 1305a52 foliate-js
(chunk fixed-layout-B8-qRQLl.js) despite package.json pinning
e16530a, while the Gitea runner (fresh checkout, no lockfile, cold
cache) built correctly.

With no lockfile in the context, npm install resolves git pins
fresh from package.json each build (tarballs are cached by
commit-specific URLs), so the persistent npm cache mount cannot
serve old commits across pin bumps. Local lockfiles can no longer
poison builds even if regenerated on the host.

Verified: after evicting the poisoned cache mounts
(docker builder prune --filter type=exec.cachemount) and rebuilding,
the container serves fixed-layout-BE0KdOql.js with both dblclick
handlers present, matching the reference build.
2026-09-17 15:37:58 -04:00
john-okeefe 5329bf35a9 fix: pin foliate-js e16530a — desktop double-click zoom toggle
Release / build-and-push (push) Successful in 3m20s
Bump the linuxhg mirror pin from 1305a52 to e16530a (mirror main is
already synced). The fork adds a desktop double-click zoom to the
fixed-layout renderer for all fixed-layout content (comics, manga,
PDFs, fixed EPUBs):

- at fit scale, double-click zooms to 2.5x at the clicked location
- zoomed or panned, double-click resets to 100% centered
- PDF text-layer spans and annotation links keep native double-click
  (word selection) in Smart/Text modes; Pan mode zooms anywhere
- mirrors the existing touch double-tap; disabled in panel-zoom mode
  like the other gestures

Also closes the gap where node_modules was serving a stale tarball
older than the previous 1305a52 pin (missing the toolbar-resize
debounce and selection-drag fixes); a fresh install now resolves the
full set.
2026-09-17 14:37:05 -04:00
john-okeefe 80e5d5b0d1 feat(reader): comic treatment for fixed-layout items in comics/manga libraries
PDFs shelved in comics or manga libraries (scanned manga, official manga
PDFs with text layers) now get comic reader treatment: bookmarks only —
no text-selection highlights, no annotations, no in-book search. Items
in ebooks libraries are completely unaffected, and EPUBs keep their
existing behavior everywhere.

- handlers: new exported ShouldTreatAsComic(libraryType, formatGroup) —
  true for fixed_layout/comic_archive in manga/comics libraries; the
  reader JSON API also exposes it as treat_as_comic
- router: the SSR reader page fetches the library type and passes
  TreatAsComic through ReaderMetadata into the init config
- reader: when treatAsComic is set, the PDF textLayer selection listener
  (mouse and touch paths) never attaches so no highlight popover can
  open; pointer mode is forced to pan (Smart/Text segment hidden);
  search button and toggleSearch are disabled; previously-created PDF
  highlights stop rendering
- bookmarks are unchanged (already page-index based for fixed layout),
  and device sync / OPDS / format classification are untouched since
  format_group stays fixed_layout
2026-09-17 14:36:58 -04:00
John O'Keefe b3a429d7e1 fix(reader): Save/Cancel in the note editor closes the whole popover
Release / build-and-push (push) Successful in 2m46s
Cancel only collapsed the editor section, and edit-mode saves only set
noteOpen=false — the minimized popover (colors/pencil/copy/delete) stayed
up. Explicit Save and Cancel now call hideSelectionPopover; color tweaks
with the editor open still keep it open.
2026-09-17 11:47:25 -04:00
John O'Keefe d8179bb066 fix(reader): popover dismiss on PDF taps; notes save on create; colors don't close editor
- The in-book pointerdown dismiss listener lived in the reflowable-only
  block, so PDF taps never closed the selection popover — hoist it so
  every content doc (EPUB, PDF, comics) dismisses on tap.
- createHighlight hardcoded note_text: '' — 'Highlight with note' saved
  the highlight but silently dropped the note. Send p.note (and pass it
  to the EPUB overlayer).
- saveHighlightChanges collapsed the note editor on every save; color
  dots in create mode created a highlight outright. With the editor
  open, color dots now just recolor (create) or recolor-and-save with
  the editor kept open (edit).
2026-09-17 10:55:21 -04:00
John O'Keefe 960305dfa9 fix(reader): PDF selection popover shows reliably on touch, below selection
Chromium's touch selection takeover swallows pointerup in the fx iframe,
so the PDF popover's only trigger never fired on real phones — it only
appeared when timing happened to deliver the event. Mirror the EPUB
path: a selectionchange debounce (400ms settle) opens the popover, with
the pointerup path kept for desktop plus the quick-tap word-select guard.

Also place the popover BELOW the selection on coarse pointers (+90px
clearing the native selection menu and drag handles), matching the
reflowable path; desktop keeps above-placement.
2026-09-17 10:40:12 -04:00
John O'Keefe 16bbeec5a3 fix: fresh npm cache mount id to evict stale foliate-js tarball 2026-09-17 10:17:34 -04:00
John O'Keefe 39f9c39360 fix: pin foliate-js 1305a52 via linuxhg mirror
GitHub-pinned builds silently reused a stale foliate-js tarball from
Docker's npm cache mount (name@version never changed from 0.0.0);
every build since the GitHub pin shipped pre-94bb384 code. Repin to
the mirror and bust the cache mount.
2026-09-17 10:13:33 -04:00
John O'Keefe 011b6d14e4 fix: pin foliate-js 1305a52 — fx renderer ignores toolbar resizes during selection
The fx renderer re-rendered the PDF on every viewport resize;
mobile browser toolbar transitions (7-17% height change) during a
text selection drag caused the old canvas to be cleared for
re-rendering while the async pdf.js render raced with the next
resize, blanking the page. Now gated by the same 25% threshold
as the paginator.
2026-09-16 17:30:00 -04:00
John O'Keefe d4661e7f04 fix: pin foliate-js 94bb384 — PDF selection drags no longer blank the page
The fx renderer's gesture classifier only checked whether the touch
started on a .textLayer span; Chromium's long-press selects the
nearest word even when the finger landed between spans, so the
classifier saw a non-selectable target and classified the drag as
swipe/pan — preventDefault on the iframe's touchmove then cancelled
the native selection extension mid-drag and could blank the PDF
canvas. If any frame already has a non-collapsed selection, the
drag is now classified as native.
2026-09-16 17:13:24 -04:00
John O'Keefe 27a93ba2ea fix(reader): hide the chrome when a touch selection starts
Release / build-and-push (push) Successful in 2m28s
pokeChrome's anySelection gate only prevents the chrome from being
raised — if it was already visible when the selection began, it
stayed up. noteSelectionActivity now actively hides it the moment
a selection appears.
2026-09-15 20:20:34 -04:00
John O'Keefe 7afad5dcde chore: point foliate-js at linuxhg mirror main (8a20399) 2026-09-15 20:13:07 -04:00
John O'Keefe 929a862e74 feat(reader): touch selection handles, copy fallback, highlight tap editing
- Custom drag handles (start/end) for post-lift selection adjustment:
  the native handles are disabled with the rest of the native touch
  selection controller; these are positioned at the selection's
  boundary carets and driven through the same clamped caret mapping
- Copy button falls back to execCommand via a transient textarea on
  plain HTTP (navigator.clipboard is unavailable on LAN addresses);
  both paths clear the selection and dismiss the popover
- Only dismiss the selection popover on actual position changes in
  relocate (detail.section is a fresh object on every relocate, so
  reference comparison always saw a move); a tap's no-op relocate
  was closing the just-opened highlight edit popover
- Pin foliate-js f872a01 (synthetic click dispatch on quick taps)
  and 422e8e0 (don't snap taps that never panned)
2026-09-15 19:33:45 -04:00
John O'Keefe 0e55dafb1e fix(reader): mobile touch reading overhaul
- tap zones: map in-iframe taps into the visible page slice (the
  iframe is laid out at full section width; every tap previously
  computed as the left margin) and shrink the default zone size to 12%
- hyphens: manual on coarse pointers: Chrome's touch word-selection
  walks hyphen fragments, re-anchoring line-start drags and
  overshooting line ends into the next column
- selection popover: opens for settled touch selections (the gesture
  takeover swallows pointerup), positioned below the selection with
  on-screen clamping; creating a highlight clears the selection so
  the browser's own menu follows
- settle-time recovery: return the view to the selection's anchor
  page and clamp the selection to the visible page after the
  browser's selection auto-scroll wanders off the page grid
- overscroll-behavior: none on the reader page (pull-to-refresh
  during downward drags)
2026-09-15 08:50:17 -04:00
John O'Keefe cc8084a713 fix(reader): pin foliate-js 9cfe266
Non-touch gate on selection auto-paging (drag-selecting no longer
pages away mid-gesture) and small height-only viewport resizes no
longer re-wrap the book (mobile browser toolbar transitions).
2026-09-15 08:50:06 -04:00
John O'Keefe 3ab294bf81 feat(scanner): store EPUB reading direction at scan time
Release / build-and-push (push) Successful in 2m33s
Fixed-layout EPUBs lean on the reading_direction column (the web reader
forces book.dir = rtl from it when the file didn't set direction
itself), but the scanner never populated it for EPUBs - only ComicInfo
fed it. Meanwhile real Japanese EPUBs declare page-progression-direction
on the OPF spine, which foliate reads client-side but nothing stored.

Read the spine attribute in the structured OPF parser and map it into
ReadingDirection in parseOPFContent (EPUB2/3, case-insensitive, plus
'right-to-left'/'left-to-right' spellings); undeclared stays empty
rather than forcing ltr, preserving the editor's Auto default. Sidecar
OPFs are metadata-only documents without spines, so the Calibre path is
a no-op. The merge gap-fill copies an embedded-only direction into a
blank sidecar field, and hand-set values keep winning through the
existing OverrideReadingDirection protection.

Tests: declared rtl/RTL/ltr, undeclared and unknown values staying
empty, plus sidecar-wins vs embedded-fills merge cases. Existing manga
EPUBs declaring rtl (verified live in-library) pick the value up on
their next scan, feeding the API and reader config mobile clients
consume.
2026-09-13 13:13:25 -04:00
John O'Keefe e944f11415 feat(scanner): fill sparse sidecars from the PDF Info dictionary
Same gap-fill rule as the EPUB path, against the PDF's embedded Info
dictionary via a readPDFInfoDict helper reusing extractPDFMetadata's
field conventions (creator falls back to author, subject maps to
description, producer to publisher, keywords to tags, plus page count).
Sidecar values always win; unopenable PDFs skip silently.

TestMergeMetadataPDFGapFill uses an Info-bearing hand-built PDF fixture
(shared with the sidecar-cover test) and asserts the sidecar title is
kept while author/description/publisher/tags/page count fill in.
2026-09-13 13:06:48 -04:00
John O'Keefe 708598687e feat(scanner): fill sparse sidecars from embedded EPUB metadata
A sparse metadata.opf/metadata.json (title only, no description) left
books thin even when the file itself carried rich data: the embedded
extractors only ran when no sidecar existed at all. Now mergeMetadata
fills blanks from the book's own OPF - title, author, description,
publisher, language, ISBN, ASIN, series/number, publish date, tags,
contributors - while sidecar values always win and unparseable files
skip silently. Also covers .kepub, which the merge previously ignored
while the extractor already supported it.

Adds TestMergeMetadataEPUBGapFill asserting both directions: sidecar
title/author survive, embedded description/publisher/language fill in.
2026-09-13 13:05:37 -04:00
John O'Keefe aac7c72900 feat(admin): archived-items page with purge dates, restore, and purge
Admins need to see what the archive lifecycle is holding: a dedicated
/admin/archived page listing every hidden item (archived or still in
the missing grace window) with library, file path, status, and - when
retention is enabled - the exact date it will be permanently deleted
(archived_at + ARCHIVE_RETENTION_DAYS).

Per row: Restore (POST /api/media-items/:id/unarchive, clears the
archive state so it reappears; if the file is still gone the next scan
hides it again) and Delete (existing DELETE endpoint for single-row
purge with its reading history). Purge All Archived reuses the existing
bulk button. The library admin banner links to the page and keeps its
purge button; row actions use data attributes with delegated listeners
in admin.ts since this templ version has no JSFunctionCall helper.

Verified live: page 200 with purge dates shown, unarchive returned 204
and reset the row, per-item delete and bulk purge ({purged:1}) both
removed their rows with no leftovers.
2026-09-13 12:01:05 -04:00
John O'Keefe fe5ab9e5f8 feat(ui): hide missing books immediately and mark offline libraries
Two visibility fixes so the UI reflects the shelf's real state on the
next scan instead of only after the archive gate:

- Missing items disappear at once: the user-facing filters already hid
  archived rows; the same listings now also require missing_scan_count =
  0. A deleted or moved-then-not-yet-repointed file vanishes from the
  UI immediately, while purge timing stays gated on archived_at plus the
  retention window - grace protects data, not visibility. Restored
  automatically when the file returns.
- Steam Deck SD-card model for unmounted storage: resolveLibrary stats
  each library's folder roots and flags libraries with no live folder
  as Offline (LibraryData gains the field, computed at request time so
  mounts/unmounts react instantly). The bookshelf shows an empty shelf
  plus a 'storage is not connected' notice for an offline selected
  library, and both the shared LibrarySwitcher and the bookshelf's
  inline select label offline libraries with their true holding counts.
  Nothing is marked or purged while offline.
- TotalMediaCount skips offline libraries, so the 'All Books/Libraries'
  totals match what is actually visible.

Verified live: renaming uploads/Manga away produced the notice, an
empty shelf, and the offline dropdown label with a corrected total;
renaming it back restored all 38 cards with zero dirty rows.
2026-09-13 12:00:49 -04:00
John O'Keefe 249b1dfe93 feat(scanner): treat reappeared content as a move, not a duplicate
When the SHA-256 dedup found identical content already in the library,
the scan skipped the file as a duplicate - and after files moved
between folders the old row kept its stale path, cycled missing ->
archived, and the new path never took. The archive feature turned the
old destructive move behavior into a stuck move instead.

Now the dedup branch stats the old location: if it is gone, the book
was MOVED, so the row is repointed (file_path, file_size) with archive
state cleared and reading history intact. 'Skip as duplicate' only
applies when the old path still exists (a true copy). Verified live: a
moved EPUB kept its single row, followed the file, and never entered
the missing/archive cycle.
2026-09-13 12:00:19 -04:00
John O'Keefe 17216e9cc7 feat(db): archive lifecycle queries, immediate-hide filters, move support
Storage behind the scan lifecycle fixes:

- MoveMediaItemFilePath: repoints a row (path, size) and clears archive
  state when identical content reappears at a new location.
- ListHiddenMediaItems: archived OR missing rows for the admin
  archived-items page; ListMediaItemsByLibraryIncludingArchived (prior
  commit) already fed the scanner.
- All user-facing listings now hide missing items immediately, not just
  archived ones: ListMediaItems, ListMediaItemsByLibrary,
  ListMediaItemsSorted, SearchMediaItems, SearchMediaItemsUnified, the
  next_books CTE, library media counts, and the five search autocomplete
  value queries gained AND mi.missing_scan_count = 0 next to the
  archived_at filter. Purge timing is unchanged (archived_at +
  ARCHIVE_RETENTION_DAYS), so the grace period keeps protecting data
  while the UI reflects removals on the first scan.
- Detail lookups by id/path/sha stay unfiltered on purpose.
2026-09-13 11:59:41 -04:00
John O'Keefe 841db91a29 fix(scanner): extract embedded covers for sidecar-managed books
A full rescan wiped cover_image_path for every PDF/EPUB living in a
metadata.json (or metadata.opf-less) folder with no cover.jpg next to
the book: the sidecar branches returned early after findSidecarCover
missed, and mergeMetadata has no PDF/EPUB cover logic of its own. Four
books lost their thumbnails while their {file}.cover.jpg files still sat
on disk - most visibly the Audiobookshelf-managed No Starch titles.

Both sidecar branches now fall through to an embedded-cover fallback
(PDF via extractPDFCover, EPUB/KEPUB via extractEPUBCover) whenever no
sidecar cover file exists. Comics are untouched: mergeMetadata already
extracts their covers from the archive.

Adds TestSidecarCoverFallback with a hand-built one-page PDF carrying a
JPEG XObject plus a metadata.json sidecar, asserting the sidecar title
wins while the cover still comes from the file. Verified live: rescans
restored all four dereferenced covers with no leftover rows.
2026-09-13 11:59:24 -04:00
John O'Keefe 1eb9c92d6a fix(scanner): nil metadata_overrides crashed Reset to Scanned with SQLSTATE 23502
Release / build-and-push (push) Successful in 2m30s
Reset to Scanned cleared the overrides row (successfully) but then set
the in-memory copy to nil before handing it to updateMediaItem. pgx
encodes a nil []string parameter as SQL NULL, so the follow-up UPDATE
wrote metadata_overrides = NULL into the column's NOT NULL constraint
and the whole rescan failed with:

  failed to update media item: ERROR: null value in column
  "metadata_overrides" of relation "media_items" violates not-null
  constraint (SQLSTATE 23502)

Two changes:

- RescanMediaItem's reset path assigns []string{} instead of nil, with a
  comment explaining the pgx nil-to-NULL encoding trap.
- updateMediaItem routes the override set through utils.MergeOverrides,
  whose contract guarantees a non-nil slice, so no caller can write
  NULL into that column again (verified against pgx v5.9.2 source: a
  scanned '{}' round-trips as non-nil in both directions; the nil could
  only come from our own assignment).

The plain Rescan path never hit this - only Reset did. Worse, the reset
is the remedy when a book's cover_image_path override pins an empty
cover, so the crash also blocked the way out of that state. After this
fix, a plain rescan on an already-reset book repopulates scanned
metadata and extracts the cover.
2026-09-13 00:12:56 -04:00
John O'Keefe 1fa8ee3a59 feat(scanner): Calibre-aligned OPF metadata extraction
Release / build-and-push (push) Successful in 3m5s
Adopt Calibre's reading conventions for the Dublin Core metadata that
parseOPFContent now pulls from the structured OPF parse:

- Titles: EPUB3 title-type selection (prefer 'main', join a distinct
  subtitle with ': ' exactly as Calibre stores it). There is no separate
  subtitle column by design - Calibre-sidecar books arrive pre-joined,
  so a column would stay empty for most libraries and force every client
  to reimplement concatenation.
- Genre: first dc:subject, mirroring the existing processGenresAndTags
  behavior of the Calibre-sidecar path; the embedded path never
  populated Genre before. Subjects stay one-element-one-tag - Library
  of Congress headings legitimately contain commas ("Holmes, Sherlock
  (Fictitious character) -- Fiction") and must not be split.
- Identifiers: urn:isbn:/urn:asin: prefixed values parse in addition to
  opf:scheme attributes, and the scheme-less fallback now requires an
  ISBN-shaped value (10/13 digits, optional separators/trailing X) so
  URIs like the Gutenberg identifiers cannot masquerade as ISBNs -
  observed live on 'A Study in Scarlet'.
- Series: EPUB3 belongs-to-collection with collection-type=series and
  group-position refines, ahead of the classic calibre:series metas.
- Audiobookshelf metadata.json sidecars join their subtitle field into
  the title the same way.

Tests cover title-type main+subtitle joining, belongs-to-collection
series with fractional group-position, urn:isbn extraction, genre/tag
parity, and comma preservation inside subject headings.
2026-09-12 23:45:18 -04:00
John O'Keefe 72d167005f fix(scanner): resolve EPUB covers via structured OPF parsing, Calibre chain
The cover lookup scraped the OPF with attribute-order-sensitive regexes.
Real books serialize attributes in any order - Grand Central's '3 Days to
Live' puts href before id on manifest items and content before name on
the cover meta - so all three regex paths missed and the book fell
through to filename guessing, extracting no cover at all. Attribute
order is meaningless in XML; the regexes were never safe.

Replace them with a structured parse (encoding/xml, namespace and
attribute-order agnostic; see the new media_scanner_opf.go) and follow
Calibre's read_raster_cover resolution order:

1. manifest item with properties=cover-image (non-(X)HTML media only)
2. <meta name=cover> resolved through the manifest, same media guard
3. first spine item that is itself a raster image (store manga)
4. NEW cover-page fallback: books declaring no raster cover at all -
   the classic EPUB2/Adobe cover.xhtml wrapper - are mined for
   <img src> / SVG <image xlink:href> references (Calibre renders the
   page with Qt; extracting the referenced image covers the practical
   cases without a rendering engine)
5. existing zip filename guessing stays as the last resort, and the old
   regex chain survives as findCoverInOPFLegacy for OPFs too malformed
   for a real XML parse.

Hrefs are now URL-decoded and posix-normalized against the OPF's own
path (path.Join semantics), so '../art/cover.jpg' from a nested cover
page and %20-encoded names resolve correctly.

Tests: attribute-order chaos modeled on the failing Patterson book,
SVG-wrapped cover pages via guide references, image-first spines, and
path resolution edge cases. Verified live against the real
'3 Days to Live' EPUB, which previously produced no cover.
2026-09-12 23:45:01 -04:00
John O'Keefe bf2c2825ac chore(deploy): document ARCHIVE_RETENTION_DAYS in compose and .env.example
Release / build-and-push (push) Successful in 2m33s
Days an archived item is kept with its reading history before library
scans purge it for good (default 90). Set 0 to keep archived items until
purged manually from the library admin page.
2026-09-12 17:50:31 -04:00
John O'Keefe cd119a74da feat(admin): purge-archived endpoint with an archived-items banner
Bulk escape hatch for archived rows (files missing from disk for 2+
scans) so a mass external deletion never has to wait out the retention
window or be clicked away row by row:

- POST /api/media-items/purge-archived (admin only) hard-deletes all
  archived items and returns the purged count; reading history goes with
  the rows, so the call is confirmed in the UI first.
- The library admin page shows an 'Archived items: N' card (only when
  non-zero) with a Purge Archived Now button that calls the endpoint,
  toasts the result, and reloads.
- Frontend admin JS exposes window.purgeArchivedItems following the
  existing localStorage-bearer-token pattern.
2026-09-12 17:50:24 -04:00
John O'Keefe 14445a7c3f feat(scanner): archive-at-two-scans lifecycle for files missing from disk
Replace the silent hard-delete orphan cleanup (which logged only through
ScannerLogger file logs and whose failure paths left rows undetected)
with an archive lifecycle that preserves reading history:

- A file missing in one scan is marked (missing_scan_count = 1); missing
  in a second consecutive scan archives it (archived_at, hidden from
  browsing, progress/notes/highlights survive). Every branch logs to
  stdout with an [ARCHIVE] prefix so skips are always visible.
- When a file reappears - same path, or identical content at a new path
  via the SHA-256 dedup match - the archived state clears automatically
  and the item returns with its history intact.
- Archived rows older than ARCHIVE_RETENTION_DAYS are hard-purged at
  scan time (cascading deletes); 0 disables auto-purge for manual-only
  management. Retention is read from the environment in NewMediaScanner.
2026-09-12 17:50:15 -04:00
John O'Keefe e6aceae0da feat(db): archive lifecycle schema and queries for missing media
Add the storage behind the archive-instead-of-delete lifecycle:

- media_items.missing_scan_count (INT, default 0) and archived_at
  (TIMESTAMPTZ, partial index), both as idempotent ADD COLUMN IF NOT
  EXISTS backfills for existing installs.
- MarkMediaItemMissing / ArchiveMediaItem / ClearMediaItemArchive plus
  PurgeExpiredArchivedMediaItems (retention cutoff) and
  PurgeAllArchivedMediaItems (manual bulk), with CountArchivedMediaItems
  for the admin UI.
- Archived items are hidden from every user-facing listing:
  ListMediaItems, ListMediaItemsByLibrary, ListMediaItemsSorted,
  SearchMediaItems, SearchMediaItemsUnified, the next_books CTE, the
  library media counts, and the search autocomplete value lists. Detail
  lookups by id/path/sha are intentionally unfiltered, and a dedicated
  ListMediaItemsByLibraryIncludingArchived feeds the scanner so the
  lifecycle pass can see and restore archived rows.
2026-09-12 17:50:01 -04:00
John O'Keefe 44b98f3fc3 feat(scanner): read Audiobookshelf metadata.json sidecars
Libraries managed by Audiobookshelf keep a metadata.json next to each
book (title, authors, series+sequence, genres/tags, publisher,
description, isbn/asin, language, published year/date) - and no
metadata.opf. The scanner silently ignored those files: deleting them
changed nothing, and their data never reached the database.

Parse them as a first-class sidecar in extractMetadata, priority
metadata.opf -> metadata.json -> embedded media. Only fields with a
matching media_items column are mapped; narrators, subtitle, explicit,
abridged, and chapters are deliberately skipped.

Cover handling is unchanged: the existing findSidecarCover priority
(cover.jpg / folder.jpg / {basename}.jpg) applies to the sidecar branch
exactly as it does for Calibre.
2026-09-12 17:49:46 -04:00
John O'Keefe 61681aac23 feat(scanner): extract EPUB metadata from the embedded OPF directly
go-epub's ReadBook parses every spine chapter and fails the entire call
if any single chapter (or the TOC) is malformed, discarding already-parsed
OPF metadata. For books like Pragmatic's 'A Common-Sense Guide' the OPF
holds good title/author/publisher/ISBN metadata that rescans then wrote
as blanks - success toast, no (visible) change.

Refactor to parse the EPUB's own OPF document with the same Dublin Core
machinery used for Calibre sidecars: parseCalibreMetadataOPF is now a thin
file wrapper around a reusable parseOPFContent([]byte), and the OPF lookup
previously inline in extractEPUBCover is shared via findOPFPathInZip. Since
metadata never touches chapter bodies, chapter damage cannot blank it.

Also picked up along the way: dc:language mapping and scheme-less
dc:identifier values that normalize to a valid ISBN (EPUB3 style).

Tests cover a Pragmatic-style EPUB (dc namespace on <metadata>, no
identifier scheme, deliberately malformed chapter) that must still yield
full metadata, plus series/date/subject OPF parsing.
2026-09-12 17:49:30 -04:00
John O'Keefe 7a9a66fcc5 feat(ui): show file path and Reset to Scanned in the metadata editor
Release / build-and-push (push) Successful in 2m24s
Complete the metadata editor modal to match the backend override work:

- File Path: read-only, monospace field showing the resolved on-disk
  location (MediaDetail.FileLocation), next to Format and File Size, so
  admins can see exactly which file backs the record without leaving the
  editor. Renders empty for non-admins, who never receive the path.
- Reset to Scanned: new button beside Rescan. It confirms, then calls
  POST /api/media-items/:id/rescan?reset_overrides=true to drop all
  per-field user overrides and re-extract scanned defaults. Rescan alone
  keeps customizations; Reset discards them.
2026-09-12 14:22:38 -04:00
John O'Keefe 2df3ecbf36 fix(ui): remove the broken Generate Cover button from the metadata editor
Generate Cover has never worked: it fetched the book file using a URL
scraped from the cover preview <img> tag (so it downloaded either the
existing cover JPEG or, when no cover existed, the detail page HTML),
then handed it to foliate-js, which rejects both. Its fixed-layout path
also called view.renderer.renderPage(), a method that does not exist in
the pinned foliate fork. Every click ended in the same generic 'Cover
generation failed' toast.

The working alternative already exists server-side: the scanner's
PDF/EPUB cover extraction plus the per-book Rescan button, now that the
rasterizer renders the CropBox. Users who want a specific image can
upload one.

Delete web/src/cover-generator.ts, the modal buttons, and the dead
generateCover()/coverGenerating/fileUrl plumbing in book-detail.ts.
2026-09-12 14:22:29 -04:00
John O'Keefe 9da193e718 feat(ui): admin-only file location and metadata editing on book detail
The book detail page exposed server internals and unusable controls to
everyday users: it now computes and renders the book's absolute on-disk
location, and gates all of it behind the admin role.

- The page handler resolves library folder + relative path (verified
  with os.Stat; falls back to the relative path when the file is not
  found on disk) into the new MediaDetail.FileLocation field - only for
  admins, so the absolute path never leaves the server for regular
  users. This also makes it possible to locate sparse entries whose
  metadata rows are largely blank.
- The Metadata grid gains a full-width, monospace, click-selectable
  Location row (admins only).
- The Edit button and the MetadataEditorModal markup render only for
  admins. The modal drives admin-only endpoints (metadata PUT, rescan,
  reset), so non-admins previously saw a button and a form that could
  only ever fail with 403s.
2026-09-12 14:21:42 -04:00
John O'Keefe 7f64b92b9d feat(metadata): per-field user overrides that survive library rescans
Previously both the metadata editor (PUT /api/media-items/:id) and the
scanner (library scans, force rescans, per-book rescans) wrote through
the same unconditional UPDATE media_items query, so any rescan wiped
user-written descriptions, tags, and uploaded covers. Custom and scanned
values were indistinguishable, and custom cover uploads even wrote to
the same {file}.cover.jpg sidecar path the scanner generates, so each
side silently clobbered the other.

Introduce metadata_overrides, a TEXT[] column on media_items listing the
column names the user has customized:

- Saving metadata records overrides per field by diffing the submitted
  values against the stored row (an untouched save records nothing);
  overrides accumulate until an explicit reset. Cover upload/removal
  always marks cover_image_path. Bulk updates mark each applied field.
- The scanner merges: updateMediaItem() now takes the existing row and
  restores every overridden column (including derived *_search arrays)
  before writing, and preserves the override set itself.
- Uploaded covers move to a dedicated {file}.custom_cover.{jpg|png|webp}
  sidecar so the scanner can never overwrite a user cover on disk.
- RescanMediaItem gains resetOverrides: POST /api/media-items/:id/rescan?
  reset_overrides=true clears the set first, returning the item to pure
  scanned defaults.

Shared detection/restore helpers live in internal/utils
(metadata_overrides.go) with unit tests covering detection, accumulation,
unset-form equality, and restore-with-derived-fields. Schema change is
an idempotent ADD COLUMN IF NOT EXISTS applied on startup. Also includes
incidental gofmt of NewMediaScanner literals in media_scanner.go.
2026-09-12 14:21:35 -04:00
John O'Keefe b9645752f5 fix(db): skip SQL comment lines when parsing schema table names
parseTableNames() regex-scanned every line of schema.sql, comments
included, with 'CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?(\w+)'. A doc
comment containing that phrase in prose - e.g. 'declared in CREATE TABLE
above' - registered a phantom table ('above'), and startup verification
then failed with 'missing tables: above', crash-looping the app
container on every restart.

Skip lines whose trimmed form starts with '--' so comments can never
contribute table names, and add a regression test asserting every parsed
table maps back to a real CREATE TABLE statement.
2026-09-12 14:21:28 -04:00
John O'Keefe 9c8337d0a8 fix(scanner): render the PDF CropBox, not the MediaBox, in the cover fallback
pdftoppm defaults to rasterizing the MediaBox, while PDF viewers (pdf.js
in the reader, and every other viewer) display the CropBox. For PDFs
whose page 1 is the full print cover wrap (back cover + spine + front
cover in one landscape page) with a CropBox covering only the front
cover - e.g. No Starch's XeTeX-built 'Algorithmic Thinking' - the
fallback stored the entire spread as a squashed landscape cover, while
the reader correctly showed just the front cover.

Pass -cropbox so the rendered cover always matches what the reader
displays. Poppler falls back to the MediaBox when a PDF defines no
CropBox, so PDFs with identical boxes (the common case) render exactly
as before.
2026-09-12 14:21:24 -04:00
john-okeefe da1f689263 feat(ui): add Rescan button to Edit Metadata dialog
Release / build-and-push (push) Successful in 2m50s
Adds a Rescan button to the MetadataEditorModal footer that POSTs to the
new per-book rescan endpoint, with rescanning state (disabled + spinner,
matching the existing Mark Read pattern), success/error toasts, and a
page reload to pick up the refreshed cover and metadata.
2026-09-11 23:08:20 -04:00
john-okeefe 298330a3a8 feat(api): add POST /api/media-items/:id/rescan endpoint
Admin-only endpoint that re-extracts metadata and cover art for a single
book from the file on disk via MediaScanner.RescanMediaItem and returns
the updated media item. Normal library scans skip unchanged files, so
this gives a targeted way to backfill covers for previously imported
books. Includes a Bruno request alongside the existing media-items
collection.
2026-09-11 23:08:20 -04:00
john-okeefe 0614795ca2 feat(scanner): render PDF first page as cover fallback via pdftoppm
extractPDFCover previously only saved embedded raster images from page 1,
so vector/text-first-page PDFs (e.g. InDesign exports like Data Structures
the Fun Way) ended up with no cover and a dashboard placeholder. When no
embedded image is found it now falls back to rendering page 1 with
pdftoppm (poppler-utils), saving the same {pdf}.cover.jpg sidecar.

Also adds MediaScanner.RescanMediaItem, which re-extracts metadata for a
single media item (resolving its on-disk path from library folders) so
previously imported books can backfill covers without a full force rescan.

Dockerfile installs poppler-utils in the final and test-runner stages.
2026-09-11 23:08:20 -04:00
john-okeefe 6aa958c78f fix(reader): save progress on real position change, not on detected intent
Release / build-and-push (push) Successful in 2m27s
The userMoved gate from ce3ae31 broke progress saving entirely for
EPUBs. The flag was set only in the app's navigation wrappers
(goLeft/goRight, keys, slider, search/TOC/bookmark/back-stack jumps),
but foliate-js's paginator handles the most common reading gestures
itself — touch-swipe paging, scrolled-mode reading, in-content links,
selection auto-advance — dispatching relocate directly without ever
calling those wrappers. Every one of those relocations hit the
"if (!this.userMoved) return" guard, so the position never saved at
all on EPUB; only tap-zone-paged formats (comics/fixed layout) kept
saving, which matched the intermittent reports.

Detecting intent was the wrong tool: the set of foliate-internal
navigation paths is open-ended and lives in a forked dependency.
Compare the position itself instead:

- The relocate handler records the latest CFI (lastCfi), and the
  baseline (lastSyncedCfi/lastSyncedFraction) is captured right after
  view.init() resolves — i.e. the restored position, or the start of
  the book on a fresh open.
- debouncedSaveProgress saves only when the position actually moved:
  CFI comparison for reflowable books, fraction comparison (1e-4
  epsilon) for CFI-less fixed layout and PDF.
- The baseline updates after each successful save, and a
  bfcache-resurrected page re-baselines to its frozen position, so the
  anti-clobber property survives: a displayed/restored position can
  still never overwrite a newer device push.

The twelve userMoved assignments in the wrapper methods are gone —
change detection covers deliberate jumps and internal gestures alike.
Backend untouched; SaveProgress was never the problem.
2026-09-11 21:50:03 -04:00
john-okeefe 3514b4dc1c fix(reader): close the highlight popover on outside clicks in the book
Release / build-and-push (push) Successful in 2m24s
Clicks on reader chrome already dismissed the selection popover via the
host-document outside-click handler, but clicks inside the book happen
in content iframes whose events never bubble to the host document — the
host handler never sees them. The only iframe-side dismissal ran through
the selection tracker's collapsed check, which hides the popover solely
in create mode: once the popover was open in EDIT mode (clicked a
highlight, writing a note), clicking anywhere in the book did nothing
and Esc was the only way out.

Each content iframe now gets a pointerdown listener that dismisses the
popover in any mode. Clicking a painted highlight still opens the edit
popover: this hides first, then foliate's show-annotation re-opens it.
2026-09-10 08:27:16 -04:00
john-okeefe ae87c0cd6a fix(sync): let content-equal echoes refresh drifted locators
The LWW skip path compared only annotation content (text, color, note,
percentages) — locator columns were not part of 'changed'. A device echo
with identical content therefore resolved to skip, and the freshly
re-derived canonical CFIs were discarded in the same request that
computed them: a highlight whose stored start anchor had been corrupted
by the old percentage-exact bug could never heal, because every
subsequent echo carried the same text and was skipped before the
locator columns were written. Observed live: a push converted the
cross-block highlight's anchors correctly (structural, heading to
paragraph) yet the row kept its garbage start CFI and the highlight
stayed unpaintable on the web.

Echo saves now treat a non-empty incoming locator that differs from the
stored one as a change (locatorRefresher): empty locators still coalesce
(no drift), and once healed the echo produces identical CFIs, so the
steady state remains skip — no write churn. Highlights check
epubcfi_start/end; bookmarks check cfi_position/position.

applyBookmarkLWW also gains the empty-locator coalescing the highlight
path already had: web bookmark edits carry no device locators, and a
title/note edit must not wipe the stored device-native position.
2026-09-10 08:10:42 -04:00
john-okeefe b6f507b9e5 fix(sync): understand cross-block text; never store a guessed locator
Tonight's failures all traced to one blind spot: the converter could
only reason about text within a single block. A position at a chapter
heading sends walk-up context (heading + the paragraphs below, joined by
the plugin's block capture); a selection can span several paragraphs.
Neither shape could be verified (containment compared one block against
a multi-block quote, so the CORRECT structural landing at the heading
was rejected) nor matched by text search (it never crossed block
boundaries). The ladder then fell to the percentage rung — which labeled
its char-count guess Precision "exact" — and that confidently-wrong CFI
was stored: reading positions reopened paragraphs away from the true
spot, and a highlight echo overwrote the row's good web CFIs with a
garbage start anchor that made the highlight unpaintable ("disappeared").

Four changes, all in the forward converter and its consumers:

- Quote verification: after the structural walk lands, read the
  whitespace-normalized document text forward from the landing point
  (crossing block boundaries; inline spans join directly so drop-cap
  splits still read as one word). A usable context must be a prefix of
  that stream — which is exactly what device captures are: the text from
  the position onward, or the selection between two anchors. The old
  single-block containment checks remain as secondary acceptance.
- Cross-block text search: the search rung matches against the whole
  document flattened in reading order, with every rune mapped back to
  its source node and offset. A context spanning blocks now matches, and
  the matched extent yields a true range end (EndEPUBCFI) that
  highlights use as their end anchor, threaded through the facade as
  CanonicalLocator.EndCFI.
- Honest labels: the percentage rung returns Precision "percentage" —
  a char-count estimate must never masquerade as an exact anchor.
- Confident-only storage: progress adopts a converted locator solely at
  structural/exact precision (section hrefs keep their legacy handling;
  anything lower stores percentage only), and highlight conversion
  returns CFIs only at structural/exact precision — a low-confidence
  echo yields empty, which applyLWW coalescing turns into preservation
  of the row's existing web CFIs instead of clobbering them.

Tests: walk-up context at a heading verifies structurally and lands in
the heading; a block-spanning context is found by search with a range
end landing in the following paragraph; the percentage rung is honestly
labeled; all drop-cap guards stay green.
2026-09-09 20:15:12 -04:00
john-okeefe ce3ae31ced fix(reader): read position from the API at open; never write a restored position
The reader page embedded a snapshot of reading state (position,
bookmarks) server-side at render time. Browsers may reuse that HTML
(heuristic caching, bfcache), so opening a book could restore a stale
position — and worse, the restore's relocate auto-saved it back,
overwriting a newer device push minutes later. A KOReader sync followed
by opening the web reader would silently revert the row to the old web
position; the row's source and the rendered page disagreed.

The web reader is intrinsically tied to the server, so it has no business
preserving reading state client-side:

- The rendered page now carries only immutable book metadata. The reader
  fetches progress fresh (cache: no-store) from the existing progress
  API at open and restores with the same priority as before (page for
  fixed-layout, CFI, percentage, fresh start); a failed fetch opens at
  the start and writes nothing. Initial bookmarks likewise come from
  their endpoint instead of the embed; annotations already did.
- Progress saves are gated on deliberate navigation only (page turns,
  keys, slider, search/TOC/bookmark/back-stack jumps, tap zones — each
  marks the session as user-moved). Restores and section-load
  relocations never write, so displaying a position can no longer
  clobber a newer one. A bfcache-resurrected page resets the flag and
  cannot write its frozen position either. This replaces the old
  five-second post-init suppression, which a stale page bypassed.
- The server-rendered initial progress badges render a neutral
  placeholder until the first relocate fills them (sub-second).

No API, schema, or sync-engine changes. Normal reading saves exactly as
before — the first save now simply waits for the first real page turn.
2026-09-09 14:52:30 -04:00
john-okeefe 75c1d9bb95 fix(highlights): web edits update the targeted row instead of minting duplicates
PUT /highlights/:id parsed the row id from the URL and then dropped it:
the sync-aware path routed through SaveHighlight's content-derived dedup
key, on the assumption that the same text + CFI always resolves to the
same key. That assumption breaks in practice — the stored epubcfi_start
drifts from foliate's range shape to the converter's point shape after a
device echo rewrites the row (bucketPosition cuts at the last colon, so
'…/6,/1:367,…' and '…/6/1:367' bucket differently), and the user can
edit the selection text. The recomputed key then misses the row being
edited and createHighlight mints a second one: the edited row (with
note, no device pos0) beside the original — served to KOReader as two
highlights, one noted and one not. Editing a selection's text would hit
the same trap.

SaveHighlightRequest gains an optional HighlightID. When set, the save
resolves the row by id (ownership-checked), LWWs against it under its
stored dedup key, and never re-derives identity from content. The PUT
handler passes the already-parsed id. Device pushes, Kobo, and the sync
queue send no id and keep the identity-based flow untouched.

applyLWW also stops wiping stored locators on web edits: the web reader
sends empty start/end positions (it never had a CRE xpointer), so a
note/color edit now keeps the device-native positions and CFIs instead
of blanking them — round-trip serve-back for device-created highlights
survives web-side edits.
2026-09-09 14:12:18 -04:00
john-okeefe 905218dd4b fix(reader): stop tap zones from paging during long-press text selection
Selecting text near the left edge on a touch browser could page back
instead: a long-press released just inside the 500ms tap window (Android
selection engages at ~400-500ms, right at the guard boundary) or landing
on a margin resolved as a tap, armed the 280ms debounce, and nothing
ever cancelled it. Four hardenings in the tap-zone pipeline:

- contextmenu (Android's long-press-engaged signal) suppresses the
  matching pointerup from counting as a tap, closing the duration race
- pointercancel (the browser taking over the gesture) now resets the
  tracked pointer so stale state can never match a later touch
- selectionchange on the host document and every content iframe cancels
  an armed tap action: a selection appearing right after finger-lift
  means the 'tap' was a long-press selection engaging
- the host-viewport pointerup honors the tracked any-selection flag,
  closing the blind spot where selections in iframes or the host's own
  fixed-layout text layer were invisible to the host surface (the
  per-surface check only ran for iframe docs)

Purely touch-path (coarse pointer) changes; desktop behavior untouched.
2026-09-09 13:00:39 -04:00
john-okeefe 94dad5e089 fix(reader): stop page-turn and zoom shortcuts while typing in form fields
handleKeydown computed a 'typing' guard for the event target but only
applied it to the drawer-shortcut block (t/s/b/?//). The vi-style page
turns (h/l), arrows, and zoom keys (+/−/0) fired regardless, so typing
into the note textarea hijacked the keys: 'Wh' turned back a page on the
h, arrows moved pages instead of the caret, and digits/minus zoomed.

Return early for INPUT/SELECT/TEXTAREA/contentEditable targets, keeping
Escape live so popovers and drawers stay dismissable from the keyboard
mid-note. Covers the selection-popover note field, the notes drawer
textarea, bookmark rename, and the search box. The now-unreachable
'!typing' condition on the shortcut block is dropped.
2026-09-09 12:59:37 -04:00
john-okeefe 2366faccce fix(reader): make device-synced highlights editable on the web
Device-synced highlights paint with a synthesized range CFI (renderCfi,
built by toRangeCfi from the stored point CFIs and selection text) while
the stored locator stays a point CFI. Three follow-ons from that split:

- show-annotation (click-to-edit) matched the clicked value against the
  stored point cfi only, so clicking a device-created highlight never
  opened the edit popover — it listed in the drawer but was uneditable.
  Match either the stored cfi or the renderCfi the overlay was added by.

- deleteHighlightById removed the overlay with the stored point cfi,
  which never matched the painted value; the highlight box lingered
  until reload. Delete with the value it was added by.

- saveHighlightChanges re-added the overlay without removing the old
  value; an edit that changes the synthesized range (note/text edits
  change the UTF-16 length it derives from) would ghost the old paint
  beside the new one. Remove the previous overlay value first when the
  edit changed it.

Web-created highlights are unaffected: their stored CFI is already a
native range, so renderCfi === cfi for them.
2026-09-09 12:38:24 -04:00
john-okeefe e40530824e feat(koreader): one conversion route for every feature; bookmarks get web CFIs
Release / build-and-push (push) Successful in 2m14s
Progress, highlights, notes, and bookmarks entered position conversion
through three different doors: progress converted inline with an
uncached converter, annotations through the facade, bookmarks not at
all (the raw xpointer was stored verbatim, cfi_position stayed empty,
and the web drawer's goToBookmark silently no-ops on cfi-less entries).

Unify on the facade (ConvertToCanonical/ConvertFromCanonical):

- annotationEpub context resolved once per push: media item + EPUB path
  shared by every annotation instead of re-fetched per entry
- progress forward: the inline block becomes one facade call;
  non-reflowable formats pass through unchanged, and the cached
  converter stops re-parsing the book on every sync
- progress reverse: convertCFIToXPointer delegates to reverseConvertCFI,
  keeping the stored percentage in play for the fallback ladder
- bookmarks (bulk progress and /sync-bookmarks): pos0 resolves
  structural-only — bookmark text is a display label, never book text,
  so no context is supplied; webUsableCFI stores the result only for
  structural/exact epubcfi landings, discarding href/percentage results
  rather than storing dead drawer links. Also records percentage_location
  and origin_source on the legacy endpoint.
- percentages thread through: highlights/notes/bookmarks pass the device
  percentage or the derived section percentage instead of a hardcoded 0,
  so the last-resort fallback lands near the true position instead of
  the document start
- extendCFIByLength end-derivation now also fires on structural starts
  (it had silently stopped matching when the structural rung began
  landing starts with precision 'structural' rather than 'exact')

Tests: the drop-cap xpointer through the facade with empty context (the
bookmark scenario) must land structurally, not doc-start; webUsableCFI
table covers the store/discard gate.
2026-09-09 09:04:23 -04:00
john-okeefe 5a6c361c11 perf(sync): share the bounded converter cache for section percentages
Per-annotation percentage derivation (deriveAnnotationPercentage) built a
fresh CFIConverter for every highlight/note/bookmark, re-reading and
re-parsing the whole EPUB each time. Export SectionPercentageCached so
handlers reach the same bounded cache ConvertToCanonical already uses
(8 books, insertion-order eviction): one parse per book per push instead
of one per annotation.
2026-09-09 09:04:10 -04:00
john-okeefe 7b1c809ae3 feat(bookmarks): location identity, origin provenance, KOReader-style labels
- Drop UNIQUE(media_item_id,user_id,title): titles are display labels
  shared verbatim across clients; same-title bookmarks on different pages
  now coexist instead of 500ing (deleting over a tombstone no longer
  blocks future creates with that title)
- Add origin_source column recording the creating client (android/web/
  koreader), set once at insert, exposed in API responses
- Web reader auto-title mirrors KOReader's 'in <chapter>' convention,
  falling back to 'Bookmark'; adds bookmark rename in the drawer
2026-09-09 08:17:43 -04:00
john-okeefe f7c4dfe2e8 fix(sync): resolve CRE positions structurally with text as verification
Release / build-and-push (push) Successful in 2m31s
Drop-cap markup like <p><span>C</span>onvergence of Heaven and Earth</p>
made getTextFromXPointer return just 'C'. The text search then matched
the first 'C' in the chapter and stored doc-start (/4/2/1:0) with
'precision: exact', so the web reader reopened at the chapter start
while the percentage looked mid-chapter.

- Add convertByStructuralPath: walk the parsed CRE ElementPath against
  the raw XHTML (same-tag 1-based indexing, mirroring buildCREXPointer),
  map CharOffset into the target element's text, and build the CFI.
  Usable device text verifies the landing; disagreement falls through
  instead of storing a confident-but-wrong CFI.
- Add usableContextText guard (>=8 runes, >=2 words): single chars can
  never claim an exact text-search hit in either direction
  (ConvertCREToStandard and reverseByTextSearch).
- Add drop-cap regression fixtures plus usable-context unit tests.
- Verified against the real book: DocFragment[26]/p[12]/span header now
  converts to epubcfi(/6/52!/4/28/2/1:0) structural both with 'C' and the
  full header, and round-trips back to DocFragment[26].
2026-09-08 22:32:51 -04:00
john-okeefe b1eda696f1 Revert "fix(bookmarks): upsert on title conflict so position upgrades don't 500"
This reverts commit 27b3dcb69f.
2026-08-30 21:01:59 -04:00
john-okeefe 27b3dcb69f fix(bookmarks): upsert on title conflict so position upgrades don't 500
Bookmark dedup is keyed on hash(title + position bucket), but the table
also enforces UNIQUE(media_item_id, user_id, title). When a client re-
saves the same bookmark title with a changed position form - e.g. the
Android app upgrading a percentage-only row to an EPUB CFI, or a web and
app bookmark landing on the same 'Bookmark at 44%' title - the dedup-key
lookup misses and the INSERT violates the title constraint, returning
HTTP 500 and failing the sync.

A title collision on the same (user, item) is by definition the same
bookmark slot, so take the LWW semantics all the way: ON CONFLICT DO
UPDATE replaces position/cfi_position/page/chapter/percentage, refreshes
dedup_key and timestamps, merges device_sync_data, and - matching
UpdateMediaBookmarkForSync - clears deleted/deleted_at so a re-create
resurrects a tombstoned title slot instead of leaving an invisible row
holding it.

Device sync flows are unaffected: KOReader/Kobo pushes that carry their
own dedup-key echoes never reach the INSERT, and same-key saves still go
through applyBookmarkLWW with its tombstone freshness checks.
2026-08-30 20:57:15 -04:00
john-okeefe 5c5593644d fix(media): gate file serving by library visibility; proper download URL
ServeFile previously authenticated only ("any logged-in user") and never
checked that the user can actually see the library owning the file, so
knowing a library UUID + path was enough to fetch content from hidden
libraries. Library visibility is the permission model - the library is
what grants access to its media.

- ServeFile now resolves two URL forms through one flow:
  /uploads/library-{id}/{path}   (covers, reader files)
  /api/media-items/{id}/download (explicit book download, new)
  The item form looks up the media item, derives its library and file
  path, and adds a Content-Disposition attachment header.
- Both forms enforce GetUserVisibleLibraries for the authenticated
  user, mirroring the OPDS download handler (403 when not visible).
- Deleted the dead MediaHandler.DownloadBook handler (never routed).

Also widen media_highlights.start_position/end_position from
VARCHAR(100) to TEXT: the API handlers validate up to 1000 characters
(full Readium locators, KOReader CRE xpointers) but the column rejected
anything longer at the database layer. Metadata-only change applied
idempotently at startup; existing rows are untouched.

Verified against the running server: download 200 + attachment headers
+ epub bytes, unauthenticated 401, user hidden from the library 403 on
both URL forms, visible user 200, covers unchanged, and a 334-char
locator JSON now round-trips through the highlights API.
2026-08-30 11:44:36 -04:00
john-okeefe 9e516b96cc docs(android): require reader settings parity with the web reader
The web reader's font roster (Literata default, plus seven self-hosted
variable fonts), typography controls, chrome/reading theme split,
fx brightness/contrast/invert stack, tap zones, and highlight palette
are the reference design for the Android reader - only the mobile
presentation differs. Document the mapping to the synced reader_settings
model so the app reuses it instead of inventing a parallel one.
2026-08-29 15:25:52 -04:00
john-okeefe 44735554f7 docs(api): document the real book download route
The documented GET /api/media-items/:uuid/download is not registered
anywhere - MediaHandler.DownloadBook exists but no route mounts it.
Book files (and covers) are actually served by the JWT-authenticated
GET /uploads/library-{id}/{path} route that the web reader uses.

Rewrite the download doc around the real file route (URL construction
from the item's library_id and relative file_path, MIME/Cache headers,
error codes), note the dead handler so nobody relies on the phantom
endpoint, and correct the API reference index. Mention the OPDS device
route as the conversion-capable alternative.
2026-08-29 10:25:59 -04:00
john-okeefe 311049379d docs(android): add QR pairing sign-in to roadmap, update auth design
Document the authentication decision reached for the Android client:
username/password login is primary (the app needs the user-JWT API
surface that device tokens cannot reach), with the app self-approving
its own device registration post-login so it still shows up on the
Devices page with sync attribution.

Add the Netflix-style QR pairing flow to the post-v1 roadmap with its
constraints: the QR grants a full login with zero typing; a typed-code
fallback covers phones with broken cameras; KOReader keeps its existing
flow (no typed codes there); and pairing must encode the configured
BASE_URL rather than a detected LAN IP so remote instances
(https://public.domain) work identically.
2026-08-28 22:45:29 -04:00
john-okeefe f65db5ab4f docs(api): align auth/devices/libraries/media-items docs with handlers
Verified against the Echo routes and handler structs, fixing drift that
would break API clients:

- login: response field is access_token, not token (AuthResponse struct)
- register status: status is only pending|approved; expiry is HTTP 410
  (not a status value), approved responses are single-use, and pending
  registrations do not survive server restarts
- visible libraries: endpoint is GET /api/libraries/visibility and
  returns a top-level array of full library rows, not a wrapped object
- media items list: response is {"data": [...]}, library_id is optional,
  limit defaults to 50 (max 1000), no total field; document the sort
  parameter, the two response shapes, and raw-vs-resolved file paths

refresh and device-registration docs verified accurate; no changes.
2026-08-28 22:15:42 -04:00
john-okeefe 7ddcdd0756 docs(readme): link Android app doc and adopt app favicon as title icon
Add the Android client design doc to the For Developers section, point
the Supported Devices table at it, and replace the emoji title icon
with the app's book-open favicon (Tokyo Night #7aa2f7) to match the
actual product branding.
2026-08-28 20:59:31 -04:00
john-okeefe b927ed9988 docs(android): add native Android client design doc
Document the planned native Android client (bookhoard-app): product
vision, tech stack and rationale (Kotlin + Compose + Readium over
hybrid/Flutter/KMP alternatives), module architecture, offline-first
sync flow over the existing REST/WebSocket API, reader and comics/manga
UX, iOS posture, distribution and licensing, and a five-milestone
roadmap. The client is a thin, offline-first consumer of the server's
existing device registration, universal progress, annotation, and
conflict-resolution APIs — no server changes required.
2026-08-28 20:59:31 -04:00
john-okeefe e2953c4a01 chore(license): relicense project from GPL-3.0 to AGPL-3.0
Replace the GPL-3.0 license text with the full GNU Affero General
Public License v3.0 text, strengthening copyleft coverage for the
network-service use case (users interacting with Bookhoard over the
network are entitled to the corresponding source).

- LICENSE: swap GPL-3.0 text for the canonical AGPL-3.0 text (gnu.org)
- README.md: update both license references (Project Status and
  License sections) from GPL-3.0 to AGPL-3.0
- docs/user/sync-guide.md: update the footer license reference

The bundled BSD 3-Clause license in internal/sevenzip/LICENSE is a
third-party dependency license and is intentionally left unchanged.
2026-08-28 20:12:20 -04:00
john-okeefe d429534b12 docs(api): deleted-annotation history + KOReader deletion propagation
Release / build-and-push (push) Successful in 2m24s
New media-items/deleted_annotations.md for the list/restore/purge
endpoints; endpoint index updated. The KOReader protocol page documents
deleted_highlights/deleted_bookmarks on the progress push and the
deletion-propagation contract: keys learned only from server pulls,
explicit arrays only (never absence), tombstone convergence via the
metadata fetch, no resurrection from stale replays, and the web history
as the restore path.
2026-08-22 13:16:54 -04:00
john-okeefe f70579b4fc feat(ui): deleted-annotation history on the book page
Replace the Notes & Highlights 'coming soon' stub with a real modal:
active counts plus a 'Recently deleted' section listing every tombstoned
highlight, note, and bookmark (type badge, deletion time in the user's
timezone, text preview), each with Restore and Delete-permanently
actions. Restore returns the annotation to every synced device; Delete
permanently is confirmed before purging. The list is server-rendered
from MediaDetail.DeletedAnnotations — no fetch on open.

Alpine handlers in book-detail.ts call the new restore/purge endpoints
and reload on success. style.css picks up the line-clamp utilities used
by the text previews.
2026-08-22 13:16:48 -04:00
john-okeefe 1f5c5a28bd feat(sync): propagate KOReader annotation deletions + history API
KOReader push (processBookAnnotations) accepts deleted_highlights and
deleted_bookmarks arrays of dedup keys and tombstones the matching rows,
after the upserts so a key present in both lists resolves to 'deleted'
(the newer intent). Deletions remain soft: rows stay restorable from the
history and echo to other devices as tombstones on their next pull. A
stale device replay of the annotation cannot resurrect the tombstone —
device pushes carry no modification timestamp, so the save loses to the
delete. Absence from these arrays is never a delete, keeping category
toggles safe.

New annotation-history endpoints (annotation_history.go, media.go):
  GET    /api/media-items/:id/annotations/deleted
  POST   /api/media-items/:id/annotations/:annotationId/restore
  DELETE /api/media-items/:id/annotations/:annotationId
All scoped to the authenticated user and the route's book; the DELETE is
the permanent purge (annotation_type required in query or body).

MediaDetail gains DeletedAnnotations, populated by the book page route
via the shared DeletedAnnotationsForBook builder, so the server-rendered
history ships with the page instead of requiring a client round-trip.

Binding tests cover the plugin's exact wire shape and the legacy
plugin case (arrays omitted -> empty).
2026-08-22 13:16:43 -04:00
john-okeefe d4c52e9a6a feat(sync): restore/purge service methods + bookmark tombstone by dedup key
RestoreAnnotationByID and PurgeAnnotationByID dispatch on annotation kind
(highlight/note/bookmark) to the new queries, broadcasting an annotation
update on restore so connected web sessions refresh. Both report whether
a row actually changed.

TombstoneBookmarkByDedupKey mirrors the existing TombstoneHighlight for
bookmarks: devices report deletions by dedup key (they have no row IDs),
and until now only highlights had a key-based tombstone path — device
bookmark deletions had nowhere to land.

ValidAnnotationKind centralizes the kind check the HTTP handlers share.
2026-08-22 13:16:36 -04:00
john-okeefe acbb6c7981 feat(db): queries for the deleted-annotation history
ListDeletedAnnotationsForBook unions tombstoned highlights, notes, and
bookmarks for a user+book regardless of the sync TTL cutoff (the history
must show everything still restorable, not just recent deletes), with
display text, secondary text, color, and both timestamps.

Restore queries clear deleted/deleted_at (lossless — the row was soft-
deleted, never removed) and are scoped to the owning user and media item
so a restore can never touch another user's annotation.

Purge queries hard-delete an already-tombstoned row: the user-driven
counterpart of the TTL maintenance sweep, for explicit 'delete
permanently' actions from the history.

All six write queries are :execrows so callers can distinguish 'restored'
from 'nothing matched' without a follow-up read.
2026-08-22 13:16:31 -04:00
john-okeefe 91c8be8562 docs(api): document the KOReader resolve endpoint
Add koreader/resolve_book.md for GET /api/sync/koreader/resolve, list
the endpoint in the API reference, and describe the resolve-then-pull-
then-push linking flow in the KOReader protocol page — including why a
device pushing to bootstrap its identity creates progress conflicts for
books already mid-read from other sources.
2026-08-22 09:57:44 -04:00
john-okeefe 6859f81144 feat(sync): add read-only KOReader book resolve endpoint
GET /api/sync/koreader/resolve?sha256={hash} maps a file content hash to
the book's UUID through the shared format-aware BookResolver (primary
media_items hash, then per-format hashes so converted KEPUB/PDF files
match) without touching any progress state.

Devices need the UUID to pull metadata, but a freshly downloaded book has
none cached. The old way of learning it was to push once, which
transmitted the device's first-page position and manufactured a progress
conflict for books already mid-read from another source. A read-only
lookup lets clients link (and pull) without ever pushing bootstrap
progress: resolve, then pull, then push.

Returns 200 {book_uuid, sha256, title, author}, 400 for a missing or
malformed hash, 404 when no library item matches.
2026-08-22 09:57:35 -04:00
john-okeefe 078c4b1f3f docs(api): add system/hash-conflict sections to API references
Monolithic api-reference.md:
- New 'System Settings & Configuration' and 'Hash Conflicts' sections
  (endpoints, examples, response shapes) with TOC entries
- Device Management: add the sidecar config/download endpoints
- Fix stale registration flow: correct auth_url path, drop phantom
  device_id, add poll_interval/setup_instructions, status endpoint is
  POST /api/devices/register/status, and sync_endpoints point at
  /api/sync/koreader/*
- Mark PUT /api/libraries/scan-settings as legacy/superseded
- Repair Additional Resources and Collections links (dead
  COLLECTIONS_API.md / KOBO*_SETUP.md / missing-guide references)

Split api-reference.md index:
- Quick links and sections for System (settings + config) and the
  admin hash-conflict endpoints; device sidecar endpoints under Device
  Management; browse + legacy scan-settings routes under Libraries
2026-08-20 14:41:05 -04:00
john-okeefe c49a9605ff docs(api): rewrite KOReader bookmark sync for current protocol
sync_bookmarks.md documented a request shape the handler never accepted.

- Document the real body: book_uuid/book_sha256 (either required,
  SHA-256 is format-aware), plus separate bookmarks/notes/highlights
  arrays using the shared KOReader annotation shape (pos0/pos1, page,
  text, type, per-annotation book_sha256, dedup_key, percentage)
- Document color semantics from 178fb2e/dafcadd: KOReader palette
  names map to web hex swatches at the boundary, echoes carry no color
  so stored web colors survive round-trips, explicit colors are device
  edits
- koreader-protocol.md: cross-link the bookmark shape/color/dedup
  rules from the progress-sync field table
2026-08-20 14:40:59 -04:00
john-okeefe df90938c5c docs(api): document device sidecar endpoints; fix registration docs
- New get_sidecar_config.md for GET /api/devices/:id/sidecar and
  /sidecar/download: the .bookhoard.json config served to devices
  (endpoints, books keyed by per-format SHA-256 with UUID fallback,
  collections, format availability) used by the KOReader plugin to
  self-configure
- register_device.md: correct the response — no device_id at
  registration; auth_url is /devices/approve/:id (was the nonexistent
  /devices/auth/confirm/:id); document poll_interval and
  setup_instructions, and the approve-then-poll flow
- get_devices.md: fix the status endpoint path to
  POST /api/devices/register/status (was /api/devices/auth/status)
2026-08-20 14:40:54 -04:00
john-okeefe ffcdab36a0 docs(api): document hash-conflict resolution endpoints
Cover the admin API added in 03cb4c7 for duplicate-content decisions:

- GET /api/admin/hash-conflicts — pending conflict groups with member
  items and per-item usage counts (progress, highlights, bookmarks,
  notes, collections)
- POST /api/admin/hash-conflicts/:id/resolve — action=keep (merge child
  rows into keep_uuid, delete losers) vs action=keep_all (dismiss);
  JSON and form-encoded bodies, error codes including 409 for already
  resolved
- When conflicts are created (startup backfill, rescans) and the
  guarantee that files on disk are never deleted
2026-08-20 14:40:50 -04:00
john-okeefe 44a0f8c7a4 docs(api): document unified tunable system settings endpoints
The scattered scan-settings JSON routes are superseded by the new
admin-only /api/system/settings pair backed by the SettingsRegistry
(introduced in 885f6d8 / bc47450).

- Rewrite system/settings.md around GET/PUT /api/system/settings:
  SettingEntry metadata shape (type, min/max, requires_restart,
  category, group, is_default), type-aware validation rules, and the
  full tunable-setting catalog (scanner, general, security, api, sync,
  performance) with defaults, ranges, and restart requirements
- Note the legacy /api/libraries/scan-settings routes as back-compat
  only (they now refresh the registry cache on write)
- Add system/config.md for GET/PUT /api/system/config: raw key/value
  system configuration (e.g. base_url), including validation notes and
  guidance to prefer the typed settings endpoint for registry keys
2026-08-20 14:40:45 -04:00
john-okeefe 8ca95db08a docs(user): update admin and collections instructions for sidebar UI
Admin:
- Admin pages live in the sidebar's Administration panel (Dashboard,
  Libraries, Hash Conflicts, Users, Settings)
- Library creation is the Create Library modal (Library Name,
  Description, Library Type); folders are added afterwards by expanding
  the library row and using the Folders section's path input + Browse +
  Add — the old Add Library modal with folder and 'Scan on save' fields
  no longer exists
- Scanning is via the Scanner API or watch mode (File Watcher status on
  the admin dashboard); remove references to the removed per-library
  Rescan button and 'Force Rescan' option

Collections:
- Fill in the empty creating/managing placeholders with the real flow:
  New Collection button, modal fields (name, description, icon grid,
  color swatches), per-collection edit/delete icon buttons, Restore
  System button, and dashboard-section visibility via Customize
  Dashboard
2026-08-20 14:30:10 -04:00
john-okeefe 9a60196f1d docs(user): update dashboard and bookshelf instructions for sidebar UI
Dashboard:
- Customize Dashboard is opened from the icon button at the right end
  of the Library bar (next to Refresh), and requires a specific library
  selected rather than 'All Libraries'
- Library switching uses the Library dropdown in the bar below the top
  bar (includes 'All Libraries' with counts)
- Collection sections are shown/hidden from the Customize Dashboard
  toggles — the per-collection 'Show on Dashboard' setting is gone
- Mention the hover chevrons for scrolling carousels

Bookshelf:
- Saved filters: document the new toolbar buttons — Filters (opens the
  filter drawer with Apply Filters, Esc, and overlay-click close), Save,
  Load (Saved Filters dropdown with trash-icon delete), and Clear —
  replacing the emoji-labelled Save Filter / Saved Filters buttons
- Tag filtering: filters now live behind the Filters drawer on the All
  Books page
2026-08-20 14:30:05 -04:00
john-okeefe 7274b5196c docs(user): update appearance/profile instructions for sidebar UI
- Theme switching lives in the sidebar's Appearance panel (palette
  icon): swatch list with a checkmark on the active theme, and the
  'Bookshelf' section below it for wood textures; on small screens the
  sidebar opens via the top-bar menu button
- Profile: account menu is the username accordion at the bottom of the
  sidebar (not top-right); save button is 'Save Changes'
- Remove Wood Light/Dark/Mahogany from the Available Themes list (they
  are bookshelf backgrounds, not color themes) and consolidate the
  Catppuccin variants
2026-08-20 14:30:00 -04:00
john-okeefe 868003331c docs(user): update device and sync navigation for sidebar UI
The new-ui redesign replaced the top header with a sidebar and removed
the Settings pages.

- Replace all 'Settings → Devices' paths with the Devices page in the
  sidebar
- Conflicts are resolved from the book detail page's Sync Progress
  button or the Conflicts page (/conflicts); drop the nonexistent
  'Settings → Conflicts' path
- Queue status and unlinked-book references no longer invent per-device
  button paths that don't exist on the Devices page
- Reading history now points to the Progress page / book detail
- Export FAQ no longer references a Settings → Export flow that isn't
  in the UI
2026-08-20 14:29:54 -04:00
john-okeefe e310fa6a9d docs(index): update device guidance and fix broken links
- Device setup and quick-find entries now lead with the KOReader guide
  and label native Kobo sync as coming soon
- Kobo protocol/API listings tagged as a coming-soon feature
- Fix seven pre-existing broken links: contributing/Development.md had
  the wrong case (development.md), and PROJECT_GUIDELINES.md links were
  missing the ../ prefix to reach the repo root
- Refresh last-updated stamp
2026-08-20 14:14:55 -04:00
john-okeefe e35d394736 docs(readme): update device support matrix to current state
- Supported-devices table: Kobo moves from 'full support' to 'coming
  soon, use KOReader on Kobo today'; mobile apps 'coming later' with no
  speculative date
- Universal-sync pitch now states what actually syncs (position,
  bookmarks, highlights, notes) between KOReader and the web
- Frame KEPUB conversion and collection shelf mappings as groundwork
  for upcoming native Kobo support
- Fix dead links: docs/DEVELOPMENT.md → docs/developer/development.md
  and docs/contributing/DEVELOPMENT.md → actual path
2026-08-20 14:14:52 -04:00
john-okeefe 2a7ac881fd docs(user): align sync and user guides with current device support
- sync-guide: only Web and KOReader are fully supported; move Kobo to
  coming soon, drop fake Q2-Q4 2026 release dates for mobile/Kindle/
  Remarkable, and describe the plugin + server-approval registration
  flow instead of QR-code/URL approval
- sync-guide: remove cellular/mobile-app advice from battery and
  best-practice sections, correct the Calibre compatibility FAQ, update
  the changelog to reflect shipped vs. pending sync features, and fix
  the license header (GPL-3.0, not MIT)
- user-guide: lead device setup with KOReader; mark the Kobo guide as
  coming soon
- calibre-integration: OPDS client list no longer implies native Kobo
  support
- auth overview: label the mobile-application token guidance as
  'coming later' since no mobile apps exist yet
2026-08-20 14:14:49 -04:00
john-okeefe 26f1f98736 docs(kobo): mark native Kobo sync as coming soon
Native Kobo sync is implemented server-side but not yet supported on
real devices, so stop documenting it as a working feature.

- Rewrite kobo-setup.md as a coming-soon stub: point users to KOReader
  (which runs on Kobo hardware) as the supported path today, and list
  what native sync will deliver when released
- Add 'Coming Soon' status banners to the Kobo protocol spec, all five
  Kobo endpoint docs, and both API references, noting the endpoints are
  under active development and may change
- Tag the device shelf endpoints as pending native Kobo support
2026-08-20 14:14:43 -04:00
john-okeefe 54d0550dec docs(koreader): rewrite setup guide for plugin + server-side approval flow
Replace the outdated Calibre-wireless/Basic-Auth instructions with the
actual current flow: install the bookhoard.koplugin plugin, enter the
server URL in the plugin menu, then approve the pending registration
from Settings → Devices. Registration tokens are delivered to the
plugin automatically after approval (5-minute expiry), so no
credentials are ever typed on the device.

Also document bidirectional sync of position, bookmarks, highlights
(colors mapped between web and KOReader palettes), and notes, plus
format-aware SHA-256 book matching, OPDS delivery, and trimmed
troubleshooting sections covering the new registration flow.
2026-08-20 14:14:39 -04:00
john-okeefe 995ccb50bb Merge branch 'test-fixture-cleanup': book-agnostic CFI converter tests
Release / build-and-push (push) Successful in 2m36s
2026-08-20 09:22:10 -04:00
john-okeefe 4ab947f7db test(sync): replace book-specific CFI converter fixtures with a synthetic EPUB
Six converter tests pointed at absolute paths for 1984 and Crime and
Punishment under uploads/ — books that don't exist on most checkouts
(CI included), so the suite shipped with 5 permanently failing tests
(and a sixth passing only by accident: the percentage-fallback path
triggered by the missing file is the outcome it asserts).

A writeTestEPUB helper now builds a minimal deterministic EPUB in
t.TempDir() (zip → container.xml → OPF → 6-doc spine), so the tests
exercise the real zip/OPF/spine/document pipeline with no external
dependencies. The xpointer→CFI conversion, fragment-ID conversion,
both round-trips (bare and context-text-anchored), and the text-search
and percentage fallbacks all keep their original assertions, now
against known document content. internal/sync is green for the first
time on this machine.
2026-08-20 09:22:10 -04:00
john-okeefe f07c93e582 Merge branch 'sync-annotations-fix': bidirectional annotation sync for KOReader
Server-side (8 commits): web annotations finally reach KOReader and
vice versa. Fixed the 400 bind failures on every annotation-carrying
push (loose client types), resolved device-native pos0 locators for
every source (device xpointers pass through round-trip identical,
web CFIs convert to CRE xpointers with text-search anchoring, PDF
anchors map to pages), derived degenerate range ends from selection
length, echo-deduplication via served dedup keys (pull→push cycles
converge instead of minting duplicates), web↔device color mapping at
both boundaries with echo suppression (web colors flow to devices,
round-trips never drift them, device edits win), drawer-based
annotation classification, and tombstone propagation that can't
cross-delete. Perf: parsed-EPUB converter cache (bounded, locked).

Plugin-side (bookhoard.koplugin @ 4ea3966): dual-model annotation
store (KOReader 2024.07+ v2 ui.annotation + legacy v1), thin-client
collection (no per-annotation CRE lookups), dedup-key identity
matching, device-default coloring for applied highlights with
datetime_updated-based echo suppression, and native-shaped
AnnotationsModified dispatches (fixes a ReaderThumbnail crash and
paints immediately instead of after restart).
2026-08-20 09:13:24 -04:00
john-okeefe 178fb2eb37 feat(sync): serve web highlight colors to KOReader (mapped to its palette)
Reverses the earlier "no colors to the device" decision now that the
echo machinery makes it safe: GetMetadata maps the stored web hex to
KOReader's fixed color names (#ce93d8→purple, #90caf9→blue,
#a5d6a7→green, #ffd54f→yellow; pink maps to purple as the closest —
round-trip drift is prevented on the device by echo suppression, and
a device edit still wins). mapColorToKOReader restored for serving;
ingest (name→hex, preserve-on-echo) unchanged.
2026-08-20 08:43:21 -04:00
john-okeefe dafcadd211 fix(sync): echo dedup + color semantics + classification for KOReader round-trips
Echo duplication: devices push their full annotation list on every
sync, and an echo of a web-created annotation computed a different
dedup key than the original (device locators differ from web locators)
— every pull→push cycle minted a duplicate row, and cleaning those up
on the web tombstoned them back to the device, deleting the
just-applied copies. That was the "web highlights never appear on
KOReader" experience. GetMetadata now serves each annotation's
dedup_key; the device stores it on the applied entry and echoes it in
pushes; SaveHighlight/SaveBookmark/SaveNote accept a DedupKey
override so echoes converge onto the original row (verified: pull →
echo push creates no rows, LWW skips identical content).

Color semantics (per user preference): devices render their own
default and cannot round-trip web colors, so GetMetadata no longer
serves colors at all — every highlight syncs regardless of its web
color and the device draws its default. An echo carries no color;
ingest then PRESERVES the stored web color (existingHighlightColor
lookup by dedup key) so round-trips never change it. A non-empty
device color means the user edited the highlight there: it maps
name→hex (green→#a5d6a7, default yellow) and wins. Verified: echo
kept #ffd54f; a simulated device edit with "green" updated the web
row to #a5d6a7.

Classification: KOReader auto-fills text="in Chapter X" on page
bookmarks (ReaderAnnotation:updateItemByXPointer), so the plugin's
text-presence classification turned every echoed bookmark into a junk
highlight on the web. v2 classification now keys off the drawer field
(present = highlight/note, absent = bookmark with its label in note).
2026-08-19 19:41:58 -04:00
john-okeefe 50ec2bebf2 fix(reader): render device-synced highlights — synthesize range CFIs
Device-synced highlights stored POINT CFIs (epubcfi(.../8/1:1)); the
overlayer resolves those to a collapsed range and paints nothing, so
KOReader-made highlights were listed in the drawer but invisible on
the page. mapHighlightRow now builds a renderCfi: a proper RANGE CFI
(epubcfi(base,/start,/end)) synthesized from the stored start/end
points. It also repairs stale rows: missing ends (old web highlights)
and degenerate document-start ends (the old converter fallback) are
derived from the start offset plus the selection text's UTF-16
length. All overlay drawing, navigation (showAnnotation), and the
post-create/post-edit re-adds use renderCfi. Verified in-browser
against live device-synced rows: the paginator's overlayer paints
the highlight rects after the fix.
2026-08-19 14:08:03 -04:00
john-okeefe 6e9b3528d8 fix(sync): synced highlights painted nowhere — degenerate range ends + color model mismatch
Both directions synced data but rendered nothing:

- Web reader <- devices: highlights painted no overlay. Device pushes
  resolve their start xpointer exactly (text-search anchored by the
  selection) but the end conversion carries no context and fell back
  to a document-start CFI (epubcfi .../1:0) — a garbage range end.
  When the start resolved exactly, the end is now derived from it:
  same node, character offset advanced by the selection's UTF-16
  length (extendCFIByLength). Same repair when SERVING to devices,
  where old web highlights (no end anchor) and converted range CFIs
  both collapsed pos1 onto pos0 (extendXPointerByLength on the
  xpointer form) — KOReader drew zero-width highlights.
- Colors: KOReader paints from a fixed name set (Blitbuffer
  HIGHLIGHT_COLORS), the web uses hex swatches; neither understood
  the other, so device colors fell back to defaults and web hex drew
  nothing useful on devices. Both boundaries now translate: ingest
  maps names to hex (default #ffd54f), GetMetadata maps hex to names
  (default yellow) — per-datatype edits re-push with the editing
  side's color, which LWW then propagates. SyncBookmarks endpoint
  aligned to the same mapping and default.
2026-08-19 14:08:03 -04:00
john-okeefe 97e546b2a4 feat(reader): send end-anchor CFI for EPUB highlights
Web highlights stored only epubcfi_start, so devices received
degenerate pos0 == pos1 (zero-length) highlight ranges. The reader
now collapses the selection range to its end point for a second CFI
and stores it as epubcfi_end (PDF rect anchors reuse the JSON anchor
for both ends).
2026-08-18 19:13:51 -04:00
john-okeefe 1585aa1073 perf(sync): share parsed EPUBs across conversions, make converters concurrency-safe
ConvertToCanonical/ConvertFromCanonical built a fresh CFIConverter
per call, and each annotation converts twice (pos0+pos1) — a book
with 200 highlights re-opened and re-parsed the EPUB 400+ times per
sync, and again per metadata pull. A bounded 8-entry cache keyed by
path now shares converters (the parsing work belongs on the server;
clients stay thin). CFIConverter gained a mutex around its lazily
built spine/doc caches since instances are now shared between
concurrent requests.

Adds CFIConverter.SectionPercentage: book-wide percentage for a CRE
xpointer from the spine char distribution (midpoint of its document)
— the server-side counterpart to dropping per-annotation
getPageFromXPointer lookups from the plugin.
2026-08-18 19:13:51 -04:00
john-okeefe f6e257e497 fix(sync): web annotations never reached KOReader — bind 400s + unresolvable locators
Two blockers, diagnosed by simulating the plugin against the live
server with real library books:

1. Every KOReader progress push carrying annotations failed the JSON
   bind with 400 ('cannot unmarshal string into ... chapter/page of
   type int') — the plugin sends chapter:'', page:'30', and for CRE
   documents page:'/body/...' — so annotation sync AND progress sync
   failed together. KOReader annotation chapter/page now use FlexInt,
   which accepts numbers, numeric strings, empty strings, and
   non-numeric strings (decoding to 0). The server is deliberately
   liberal here so thin clients can send raw bookmark data.

2. GetMetadata served locators KOReader cannot place, so pulled items
   were junk: web bookmarks leaked 'cfi:epubcfi(...)' positions, web
   PDF highlights had empty pos0 (skipped by the plugin, invisible),
   and web deletions carried no pos0 so tombstones never matched.
   New koreaderPos0 resolver handles every source: device-native
   xpointers pass through untouched (round-trip identical, verified),
   web PDF JSON anchors map to their page number, EPUB CFIs convert
   to CRE xpointers (selection text passed as text-search context for
   exact anchoring), 'page:N' positions strip to the bare number.
   Unresolvable annotations are skipped with a log line instead of
   poisoning devices; tombstones get pos0 injected from the new
   locator columns.

Also: thin clients omit per-annotation percentages (paging docs still
send arithmetic page/total); the server derives them — section
midpoint from the spine char distribution for CRE documents, page/
page-count for fixed formats.
2026-08-18 19:13:38 -04:00
john-okeefe 0670d904a0 feat(db): locator columns for tombstoned annotations
GetTombstonedAnnotationsForBook now also returns each tombstone's
start_position/end_position and epubcfi_start/end (note: position/
epubcfi_location, bookmark: position/cfi_position), so serving code
can resolve a device-native locator for deletions of web-created
annotations, whose device_sync_data carries no pos0.
2026-08-18 19:13:23 -04:00
john-okeefe 922336c064 Merge branch 'reader-redesign': reader v2 — immersive chrome, annotations, search, touch, webtoon
Release / build-and-push (push) Successful in 3m0s
Full reader redesign across 22 commits (with the foliate-js fork's
zoom-control engine work pinned per release):

- Phase 0: panel/chrome stabilization, bookmarks end-to-end (REST CRUD
  via AnnotationService), dead UI removal, tombstone resurrection fix
- Phase 1: edge-to-edge glass chrome with auto-hide, slide-over drawers,
  tri-state PDF pointer mode (Smart/Pan/Text), Kindle-style theme swatches
- Phase 2: touch gesture engine (pinch/pan/swipe/double-tap), tap zones,
  mobile sheets + compact toolbar with overflow menu
- Phase 3: EPUB highlights & notes (selection popover, overlayer
  rendering, annotations drawer), PDF text highlights (fraction-rect
  overlays), in-book search for both EPUB and PDF, back-to-location
  stack, page thumbnails, shortcuts help modal, desktop edge zones
- Phase 4: webtoon (vertical-scroll) mode for comics, brightness/
  contrast/night filters, bookmark toast feedback
- Build hygiene: vite stale-chunk cleanup, browser-verified fixes for
  Alpine proxy/dpr/duplicate-key classes of bugs along the way
2026-08-18 09:53:10 -04:00
john-okeefe 243d369d21 fix(reader): pin foliate-js e448d36 — webtoon pages now load
The initial webtoon commit's IntersectionObserver (shadow-host root)
never delivered intersections in Chromium, leaving pages blank.
Scroll-driven loading in e448d36 fixes it; verified end-to-end in a
real browser: pages render (content-rich screenshots), deep scroll
advances the reading position (7/10) and progress readout, filters
visibly change both webtoon images and PDF pages via ::part(filter)
(brightness 5% -> 57% smaller screenshot), paged comics still use
foliate-fxl, and webtoon UI gating (zoom/spread hidden) works.
2026-08-18 08:34:44 -04:00
john-okeefe e500039d1b feat(reader): webtoon reading mode + brightness/contrast/night filters
Phase 4 of the reader redesign (foliate-js ea268df):

- Webtoon mode for comics: continuous vertical scroll of all pages
  (900px centered column on wide screens), lazy-loaded with a 150%
  IntersectionObserver margin, far pages unloaded to bound memory
  with stable aspect-ratio placeholders so the scrollbar never jumps.
  Chosen per book (Paged | Webtoon segmented control in Settings →
  Layout & Display; stored in localStorage per media item since a
  webtoon title and a paged manga volume want different flows).
  Toggling reloads the reader — the renderer is chosen at open time —
  and progress restores from the saved page. Relocate events flow
  through the same pipeline, so the slider, progress saving, back
  stack, tap zones, and edge zones all work unchanged. Zoom/fit/
  magnifier/spread controls hide in webtoon (natural-width scroll).
- Display filters for fixed-layout: brightness (30-130%) and
  contrast (70-130%) sliders with live preview, plus Night Mode
  (invert) — also a quick row in the ⋯ tools menu. One --fx-filter
  CSS var drives everything: ::part(filter) on foliate-view iframes
  (forwarded via the new exportparts attribute) and the webtoon
  page images alike. Persisted as fx_brightness/fx_contrast/fx_invert
  (types + defaults both sides); Restore Defaults resets them.
2026-08-18 08:25:00 -04:00
john-okeefe 94be6edceb feat(reader): shortcuts help modal + desktop edge page-turn zones
Help menu (the reader had a growing shortcut/gesture vocabulary with
no discoverability): a ? topbar button, the '?' key, and F1 open a
glass modal listing navigation, zoom/pan, highlight, and touch
gesture reference — format-aware (fixed-layout/PDF rows appear only
where they apply), Esc closes it first in the dismiss chain.

Desktop edge zones: clickable page-turn strips on the left/right
viewport edges (8% width, 44-72px), desktop only (hover+fine-pointer
media query — touch devices use tap zones, avoiding double paging).
Hovering reveals a chevron arrow and a subtle edge gradient. Zones
disable (pointer-events pass-through) while a fixed-layout page is
zoomed so edge clicks belong to content: panning, selection,
highlight editing. fxZoomed tracks zoom state via the renderer zoom
event, reset/fit actions, and init.
2026-08-18 07:59:26 -04:00
john-okeefe 1a07635605 feat(reader): confirm bookmark creation with a success toast
The 🏷️ bookmark button (and the 'b' shortcut) saved silently — an
accidental click gave no reaction at all. addBookmark() now shows a
short success toast ('Bookmark added — <progress>') using the
existing toast system, which the reader bundle hadn't been importing.
Importing it also activates the shared fetch interceptor, so failed
reader API calls (incl. bookmark saves) surface error toasts instead
of being swallowed.
2026-08-17 14:28:09 -04:00
john-okeefe 206db93587 fix(reader): PDF contents drawer rendered nothing — duplicate x-for keys
Diagnosed in a real browser (playwright/chromium against the running
app + Head First SQL): the engine's book.toc held all 18 entries with
correct labels/hrefs and the tab counter even showed 380, yet zero
links rendered while the console flooded with 'Alpine Warning:
Duplicate key on x-for'.

Root cause: the drawer keyed TOC rows by item.href. PDF outline
entries frequently share the same destination (e.g. the printed TOC
page is targeted by several bookmark entries), so flattened items
carried duplicate keys — and Alpine's x-for renders NOTHING for a
duplicated key, not even the unique ones. EPUB TOCs never collided
because their hrefs are unique file paths, which is why this only
surfaced on PDFs.

Key is now href + row index (the list is static once loaded, so
positional keys are safe). Verified end-to-end in the browser: 18
entries render and the drawer populates.
2026-08-17 14:14:33 -04:00
john-okeefe fd4c357d39 feat(reader): page thumbnails tab + reliable PDF contents
Investigation: the contents drawer read book.toc, which makePDF
builds from pdf.getOutline() — verified against the real library PDF
(Head First SQL) through the exact vendored pdf.js build AND the exact
range transport the browser uses: 18 chapter entries come back. So
the source is right; manga-scan PDFs and CBZs simply have no embedded
outline, which made Contents look broken exactly where users expect
page-based navigation.

- TOC now populates eagerly right after the book opens (toggle-time
  lazy population removed), so an existing outline can never silently
  miss due to timing; the drawer keeps the honest empty-state text
  for books without outlines.
- New 'Pages' tab in the contents drawer for fixed-layout books:
  a Kavita-style thumbnail grid (3-up, current page highlighted and
  scrolled into view, click to jump — recorded on the back-to-
  location stack). Thumbnails render client-side: PDFs via the
  in-memory pdf.js document (small viewport render, Alpine.raw
  unwrap); comics via the page's image blob drawn down to a 110px
  canvas, then unloading the full-size blob so thumbnailling doesn't
  hoard page images. Lazy via IntersectionObserver scoped to the
  drawer's scroll container (200px margin), canvases cached at module
  level so revisits are instant; failures warn in console and allow
  retry. The backend /readers/thumbnails endpoint turned out to be an
  empty stub, so nothing server-side was worth wiring.
2026-08-17 13:55:21 -04:00
john-okeefe dc68d03360 fix(reader): both toolbars showed below 768px — cascade-layer conflict
The display:none for the full toolbar lived in @layer components while
the div also carried Tailwind's flex utility (@layer utilities). Layer
order beats specificity, so the utilities layer always won and the
full bar never hid below the breakpoint (the compact row only worked
because it had no display utility of its own).

Switch to Tailwind's own responsive utilities in the markup — full
toolbar 'hidden md:flex', compact row 'flex md:hidden' — and delete
the custom rules; responsive display now resolves inside a single
layer where source order (responsive variants after base) guarantees
the right winner.
2026-08-17 13:42:02 -04:00
john-okeefe 6cd0fb226a feat(reader): mobile-pattern toolbar — compact row + ⋯ overflow menu under 768px
Wrapping alone isn't how polished mobile readers work. Adopt the
standard pattern (Kindle/Apple Books/Mihon) responsively:

- >= 768px: the full fixed-layout toolbar stays (wrap still absorbs
  mid-size widths) — power users keep one-click zoom/fit/spread.
- < 768px: single-line compact row — page back, back-to-location pin,
  slider, page forward, progress, and a ⋯ overflow button. No
  wrapping, no horizontal scroll.
- ⋯ opens a glass menu anchored above the bar with LABELED rows
  (Zoom −/%/+, Fit, Page position/Recenter, Magnifier, Pointer
  Smart/Pan/Text, Double page, Contents) — labels beat mystery icons
  on touch. Pointer row hides for comics; menu scrolls if tall.
- Dismissal: Esc, outside click (⋯ button exempt so it re-toggles
  cleanly), opening any drawer or TOC closes it; hides with the
  chrome. Compact slider registered in progressSliders() so all
  three stay in sync with relocate events.
2026-08-17 13:35:14 -04:00
john-okeefe f283903e2b feat(reader): relocate back-to-location, add recenter control, wrap bottom bar on small windows
- Back-to-location moves from the topbar (where it sat between Back
  and the title, too subtle and disconnected from navigation) into
  both bottom-bar rows, beside the page-back arrow — the natural
  'go back' cluster. New icon: a location pin, clearly distinct from
  the back arrow and page controls. Appears only when the stack has
  a return target; Alt+← unchanged.
- New recenter button in the fixed-layout row (crosshair icon, next
  to zoom): resets pan offsets while keeping the current zoom —
  backed by foliate's new recenter() (1c812e8), which zeroes the
  wrapper translate and re-syncs the spread side.
- Both bottom-bar rows wrap gracefully on narrow windows instead of
  overflowing/h-scrolling: controls are grouped (paging+back | slider |
  fit+zoom+magnifier+recenter | pointer mode | spread | progress+TOC)
  so groups flow to a second line at small widths; the slider shrinks
  first (grow + min-width), everything else stays whole. Fixed-layout
  row drops its overflow-x-auto.
2026-08-17 13:23:59 -04:00
john-okeefe 34a27a5951 fix(reader): PDF highlights offset from the words — wrong fraction denominator
Highlights landed on the right line but shifted right and oversized
on any display with devicePixelRatio != 1. Cause: selection fractions
divided the textLayer span rects by documentElement's screen rect,
but pdf.js scales the iframe's <html> by 1/dpr — that rect is dpr×
smaller than the visible page, inflating every x/w fraction by dpr
(on a 2× display a highlight started twice as far right and was twice
as wide). dpr=1 displays were coincidentally correct, which is why
the geometry looked sound when written.

The denominator is now the rendered canvas (#canvas canvas), whose
post-transform rect IS the visible page and shares the textLayer's
transform space — the dpr scaling cancels exactly. Comics keep the
img denominator; a viewport fallback covers any page without either.
The popover-placement scale factors (frame/denominator) become 1 for
PDFs as a side effect, fixing popover drift too. The fork's click
hit-test (86e234d) gets the same canvas-aware denominator so clicking
highlights opens the editor at the right spot.

Highlights saved before this fix stored dpr-inflated fractions and
will still render misplaced — delete and re-create them.
2026-08-17 08:33:11 -04:00
john-okeefe 1905feceea fix(reader): PDF search returned nothing — reactive proxy broke pdf.js; add back-to-location
PDF search diagnosis: extraction and matching were proven correct
against the real 609-page library PDF (pdfjs 5.5.207, incl. the exact
range-transport setup makePDF uses — 841 hits for 'SELECT'), and the
served bundle had every piece. The failure was Alpine's reactivity:
this.book is a plain object, so reading .pdf through component state
returns a reactive Proxy around the PDFDocumentProxy — and pdf.js
v5 uses #private fields, so getPage() through the proxy throws
'cannot read private member', which the empty catch rendered as a
silent empty result set. runPdfSearch now unwraps via Alpine.raw
(falls back to the raw read), and search failures surface in the
drawer ('Search failed — see console') plus console.warn instead of
masquerading as 'No matches'.

Back-to-location stack (research/footnote workflow): the current
position is recorded before every programmatic jump — search-result
clicks, TOC entries, bookmark and highlight jumps — and on every
internal link click (footnotes, cross-references) via foliate's
'link' event. A ↩ button appears in the topbar once a return target
exists; Alt+← works everywhere. Ordinary paging never pollutes the
stack (max depth 50, consecutive duplicates collapse).
2026-08-17 08:23:57 -04:00
john-okeefe 6fc4107e3c feat(reader): in-book search for PDFs
PDFs have fully searchable text (pdf.js text layer) — the previous
reflowable-only gate existed only because foliate's generic search
needs DOM documents that PDF sections don't provide. This adds a PDF
pipeline alongside it:

- Fork d065495 exposes the pdf.js document proxy as book.pdf so the
  host can drive text extraction directly.
- New web/src/reader/pdf-search.ts: extractPdfPages() pulls each
  page's textContent with item geometry (progress-reported, cached
  after first search). PDF text items often omit inter-word spaces
  (gaps are positional), so pages are joined gap-aware — baseline
  changes, hasEOL, or horizontal gaps past a font-size threshold
  become spaces — recording a char→item map. searchPdfPages() does
  case-insensitive matching over the joined text and maps each hit
  back to the page-fraction rects of the items it spans, with
  ellipsized pre/match/post excerpts. Pure functions, unit-sanity
  checked (cross-item 'brave new' → two rects).
- runSearch branches: EPUB keeps foliate's DOM search; PDFs search
  the extracted pages, group hits per page ('Page 12'), and render
  on-page hit rectangles through the existing fraction-rect overlay
  (addRectAnnotation) — which re-render automatically when pages
  revisit, same as highlights. Clearing the query removes them.
- Results navigate by page index; the 🔍 button and '/' shortcut now
  appear for PDFs too (comics remain without searchable text).
2026-08-17 08:04:30 -04:00
john-okeefe 5e73b0a4f6 feat(reader): in-book search for reflowable formats
Wires foliate's search engine into the new drawer system:

- 🔍 topbar button (reflowable-only; PDF/comic sections have no
  searchable text documents) and the '/' keyboard shortcut open a
  Search drawer: query input (Enter to run), live progress while
  scanning (per-section percent), match count, and results grouped
  by section with TOC labels.
- Each result shows pre/match/post excerpt rendered as three text
  nodes (no x-html — book content never enters the DOM as markup);
  the match is styled with a translucent <mark>. Clicking jumps to
  the hit's CFI and closes the drawer.
- Hits are drawn on the page through foliate's overlayer (outline
  style) and persist across page turns — the engine re-applies
  search results when a section's overlay is created. Clearing the
  query removes the outlines.
- A generation counter discards results and progress from superseded
  searches (rapid re-query), and starting a new search clears the
  previous one server-side via view.clearSearch().
- Search integrates with the drawer system: scrim, Esc-to-close,
  one-drawer-at-a-time, / focuses the input via .
2026-08-17 07:58:03 -04:00
john-okeefe eb09a5d939 fix(reader): PDF highlights never appeared — isPDF read too early + overlay shrunk by pdf.js transform
Two bugs broke the Phase 3b PDF highlight flow end to end:

1. Selection capture never attached: reader.ts read renderer.isPDF
   before view.init() rendered the first spread, but the renderer
   only sets that flag once frames exist (PDF frames carry pdf.js
   onZoom). The stale undefined copy gated the pointerup selection
   listener off, so selecting PDF text did nothing. The listener now
   gates structurally on the loaded document having a .textLayer
   (true for every PDF page, false for comics), and isPDF is re-read
   after init — which also finally makes the Smart|Pan|Text control
   and the saved pointer mode apply on PDFs.

2. Highlights rendered invisibly: the overlay SVG lived inside the
   page iframe, whose <html> pdf.js scales by 1/devicePixelRatio —
   shrinking the overlay into the top-left corner on any dpr != 1
   display. The fork (1c0ebf3) now renders annotation rects
   host-side, inside the frame wrapper element, positioned in
   percentages of the visible page box — immune to the html
   transform, zoom re-renders, comic iframe scaling, and pan/zoom.
2026-08-17 07:47:03 -04:00
john-okeefe a05b0167ad feat(reader): PDF text highlights via fraction-rect annotations
Phase 3b of the reader redesign — highlighting for fixed-layout PDFs:

- Select text on a PDF page → same glass popover as EPUBs (colors,
  note, copy). The selection's client rects are normalized to
  page-fraction quads using a transform-inclusive denominator so
  pdf.js's devicePixelRatio scaling on <html> cancels out, then
  stored as a JSON anchor {page, rects} in epubcfi_start.
- Rendering goes through the fork's new rect-annotation pipeline
  (foliate-js aba68d8): a full-bleed viewBox-0-100 SVG inside the
  page iframe, so highlights stay aligned through pan/zoom, iframe
  CSS-scaling, and PDF hi-res re-renders with zero re-anchoring.
  Frames carry their page index and re-render annotations when
  recreated on spread changes.
- Clicking an existing highlight hit-tests in fraction space and
  opens the edit popover (recolor, note, copy, delete) at the
  host-space click position; drag-selecting text never triggers it.
- Annotations drawer: PDF highlights jump by page index; notes and
  recolors round-trip through the same LWW/dedup sync path as EPUBs
  (same dedup key derivation on the JSON anchor).
- Comics keep bookmark-only highlighting (no text layer) by design.
2026-08-16 12:48:09 -04:00
john-okeefe 40d70513da feat(reader): EPUB highlights & notes — selection popover, overlayer rendering, annotations drawer
Phase 3 (EPUB half) of the reader redesign:

- Select text in a reflowable book → floating glass popover at the
  selection (5 colors, note, copy). Clicking a color creates the
  highlight via POST /api/media-items/:id/highlights, anchored by the
  foliate range CFI (epubcfi_start) with percentage position.
- Highlights render through foliate's overlayer pipeline: draw-
  annotation draws Overlayer.highlight with the stored color,
  create-overlay re-adds persisted highlights as sections load,
  show-annotation opens the edit popover when a highlight is clicked
  (recolor, edit note, copy, delete).
- Backend: highlight create/update accept epubcfi_start/end,
  note_text, and percentage fields; position validation relaxed
  (CFIs exceed the old 100-char cap); PUT routes through
  AnnotationService.SaveHighlight so edits get dedup/LWW treatment
  and actually persist note_text (the plain query can't).
- Bookmarks drawer becomes the Annotations drawer with tabs:
  Highlights (color-bar list, note previews, jump/edit/delete),
  Notes (add note at current position, list, delete — backed by the
  existing notes API), and Bookmarks (unchanged behavior).
- Popover dismissed on outside click, collapsed selection, page
  navigation, or Esc (new top-priority Esc branch).
2026-08-16 12:33:55 -04:00
john-okeefe bd7d71a284 build(vite): remove stale hashed chunks after each build
emptyOutDir is false because web/static also holds tracked assets,
so *-<hash>.js chunks from every previous build accumulated
indefinitely and leaked into Docker images via the build context
(the reader serves whichever chunk the import chain names, so the
orphans are pure confusion + bloat). A closeBundle plugin now
deletes any hashed chunk this build did not produce.
2026-08-14 16:06:27 -04:00
john-okeefe 24ea9d8a38 feat(reader): touch & mobile — tap zones, gesture engine, mobile sheets
Phase 2 of the reader redesign:

- Fixed-layout touch engine (foliate-js e9e61d8): pinch-zoom around
  the midpoint, two-finger pan, single-finger pan while zoomed,
  horizontal swipe page-turn at fit (RTL-aware via next()/prev()),
  and double-tap to zoom 2.5x / reset. Touch events forwarded from
  page iframes with converted coordinates; preventDefault only when
  the engine consumes the gesture, so PDF text selection and native
  taps stay intact. touch-action: none on the host and in comic/pdf
  page documents keeps the browser from fighting the engine.
- Tap zones (Kindle-style) for touch devices: tap the outer margins
  to page, center to toggle chrome. Size configurable (10-50%) via
  the revived tap_zone_size setting; toggle via new tap_zones_enabled
  (Behavior section of the settings drawer). Pointer-based + passive
  so drags/swipes/selection never trigger; attached both to the
  viewport and inside every page document (iframe events don't
  bubble); debounced 280ms so double-tap zoom doesn't also page; no
  zone actions while a fixed-layout page is zoomed.
- Drawers become full-width sheets on screens <= 640px.
2026-08-14 16:00:19 -04:00
john-okeefe a962342ee0 fix(sync): resurrect tombstoned annotations when a newer save re-creates them
Deleting a bookmark/highlight/note and then re-adding the same content
at the same position (same dedup key — e.g. the reader's auto-titled
'Bookmark at X%') was silently swallowed: the save hit the tombstone
branch, returned 201 with the deleted row, and the list (which filters
deleted) stayed empty. Bookmarks were further blocked by the
UNIQUE(media_item_id, user_id, title) slot the tombstoned row holds,
and notes had no TTL escape at all.

Tombstones now only block saves that predate them (stale replays from
a device that still has the annotation). A save whose modification
time is newer than max(deleted_at, last_modified_at) — a deliberate
re-create from the web or a device — resurrects the row via the LWW
update queries, which now clear deleted/deleted_at.
2026-08-14 15:42:18 -04:00
john-okeefe 14d1a158a0 feat(reader): glass chrome — translucent bars, custom slider, slide-away hide
Modernize the reader chrome bars without touching the drawer system:

- Bars become theme-tinted glass: 70% bg-primary translucency over
  the edge-to-edge page, 18px backdrop blur + saturation, hairline
  translucent borders, soft directional shadows (single .reader-glass
  class owns the effect; replaces solid opaque backgrounds and the
  tailwind backdrop-blur that would override it).
- Chrome hide/show now slides the bars off-screen (translateY) in
  addition to the opacity fade, via .chrome-hidden on #reader-chrome.
- Theme-aware hover pills (translucent currentColor tint) replace
  hard-coded gray-700 hovers; focus-visible rings added.
- Progress slider gets a custom thin rounded track with a floating
  white thumb (webkit + gecko), replacing native range styling.
- Separators and the fit-mode select match the glass language
  (.reader-sep, .reader-select).
2026-08-14 15:16:05 -04:00
john-okeefe 612f888683 feat(reader): immersive chrome, slide-over drawers, tri-state PDF pointer mode
Phase 1 of the reader redesign:

- Reading surface is edge-to-edge; top/bottom bars overlay
  translucently (backdrop-blur) instead of reserving insets, killing
  the inset-coordination bug class entirely. Chrome auto-hides after
  2.5s of pointer inactivity (chrome_behavior setting finally wired:
  auto-hide / always-visible; legacy values map to auto-hide). Pointer
  activity inside page iframes keeps it awake; Esc toggles.
- TOC / Settings / Bookmarks become slide-over drawers with a scrim
  (z-50, full-height, safe-area aware), replacing the dockable-panel
  system and its window-shade headers. Only one drawer opens at a
  time; Esc or scrim click closes.
- Bottom bar is contextual: reflowable keeps nav/slider/progress/TOC;
  fixed-layout row adds Fit Page/Width select, zoom cluster,
  magnifier (now shows active state), Double Page Spread toggle, and
  a Smart | Pan | Text segmented control replacing the cryptic
  two-state icon. Smart = text-aware drag; Text = selection-only
  (manual smart-detect off); Pan = force pan. Choice persists via
  pdf_interaction_mode (new setting + foliate 29bc958 'text' mode).
- Settings drawer: Behavior (chrome, progress mode), Appearance with
  18 Kindle-style theme swatches (single source of truth from
  THEME_COLORS), Typography, Layout — each scoped by format.
- Keyboard: t/s/b open TOC/settings/bookmark, Esc closes drawers
  before toggling chrome, shortcuts skip form inputs; both slider
  rows tracked correctly (no duplicate-ID lookups).
- Topbar: Back, title, add-bookmark, bookmarks drawer, Aa settings;
  chrome follows user theme.
2026-08-14 14:59:51 -04:00
john-okeefe ba95cc3e8b fix(reader): stabilize chrome panels, bookmarks end-to-end, dead UI removal
Phase 0 of the reader redesign:

- Panels no longer render under the top/bottom bars: sidebars get
  measured insets (same resize/safe-area mechanism as the viewport);
  panel max-height now derives from the bounded sidebar instead of a
  100vh guess; right-side border targets the actual sidebar.
- Bookmarks work end-to-end for the first time: REST CRUD under
  /api/media-items/:id/bookmarks (create/delete route through
  AnnotationService for dedup/LWW/tombstones), fix UpdateMediaBookmark
  referencing nonexistent updated_at column, frontend posts to the
  real API with per-format position (CFI vs page), live list with
  jump + delete instead of SSR-only snapshot.
- Fix chapter matching in progress saves: boundaries were compared by
  a nonexistent tocItem property, so chapter was never persisted.
- Remove dead UI: Navigator panel stub, empty dictionary popup shell,
  unwired Chrome Behavior select; purge 160 stale build artifacts.
- Reader chrome now follows the user's app theme instead of hardcoded
  theme-tokyo-night.
2026-08-14 09:05:33 -04:00
john-okeefe 03cb4c7869 feat(admin): startup hash backfill and hash-conflict resolution API
Release / build-and-push (push) Successful in 2m48s
Complete the SHA-256 lifecycle for preexisting databases: items
imported before hashing existed get hashed automatically, and any
content duplicates discovered in the process land on the new admin
Hash Conflicts page for an explicit keep/merge decision.

HashBackfillService (runs once 30s after startup, independent of
auto-scan):
- hashes every media_items row where file_sha256 IS NULL, resolving
  each path through LibraryService; per-item failures are logged and
  skipped so one unreadable file cannot block the pass
- no-op once everything is hashed (logged and skipped)
- finishes with a conflict sweep flagging every content-duplicate
  group via FindHashConflictGroups + CreateHashConflict; the sweep
  runs after the per-item pass because a preexisting pair only
  becomes detectable once both sides have their hash

API (admin-only):
- GET /api/admin/hash-conflicts - pending groups with member items
  and usage counts
- POST /api/admin/hash-conflicts/:id/resolve - action=keep_all, or
  action=keep with keep_uuid: validates the uuid belongs to the
  group, re-parents every other copy's child rows onto the kept item
  (reparent_media_item_children), deletes the losers, and records
  the resolution + resolving admin; accepts form or JSON bodies and
  returns the htmx resolved fragment

Page route /admin/hash-conflicts (admin-only) renders the template
with hydrated conflict data; HashConflictsHandler wired into the
router Config and constructed in main.

Verified end-to-end against the live database: duplicate detection,
pending listing, keep_all resolution, merge path (re-parent +
delete), and - critically - a resolved group is not re-flagged by a
later sweep (upsert no-op). Database restored afterward.
2026-08-14 08:53:01 -04:00
john-okeefe 8004cb81a5 feat(ui): admin Hash Conflicts page and nav entry
New /admin/hash-conflicts page (admin-only) listing pending
content-duplicate groups. Each group card shows the library, a
shortened SHA-256, and one row per copy with title, author, path,
size, and per-copy reading-data counts (progress, highlights,
bookmarks, notes, collections) - copies that own user data are
highlighted so the keep choice is informed.

Per copy: 'Keep this copy' merges the other copies' child rows into
it and deletes them. Per group: 'Keep both' for intentional
duplicates. Both confirm first, resolve via htmx POST, and swap the
card for a resolved confirmation inline. The confirmation fragment is
built inline in the handler rather than the templates package
(templates imports handlers; a back-import would be a cycle).

Empty state shown when no conflicts are pending. Adds a 'Hash
Conflicts' entry to the admin sidebar section between Libraries and
Users.
2026-08-14 08:52:33 -04:00
john-okeefe 77990d0dc0 feat(scanner): recompute hashes on rescan and flag content duplicates
Force rescan was metadata-only: updateMediaItem never touched the
hash identifiers, so a force scan could not backfill file_sha256 for
items imported before hashing existed (or where extraction originally
failed). Those items were invisible to content dedup and SHA-256
device matching with no way to fix short of delete + re-import.

processMediaFile now refreshes hash identifiers in three cases:
- force rescan (the admin Scan button becomes the backfill tool)
- file size change (stored hash is stale - the bytes changed)
- unchanged file with no stored hash (ordinary scans self-heal the
  legacy backlog incrementally, no admin action required)

Each recompute runs recordHashConflictIfAny: when the freshly stored
hash is now shared by more than one item in the library, the group is
upserted into hash_conflicts for the admin Hash Conflicts page. The
upsert is a no-op for already-tracked groups, so resolved 'keep both'
decisions stick.

Also extract a package-level computeFileSHA256 (the scanner method
now delegates to it) so the startup backfill service can hash files
without a scanner instance.
2026-08-14 08:52:18 -04:00
john-okeefe 0c39e04e4a feat(db): hash_conflicts table and backfill/conflict queries
Content duplicates (same library + file_sha256 at different paths,
e.g. the same book imported twice under two names on a preexisting
database) cannot be auto-collapsed the way path duplicates were:
keeping both copies may be intentional. Surface them for an explicit
admin decision instead.

Schema:
- new hash_conflicts table keyed (library_id, file_sha256) with a
  status/resolution lifecycle: 'pending' until an admin resolves via
  'keep_all' or 'kept:<uuid>' (which copy was kept after merging)
- resolution is VARCHAR(50) - 'kept:<uuid>' is 41 chars; include a
  widening ALTER for databases created with the initial 30-char width
- resolution/resolved_by/resolved_at record who decided what and when

Queries:
- ListMediaItemsMissingHash: items imported before hashing existed
  (file_sha256 IS NULL), ordered oldest-first for the backfill pass
- FindHashConflictGroups: the content-duplicate group detection
  (GROUP BY library_id, file_sha256 HAVING COUNT(*) > 1)
- ListMediaItemsBySHA256AndLibrary: full membership of one group
- CreateHashConflict: upsert with DO NOTHING so already-tracked groups
  are untouched - critical behavior: a group an admin resolved as
  'keep both' is never re-flagged by later sweeps
- ListPendingHashConflicts: admin listing with library name and live
  item counts (items may have been deleted since flagging)
- GetHashConflict / ResolveHashConflict: lifecycle
- GetMediaItemUsageCounts: per-item progress/highlight/bookmark/note/
  collection counts so the admin can make an informed keep choice
- ReparentMediaItemChildren: sqlc binding for the existing
  reparent_media_item_children() migration function, used to merge a
  losing copy's child rows into the kept copy
2026-08-14 08:52:05 -04:00
john-okeefe 8599e5c250 docs(sync): document SHA-256 fields and format-aware matching in koreader protocol
Bring the koreader protocol doc in line with the hash-sharing work:

- Request table: uuid is no longer required (it is absent on the first
  sync of a newly downloaded book); document sha256 and file_path and
  the resolution priority uuid -> sha256 -> file_path alias ->
  title/author
- Note that SHA-256 matching is format-aware (media_items hash first,
  media_item_formats fallback) so converted KEPUB/PDF downloads match
- Document the sha256 field returned by the metadata and library
  endpoints
- Add a 'Book identification' section pointing current and future
  clients (koreader, kobo, OPDS, device-link UI, mobile apps) at the
  shared BookResolver as the single resolution path
2026-08-14 08:26:47 -04:00
john-okeefe 830741cd65 feat(sidecar): key book map by per-format hashes
The sidecar config's books map is keyed by the primary SHA-256 (UUID
fallback). A device holding a converted format (KEPUB/PDF) whose hash
lives only in media_item_formats could not resolve its file through
the sidecar.

After inserting the primary-keyed entry, also register the same entry
under each per-format hash from media_item_formats (first write wins,
so a primary hash is never shadowed). Devices now resolve converted
files via the sidecar the same way the server's BookResolver does.
Applied to both the GET and download sidecar builders.
2026-08-14 08:26:32 -04:00
john-okeefe 48af5d3e14 feat(opds): always send X-Bookhoard-SHA256 on native EPUB downloads
DownloadBook populated fileSha256 only for the kepub and pdf format
branches, so the default EPUB download never emitted the
X-Bookhoard-SHA256 response header - the hash was only available in
the feed metadata, not on the download response itself.

Populate it from mediaItem.FileSha256 in the default branch so every
download response carries the canonical primary-format hash. Clients
that capture response headers at download time now learn the hash
regardless of which format they requested.
2026-08-14 08:26:14 -04:00
john-okeefe 5584bdefb5 feat(koreader): resolve pushes by SHA via BookResolver and return SHA on pull
Fixes the 'cannot push until pulling first' wall on books downloaded
via OPDS. Root cause chain: the bookhoard koreader plugin only learns
the book UUID from a successful push response, but the first push had
to match by SHA-256 alone - and that match consulted only
media_items.file_sha256, missing converted formats. When the hash
missed, no UUID was returned, so pull stayed blocked (it requires the
UUID) and the book could not sync at all.

Resolution side - route all five SHA-256 match sites through the shared
BookResolver so they are format-aware:
- resolveBookToMediaItem priority 2
- SyncBookmarks book-level lookup
- per-bookmark, per-note, and per-highlight override lookups

Exposure side - return the canonical hash so clients can learn and
cache it from a pull regardless of how the book was obtained:
- KOReaderMetadata gains sha256, populated from mediaItem.FileSha256
- KOReaderLibraryBook gains sha256, populated the same way, so the
  library list endpoint carries it for every book

Together with the plugin-side UUID bootstrap (bookhoard.koplugin),
push and pull now work in either order on any format.
2026-08-14 08:25:46 -04:00
john-okeefe 60a94df8e1 feat(sync): add shared BookResolver with format-aware SHA-256 matching
The platform had three duplicated, divergent book resolvers (koreader,
kobo, BookMatchingService) and none of them consulted
media_item_formats.file_sha256 - per-format hashes for converted files
(KEPUB, PDF) are computed and stored at import/conversion time but were
never used for lookup. GetMediaItemFormatBySHA256 existed with zero
callers. Any client holding a converted file could never match by
hash.

Add internal/services/book_resolver.go: a single shared resolution
path from client-supplied identifier to media_item.
ResolveBySHA256 checks media_items.file_sha256 first (indexed
GetMediaItemBySHA256), then falls back to media_item_formats.
file_sha256 (indexed GetMediaItemFormatBySHA256, first caller) so a
converted format matches with equal confidence. The import-time
SHA-256 is the canonical identifier shared by every interface.

Wire two of the existing resolvers through it:

- BookMatchingService.matchBySHA256: replaces the in-memory
  ListMediaItems scan of up to 1000 rows with the resolver's indexed
  lookups, and gains format awareness for the link/auto-link UI.
  MatchMethod now reports sha256_sha256 or sha256_sha256_format
- KoboHandler.mapContentIdToBookhoardUUID: the SHA-256 heuristic
  branch (ContentId that looks like a 64-char hash) now resolves
  format-aware too. Kobo's entitlement_id wire identity is untouched;
  only the opportunistic hash branch changed
2026-08-14 08:25:23 -04:00
john-okeefe 9b171a0060 fix(scanner): prevent duplicate media item imports
A read-then-write race in processMediaFile allowed the same file to be
imported twice: two concurrent scan jobs (startup scan, fsnotify dirty-
directory scan, periodic backup poll, or a manual scan each run on
separate worker goroutines with separate MediaScanner instances) could
both SELECT 'not found' and both INSERT. There was no transaction, no
row lock, no unique constraint on (library_id, file_path), and no
ON CONFLICT clause, so nothing stopped the double insert. Observed in
production as two identical 'Head First SQL' rows created in the same
second (same sha256, size, path, library).

Database enforcement:
- schema.sql: add UNIQUE(library_id, file_path) constraint, guarded so
  re-runs don't error
- schema.sql: add self-healing migration that runs on every startup -
  dedup_media_items_by_path() collapses existing path-duplicates and
  reparent_media_item_children() moves all child rows (progress,
  highlights, bookmarks, notes, collections, formats, aliases, kobo
  entitlements, etc.) onto a survivor before deleting losers, so the
  constraint applies cleanly on already-duplicated servers without
  losing reading history. Survivor picks the row with the most user
  data, ties broken by lowest id
- CreateMediaItem: upsert via ON CONFLICT (library_id, file_path) DO
  UPDATE so concurrent inserts collapse to one row and return it
- CreateMediaItemFormat: upsert via ON CONFLICT (media_item_id,
  format_type), closing the same race on format rows

Application-level guards:
- media_scanner processMediaFile: after computing the file hash, check
  GetMediaItemBySHA256AndLibrary (new query) and treat the file as
  existing when identical content is already in the library under a
  different path (content dedup, library-scoped so multi-library
  setups still work)

Ops tooling:
- scripts/dedup_media_items.sql: standalone idempotent maintenance
  script with a dry-run report (path + content duplicate groups, child
  row counts) and transactional cleanup, for servers that prefer to
  dedup manually before upgrading

Verified against the live database: the duplicate pair was collapsed
(reading_progress preserved on the survivor), schema.sql re-runs are a
no-op, and the constraint is in place with 62 unique books remaining.
2026-08-14 08:18:36 -04:00
john-okeefe b7a9b470a7 chore(deps): pin foliate-js to d4d87a9 via canonical https URL
Switch the @bookhoard/foliate-js dependency from the github: shorthand
(d164d6f) to the explicit git+https URL form (d4d87a9). The newer
revision is required by the double-page-spread support (renderer
'spread' attribute) and the explicit URL form resolves more reliably
across npm/podman builds.
2026-08-14 08:17:43 -04:00
john-okeefe f5d9578375 feat(reader): wire double-page spread setting into web reader
The double_page_spread checkbox in the reader settings panel was inert:
it had no Alpine binding, no apply logic, and no persistence. Default
was also inconsistent (false in settings-manager, absent from server
defaults).

- Add doublePageSpread state to the reader Alpine component, loaded
  from saved settings (default true)
- Add applyDoublePageSpread() which sets the renderer's 'spread'
  attribute to auto/none and persists the setting via saveSettings
- Apply the spread attribute during fixed-layout renderer init
- Bind the settings checkbox with x-model and @change
- Add double_page_spread: true to ReaderService server defaults so
  new users get the same starting value the client expects
- Also improve the PDF pan/select toolbar button: distinct smart-
  select vs pan icons, highlighted state while pan mode is active,
  and dynamic tooltips/aria-labels explaining each mode
2026-08-14 08:17:25 -04:00
john-okeefe 04e2a069d6 fix(dashboard): stop duplicating items after scan completion
Release / build-and-push (push) Successful in 5m9s
The scan-complete handler in dashboard.ts attempted to deduplicate book
cards by querying [data-media-item-id], but neither the client-side
renderBookCard nor the server-side BookCard template ever set that
attribute. As a result the dedup Set was always empty, every item from
the API response was treated as new, and all items were prepended via
insertAdjacentHTML('afterbegin', ...) on every 5-minute scan — causing
visible duplication (doubling, tripling) that only cleared on page
refresh.

Fix by replacing the fragile dedup-and-prepend logic with a per-track
full innerHTML replace. This is simpler, correctly handles items that
should be removed after a scan (the old code never removed anything),
and also removes stale sections no longer returned by the API.

Additional hardening:
- Add data-media-item-id to both renderBookCard (dashboard.ts) and the
  server-side card wrapper (dashboard.templ) so server-rendered and
  JS-rendered cards are structurally identical.
- Guard the bookhoard:scan-complete listener registration with a
  module-level boolean (scanListenerRegistered) so the handler cannot
  accumulate if Alpine ever re-inits the body subtree.
- Remove debug console.log statements from the scan handler.
2026-08-10 14:46:13 -04:00
john-okeefe 05c7431d86 feat(ui): accordion sidebar panels + wood texture preview swatches
Release / build-and-push (push) Canceled after 42s
Sidebar appearance menu improvements:

Accordion behavior:
- Lift panel open/close state to a shared 'openPanel' variable on the
  parent container so only one sidebar panel (User, Appearance, Admin,
  Sign In) can be open at a time; all can be closed.
- Admin panel still auto-opens on /admin/* pages via initial state.
- Add chevron rotation to User and Appearance panels (previously only
  Admin rotated); add a chevron to the Sign In panel for consistency.

Wood texture previews:
- Generate 48x48 WebP thumbnails (~200 bytes each) from the full-size
  PNG textures (873 KB – 1.9 MB) so the bookshelf option circles show
  the actual wood grain instead of a flat grey dot.
- Use unquoted url() in the inline style to avoid templ's double-HTML
  escaping of single quotes (SanitizeStyleAttributeValues + EscapeString
  turned url('...') into url(&#39;...) which is invalid CSS).
- 'None' keeps the flat neutral circle.

CSS resilience:
- Move the wood background-image: url() rules from the compiled
  style.css into input.css (the Tailwind source) so they survive CSS
  rebuilds instead of being silently lost.
2026-08-10 14:22:46 -04:00
john-okeefe 9a62c10803 Merge branch 'new-ui'
Release / build-and-push (push) Successful in 2m44s
# Conflicts:
#	templates/admin_library.templ
#	templates/admin_library_templ.go
#	templates/conflicts_templ.go
#	templates/unlinked_books_templ.go
2026-08-10 13:48:06 -04:00
john-okeefe 8d65e03555 fix(ui): move theme checkmark when theme changes in Appearance menu
The checkmark in the Appearance theme menu was rendered server-side
(if user.Theme == opt.Name), so it never moved after switching themes
in the browser.

- Always render the check for every theme option, hidden by default,
  using a new themeCheckClass(name, current) helper that returns the
  hidden class unless the option is the active theme.
- Give each theme button a data-theme attribute and add
  updateThemeIndicators() to web/src/theme.ts, which reads the applied
  theme from the body class (theme-<name>) and toggles the hidden class
  on each check accordingly.
- Call updateThemeIndicators() from changeTheme (before the async save
  and on failure), initializeTheme, and loadUserTheme so the menu stays
  in sync with the applied theme.
- Add unit test for themeCheckClass (templates/utils_test.go) and
  regenerate templ output.
2026-08-10 13:43:32 -04:00
john-okeefe ab465d8e0e feat(ui): adopt book-open brand icon and add SVG favicon
Bring the tighten-ui brand treatment into new-ui:

- Replace the emoji book (📚) in the sidebar header with the book-open
  icon rendered in the theme accent color, matching the tighten-ui
  header brand (templates/header.templ).
- Add web/static/favicon.svg (book-open glyph, tokyo-night accent
  #7aa2f7 stroke) and reference it from the <head> of all 27 page
  templates, so the favicon is present on login/setup/error pages too.
- Regenerate templ output for all affected templates.
2026-08-10 13:43:13 -04:00
john-okeefe 1461273162 fix(sync): wire dead token cleanup queries into daily maintenance runner
CleanupExpiredRefreshTokens and CleanupExpiredOpdsTokens were generated
by sqlc but never invoked anywhere in the codebase, so expired/revoked
tokens accumulated in the database indefinitely. The refresh-token query
was parameterized in the settings-registry work specifically so its
retention window could follow the configurable session duration, but the
periodic caller was never wired up.

annotations.go:
- Rename StartTombstonePurger to StartDailyMaintenance, which now runs
  all periodic cleanup tasks from a single 24h-tick goroutine.
- Add runDailyMaintenance helper: tombstones, then OPDS tokens, then
  refresh tokens, each logging independently so one failure never skips
  the others.
- Refresh-token retention is read from the registry (SessionDuration)
  on every tick so live admin edits are honored; guarded on the registry
  being wired so unwired test paths simply skip cleanup.
- All three queries only delete rows that are already expired or
  revoked, so active sessions are never logged out.

main.go:
- Update the call site: tombstonePurgerCancel becomes maintenanceCancel
  and calls StartDailyMaintenance.

Net footprint: still one goroutine and one ticker; the cleanup adds one
DELETE per table per day.
2026-08-10 10:43:05 -04:00
john-okeefe 598d70f735 feat(admin): editable tunable settings UI with grouped sub-sections
Replace the read-only "System Information" card (which listed hardcoded
values) with editable HTMX forms, organized so the live vs restart
distinction and related settings are visually clear.

admin_settings.templ:
- AdminSettings signature now takes liveGroups and restartGroups
  ([]SettingGroup) instead of a flat entry list.
- Remove the static System Information list. Render two cards: "Live"
  (green, applies immediately) and "Restart Required" (warning header,
  saved but only takes effect after restart).
- Within each card, TunableSettingsSection clusters entries into
  labeled sub-sections by Group (e.g. "Password Quality", "Device Rate
  Limits", "Login Lockout", "Worker Pool") with uppercase tracked
  sub-headers.
- TunableSettingRow renders an inline HTMX form per setting: a Yes/No
  select for bools, a number input with min/max for ints, text
  otherwise, posting to /admin/settings/tunable. Rows show "modified
  from default" when the value differs from the compiled default.

types.go:
- Add SettingEntry (template-local mirror of database.SettingEntry,
  keeps templates from importing database) and SettingGroup.

utils.go:
- Add GroupTunableSettings: splits a flat, group-sorted entry list into
  live and restart []SettingGroup buckets preserving source order.
  utils_test.go covers the multi-group + empty cases.

frontend.go:
- The /admin/settings page handler now loads entries from the registry,
  drops the three keys that have dedicated UI cards (default_timezone
  dropdown, scan_poll_interval_seconds, auto_scan_enabled) so they are
  not listed twice, groups the rest, and passes liveGroups/restartGroups
  into the template.
2026-08-10 08:03:02 -04:00
john-okeefe 537330e7e0 feat(app): wire settings registry into startup and admin routes
Construct the SettingsRegistry at boot, load it, and thread it through
every consumer so the configurable values take effect and stay cached.

cmd/server/main.go:
- Build the registry from the Queries handle and Load() it right after
  schema init; a load failure logs and continues (getters fall back to
  compiled defaults, so startup is never blocked).
- Wire the registry into the package-level password validator
  (SetDefaultPasswordSettings) and call SetSettings on every handler/
  service that reads tunables: AuthHandler, DeviceAuthMiddleware,
  OPDSHandler, SidecarHandler, SystemSettingsHandler,
  AnnotationService, ConversionService.
- Source the restart-time values from the registry: login lockout
  (max attempts + duration) feeds NewLoginAttemptTracker, and the new
  NewSyncQueueProcessorWithConfig / NewWorkerWithConfig take the sync
  queue and worker pool configs.

router.go:
- Config gains a Settings *database.SettingsRegistry field.
- The global auth rate limiter now reads RequestsPerMinute from
  registry.AuthRateLimit() (env stays as the enabled/disabled switch
  and as the fallback if the registry is unset).

admin_library.go:
- The HTMX scan-settings save endpoint reloads the registry after
  writing so the change is visible without a page reload.
- Add PUT /admin/settings/tunable: a small HTMX endpoint that calls
  SystemSettingsHandler.ApplySetting and returns a colored status
  snippet ("Saved" or "Saved — restart required") for the admin UI's
  per-row forms.
2026-08-10 08:02:41 -04:00
john-okeefe 885f6d8187 feat(api): unified tunable settings endpoints with typed validation
Add a single pair of admin-only endpoints that supersede the scattered
scan-settings JSON routes as the canonical way to read and write
tunable system settings. Existing legacy routes are kept working for
backward compatibility and now refresh the registry cache on write.

system_settings.go:
- GET /api/system/settings returns every known setting with full
  metadata (value, type, min, max, requires_restart, category, group,
  description, is_default) via SettingsRegistry.All().
- PUT /api/system/settings accepts {key, value}; ApplySetting() looks
  up the compiled Default for the key, runs type-aware validation
  (int range, bool parse, non-empty string, timezone via
  time.LoadLocation), upserts via UpsertSystemSetting, reloads the
  registry, and reports whether a restart is needed for the change to
  take full effect. Shared by the JSON endpoint and the HTMX endpoint.
- Legacy UpdateScanSettings / GetScanSettings / UpdateTimezoneSettings
  now reload the registry after writing and prefer the registry when
  reading, so the cache stays consistent regardless of entry point.

sidecar.go:
- SidecarHandler gains an optional registry; the timezone branch of
  UpdateSystemConfiguration (PUT /api/system/config) calls
  settings.Reload() after the write so the new value is visible
  immediately. base_url handling is unchanged.

system.go:
- Register GET/PUT /api/system/settings under the existing admin
  /api/system group.
2026-08-10 08:02:22 -04:00
john-okeefe 936a48405b refactor(background): parameterize sync queue and worker pool constructors
Split each constructor into a default-args wrapper and a config-accepting
variant so the sync queue interval/batch size and the worker pool size/
queue cap can be sourced from the settings registry at startup. These
values are constructed once at boot, so they are tagged requires_restart
in the admin UI.

queue.go:
- NewSyncQueueProcessorWithConfig(db, interval, batchSize) takes the
  flush interval and batch size as parameters; NewSyncQueueProcessor
  becomes a thin wrapper with the historical 5s / 50 defaults.

worker.go:
- NewWorkerWithConfig(numWorkers, queueCap, connManager) takes the
  queue capacity as a parameter; NewWorker becomes a thin wrapper with
  the historical cap of 100.

No behavior change for existing callers; main.go will switch to the
config-accepting variants in a follow-up wiring commit.
2026-08-10 08:02:02 -04:00
john-okeefe 757398bf15 feat(sync): make annotation tombstone TTL configurable
The 30-day retention window for soft-deleted annotations was a package
const; move it behind the registry so it can be tuned live.

annotations.go:
- AnnotationService gains an optional *database.SettingsRegistry and a
  tombstoneTTL() helper. The skip-resurrect checks and the purge cutoff
  now call it instead of reading the TombstoneTTL const directly.
- Add ActiveTombstoneTTL() so callers outside the sync package can
  compute cutoffs consistently with the service.
- The package-level TombstoneTTL const is retained as the fallback for
  tests / unwired code paths.

kobo.go, koreader.go:
- The per-book tombstone sweep cutoff now uses
  h.annotationSvc.ActiveTombstoneTTL() instead of the wsync.TombstoneTTL
  const, so both the service and the handlers honor the configured TTL.
2026-08-10 08:01:45 -04:00
john-okeefe d12911d3c8 feat(api): make device rate limits, OPDS page size, and conversion cache configurable
Move three more hardcoded values behind the settings registry. All
apply immediately on the next request (no restart needed).

device_auth.go:
- DeviceAuthMiddleware reads per-route device rate limits (sync /
  progress / metadata per minute) from the registry on each
  authenticated request via a rateLimitConfig() helper, falling back to
  the Default* constants when no registry is wired.
- The X-RateLimit-Limit response header previously hardcoded "60" for
  every request type; it now reflects the actual configured limit for
  the request type via rateLimitForRequestType().

opds.go:
- Default (50) and maximum (200) OPDS page sizes come from the
  registry's OpdsDefaultPageSize()/OpdsMaxPageSize() instead of inline
  literals, so catalog pagination can be tuned without a redeploy.

conversion_service.go:
- The 24h kepub cache lifetime is read from the registry via a
  cacheTTL() helper (was a bare 24 * time.Hour literal in the
  constructor). The field default is retained for tests that construct
  the service directly.
- conversion_service_test.go updated to assert both the field default
  and the cacheTTL() accessor return 24h.
2026-08-10 08:01:28 -04:00
john-okeefe 457a38306d feat(auth): make session duration and password rules configurable
Replace the hardcoded 7-day session lifetime and fixed password
complexity rules with registry-backed accessors so they can be tuned
from the admin UI without a code change.

auth.go:
- Drop the SessionDuration const; keep DefaultSessionDuration (7 days)
  as the fallback used when no registry is wired (e.g. in tests).
- AuthHandler gains an optional *database.SettingsRegistry and a
  sessionDuration() helper that reads the registry, falling back to
  DefaultSessionDuration.
- Cookie MaxAge, JWT exp claim, and ExpiresIn responses now derive from
  sessionDuration() instead of the package-level SessionDurationSec, so
  a settings change takes effect on the next login.

refresh_token.go:
- Refresh-token lifetime follows sessionDuration() via a new
  refreshTokenTTL() helper (was a separate refreshTokenExpiration const
  that silently had to be kept in sync with the session duration).

password_validator.go:
- PasswordValidator now reads min length and the upper/lower/number/
  special toggles from the registry at validation time, so rule
  changes apply immediately. The special-character regex is compiled
  once and reused (sync.Once).
- GetPasswordRequirements() and ValidatePassword() reflect the active
  configured rules instead of a static list.
- Add SetDefaultPasswordSettings() so the package-level default
  validator (used by echo's struct-tag validator) follows live config.

All paths degrade gracefully to the historical defaults when no
registry is wired.
2026-08-10 08:01:11 -04:00
john-okeefe bc47450653 feat(db): typed tunable system settings + SettingsRegistry
Add a typed, cached registry over the system_settings table so that
values which used to be hardcoded Go literals can be changed at runtime.

Schema (database/schema/schema.sql):
- Extend system_settings with setting_type, min_value, max_value,
  requires_restart, and category columns (all ADD COLUMN IF NOT EXISTS,
  nullable for backward compat with the original three rows).
- Seed rows for every tunable: session duration, password rules,
  login lockout, auth/device rate limits, OPDS page size, tombstone TTL,
  conversion cache TTL, sync queue interval/batch, and worker pool
  size/cap. Seed values equal the previous hardcoded literals, so
  behavior is unchanged on upgrade. ON CONFLICT DO NOTHING preserves
  any admin-modified values.

Queries (queries.sql):
- Add UpsertSystemSetting (RETURNING *) so new keys without a seed row
  can still be written through the API.
- Add GetSystemSettingFull + GetAllSystemSettingsFull returning the
  full typed row.
- Refactor CleanupExpiredRefreshTokens to take the retention window as
  a parameter (make_interval(secs => $1)) instead of the INTERVAL '7
  days' literal, so it can follow a configurable session duration.

Registry (internal/database/settings_registry.go):
- SettingsRegistry holds an in-memory cache of all known settings,
  populated by Load at startup and refreshed by Reload on writes.
- Typed domain getters (SessionDuration, PasswordRules, DeviceRateLimits,
  TombstoneTTL, OpdsPageSize, ConversionCacheTTL, SyncQueueConfig,
  WorkerPoolConfig, LoginLockout, AuthRateLimit, ...) with compiled-in
  fallback defaults and min/max clamping, so a corrupt or missing row
  can never break the app.
- SettingDefaults is the single source of truth for keys, types, bounds,
  and human descriptions; All() exposes metadata + current values for
  the admin UI/API.

The registry lives in the database package (rather than its own
internal/settings package) because a quirk in this custom go1.26.5
toolchain prevented the large handlers package from importing any
newly-created package; every consumer already imports database.

Tests: settings_registry_test.go covers default validity per type,
int clamping at both bounds, garbage-value fallback, and unknown-key
lookup.
2026-08-10 08:00:52 -04:00
john-okeefe 89ea310414 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	templates/book_detail.templ
#	templates/collection_rules.templ
#	templates/conflicts.templ
#	templates/header.templ
#	templates/progress.templ
2026-08-08 15:43:16 -04:00
john-okeefe 4b152bbe4e fix(templates): use expression attributes and fix indentation
Convert string-interpolation attributes (value="{ x }") to templ
expression attributes (value={ x }) for IDs, paths, and titles, and
fix indentation in header.templ and progress.templ.
2026-08-08 15:40:15 -04:00
john-okeefe 64ad10b3f2 feat(admin): add scan settings + system information to settings page
- Scanning section: auto-scan toggle (on/off) and poll interval input
  with HTMX form that updates system_settings table
- System Information section: surfaces all hardcoded constants
  (session duration, password policy, rate limits, worker pool,
  sync queue, tombstone TTL, OPDS page size, CORS, etc.)
- New ScanSettingsData type, ScanSettingsSection partial template
- New HTMX endpoint: PUT /admin/settings/scan
- registerAdminSettingsRoutes for scan settings HTMX CRUD
- Fix bookhoord→bookhoard import typo in admin_library.go
2026-08-07 23:37:14 -04:00
john-okeefe a42232e67a fix(admin): expandable sidebar section + library manage toggle
- Admin section in sidebar now uses same expandable panel pattern as
  Appearance and User sections (toggle button with chevron, auto-opens
  when on /admin pages)
- Library Manage button toggles open/close instead of only opening
  (uses htmx.ajax for open, clears panel for close)
2026-08-07 23:29:01 -04:00
john-okeefe 006cc0c2c9 chore(frontend): slim admin.ts, remove dead library.ts
- Remove dead functions from admin.ts: loadSystemStats, renderSystemStats,
  triggerLibraryScan, triggerQuickScan, all WebSocket functions
- Remove library.ts (695 lines of innerHTML string-building replaced by
  HTMX server-rendered partials)
- Remove library import from main.ts
2026-08-07 09:35:47 -04:00
john-okeefe 706be09dec feat(admin): redesign library management with HTMX expandable rows + stats dashboard
Library management:
- Redesign admin_library page with expandable rows that load detail
  panels via HTMX (LibraryList, LibraryPanel, FolderBrowserContent partials)
- Add create/edit/delete library modals using data-* attributes
- Add folder browser modal with inline add/remove via HTMX
- Add user visibility checkboxes toggled via HTMX
- New endpoints in admin_library.go: create, update, delete, panel,
  folders add/remove, browse, visibility
- New template types: FolderData, DirEntry, UserVisibilityData,
  AdminStats; extend LibraryData with TypeValue and FolderCount

Admin dashboard:
- Rewrite admin.templ to show 4-stat grid (libraries, media, users, devices)
- Add getAdminStats helper querying library/user/media/device counts
- Pass AdminStats to template from both /admin and /admin/ handlers
2026-08-07 09:35:39 -04:00
john-okeefe a87c8afc22 refactor(admin): remove separate sidebar, integrate admin nav into main sidebar
- Delete AdminSidebar component entirely
- Add Administration section (Dashboard, Libraries, Users, Settings) to
  header sidebar, visible only for admin users
- Remove Admin Panel link from user dropdown
- Strip admin chrome (sidebar wrapper, back buttons) from all 5 admin
  page templates
- Fix activeClass to handle trailing-slash routes correctly
- Add isUserVisible helper for library visibility toggles
- Fix processing issues page: remove dead Alpine JS, wire HTMX dismiss
  with proper mediaItemId, add issue ID swap targets
- Add processing issue resolve/delete routes to library router
- Add GetProcessingIssueStatsData context-based method
- Fix users page: remove broken hx-headers auth, simplify role select
- Fix settings page: remove dead adminSettings Alpine ref
2026-08-07 09:35:30 -04:00
john-okeefe 2976dad4e5 Merge branch 'new-ui' into main
Release / build-and-push (push) Successful in 2m45s
UI/UX redesign with modern look (Kavita/Komga/Audiobookshelf vibe):
- Dashboard read-action JS fix with conflict routing
- Reader back button with sessionStorage referrer
- Collection detail page interactions (selection, bulk remove, book picker)
- Custom confirm dialog replacing native confirm()
- System collections read-only
- Collection modal icon/color selection
- OPDS base_url fix + setup gate requires base_url
- Progress page fixes (percentage formatting, blank fields)
- Devices page reform (KOReader plugin instructions, gear icon)
- Embed time/tzdata for Alpine container compatibility

# Conflicts:
#	templates/reader_templ.go
#	web/static/style.css
2026-08-06 15:24:48 -04:00
john-okeefe acc9d08c60 fix: embed time/tzdata so time.LoadLocation works in Alpine container
Alpine doesn't ship the IANA timezone database, causing
time.LoadLocation('America/New_York') to fail with 'Invalid timezone'
for every non-UTC option in the profile settings dropdown.
2026-08-06 15:18:22 -04:00
john-okeefe e09c55cecc feat(devices): reform Add Device modal with KOReader plugin instructions
- Add gear icon (proper cog) and use it for device settings button
- Replace KOReader manual identifier input with step-by-step plugin
  setup instructions (clone repo, enter server URL, approve pending reg)
- Show server URL with copy button pre-filled from baseURL
- Kobo keeps manual registration flow (device name + identifier)
- Comment out Web Browser and Mobile App options (not implemented)
- Use Alpine x-model on device-type select to toggle between
  KOReader instructions and Kobo registration form
2026-08-06 14:48:48 -04:00
john-okeefe 6f1c13998c fix(ui): populate Last Updated and Synced From fields on progress page
GetAllProgressData (SSR handler) was missing LastUpdated, DeviceIcon,
DeviceName, DeviceType, and EpubCFI fields that the template expects.
All showed blank. Now matches the API handler's field population.
2026-08-06 14:06:47 -04:00
john-okeefe 0a0caa9533 fix(ui): round progress percentage to 2 decimals on progress page
The large percentage badge displayed the raw float value unformatted
(e.g. 1.523456789%). Now uses %.2f for a clean display (e.g. 1.52%).
2026-08-06 13:25:54 -04:00
john-okeefe 4716790564 fix: OPDS base_url placeholder bug + setup gate requires base_url
Three bugs fixed:

1. Schema seeded base_url with fake placeholder 'bookhoard.example.com'.
   Removed seed; startup now seeds from BASE_URL env var only if DB row
   is empty (admin changes persist across restarts). One-time UPDATE
   clears the placeholder in existing installs.

2. config.GetBaseURL() had a broken type assertion (local SystemConfigRow
   vs database.SystemConfig) that always failed, returning . Admin panel
   showed env var fallback instead of actual DB value. Fixed with a
   function-type getter that properly wraps the DB query.

3. OPDS handler read base_url only from DB with no fallback. When DB had
   the placeholder, all feed links pointed to an unreachable domain,
   breaking KOReader search/download. Added deriveBaseURL() helper that
   falls back to the request Host/scheme when DB value is empty.

Setup gate improvements:
- isSetupComplete now requires both admin user AND non-empty base_url
- Setup middleware no longer exempts all /api/ routes; only allows
  /api/auth/register, /api/auth/login, /api/system/config before setup
  is complete. All other API routes get 503.
- Cache invalidated when base_url is saved via admin settings

Dev workflow:
- New bruno/NewDevDBSetup/SetBaseUrl.yml for dev DB setup
- NewDB.sh runs SetBaseUrl between RegisterUser and CreateEbookLibrary
2026-08-06 13:02:35 -04:00
john-okeefe 8e2c1a4b3a fix(ui): make icon and color selection work in collection modal
Three root causes, all fixed:

1. Icon buttons were created with setAttribute('onclick', ...) in
   populateIconGrid, but selectIcon is module-scoped (not on window),
   so clicking threw ReferenceError. Switch to addEventListener with
   a closure. Icon search/focus used plain oninput/onfocus attributes
   with the same problem — convert to Alpine @input/@focus.

2. selectColor's highlight selector queried [onclick="selectColor(...)\]
2026-08-06 11:35:14 -04:00
john-okeefe 4a870a7f18 fix(ui): keep search filter visible on system collections 2026-08-06 11:22:26 -04:00
john-okeefe 40dabfd788 feat(ui): hide management controls for system collections
System collections (Not Started, Continue Reading, etc.) compute
their contents dynamically from reading_progress, so manual
add/remove has no effect. Hide the search bar, Remove Selected
button, Add Books button, per-card checkboxes, and Remove buttons
when the collection is system, making the page visually read-only.

- Add IsSystem bool to CollectionData, populated from the database
  is_system_collection flag.
- Add data-is-system to #collection-data so the JS renderer can
  also conditionally omit controls on library switch.
- Wrap toolbar controls, book picker modal, card checkboxes, and
  remove buttons in if !collection.IsSystem in the template.
2026-08-06 11:08:29 -04:00
john-okeefe ef3c05714a fix(ui): custom confirm dialog, picker visibility, remove wiring
Three fixes for the collection detail page:

1. Picker modal never showed because the outer overlay div had
   style="display:none" with no x-show binding — add x-show bound
   to $store.bookPicker.isOpen plus a backdrop and click-to-close.

2. Replace native confirm() with an in-page Alpine modal for UI
   continuity. Add confirm dialog state (showConfirm, confirmMessage,
   pendingAction) and methods (requestRemoveBook, requestBulkRemove,
   executeConfirmed, closeConfirm) to the collections component.
   The actual API calls (doRemoveBook/doBulkRemove) are triggered only
   when the user confirms.

3. Rename removeBook → requestRemoveBook and bulkRemove →
   requestBulkRemove in template + JS renderer so the dialog opens
   instead of navigating or failing silently.
2026-08-06 10:55:37 -04:00
john-okeefe 816ee0ec80 fix(ui): wire up collection detail page interactions
The /collections/:id page had several broken features because three
referenced functions (removeBook, toggleBookForRemoval,
filterCollectionBooks) were never defined, and every book card was
wrapped in <a href="/media/..."> so clicking the checkbox or remove
button navigated to the book detail page instead.

Card restructure:
- Remove the <a> wrapper; title and cover are now individual links.
- Checkbox sits in a <label> with expanded click area (p-2 -m-2).
- Checkbox uses Alpine :checked/@change bound to a reactive
  selectedBooks array on the collections component.

Remove (single + bulk):
- Add removeBook(id) and bulkRemove() methods with confirm() dialogs.
- Wire the "Remove Selected" button with :disabled binding and @click.
- Selected-count badge is now Alpine-reactive (x-show/x-text).

Search within collection:
- Add filterCollectionBooks() that filters cards client-side by
  title/author via data-* attributes and @input.

Book picker ("Add Books"):
- Point the HTMX search inputs at the existing /api/media-items/search
  endpoint instead of the non-existent /api/media-items/filtered.
- Add hx-trigger="loadBooks" + hx-get to the grid so loadBooks()
  actually fires an initial request when the picker opens.
- Merge the hidden limit/offset inputs into the #book-picker-filters
  div so hx-include picks them up (was a separate <form id=filter-form>
  that nobody referenced).
- Add show_checkbox mode to handleSearchHTML: when present, render a
  new BookPickerGrid template with clickable, selectable cards instead
  of the reader BookCard.
- Fix bookPicker submit() to location.reload() instead of a non-existent
  reloadCollection HTMX event, and clearFilters() to target text inputs.
2026-08-06 10:40:18 -04:00
john-okeefe 73a2852ee3 fix(reader): back button remembers the page you came from
The reader's back button was hardcoded to the book detail page
(/media/{id}), so even when you launched the reader straight from the
dashboard the back button ignored that and sent you to the detail view.

Mirror the existing book-detail.ts referrer pattern: capture
document.referrer into sessionStorage on load (excluding other reader
pages and the reader's own URL), then override the back link's click to
navigate there. Falls back to the link's original href (/media/{id}) when
no valid referrer exists (direct URL access).
2026-08-06 09:51:04 -04:00
john-okeefe f67232a20b fix(ui): show read action on JS-rendered dashboard cards
The dashboard re-renders its sections client-side (library switch,
refresh, saving settings) via renderBookCard in dashboard.ts, which was
still the old markup with no .book-card-action overlay. So the read
button appeared on the server-rendered cards but vanished as soon as the
dashboard re-rendered, while the bookshelf (always templ-rendered) kept
working.

- Rewrite renderBookCard to match the templ BookCard: detail link plus
  the play/read action overlay, routing to the reader or the detail page
  when the book has an active conflict.
- Add has_conflict to the BookInfo TS type and stamp it in the dashboard
  sections API (GetSections) so client-rendered cards can route correctly.
- Add pointer-events-none / group-hover:pointer-events-auto to the
  client-rendered carousel nav buttons so they no longer swallow hover
  over edge cards, matching the templ fix.
2026-08-06 08:40:06 -04:00
john-okeefe 7b465ac97e fix(ui): use a grid icon for Customize Dashboard
The settings icon is a circle with radiating spokes, which reads as a
sun/light-mode toggle rather than a dashboard control. Swap it for the
grid icon, the standard dashboard-layout affordance.
2026-08-06 08:24:54 -04:00
john-okeefe 033a012069 feat(ui): group sidebar footer menus into distinct panels
The account and appearance menus sat in one container with no separation,
so expanding one made the other hard to find. Each collapsible menu now
lives in its own subtly lifted, bordered panel (.sidebar-panel) so the
expanded items stay visually contained and the menus never blend together.
Applied to the account, appearance, and sign-in menus.
2026-08-06 08:14:10 -04:00
john-okeefe 9165c4eb3f feat(ui): use a book-open icon for the card read action
Swaps the play (triangle) icon for book-open on book cards, since the
action is to start reading rather than play media.
2026-08-06 08:01:33 -04:00
john-okeefe 9ef6c5b6ed feat(ui): split book-card play action into reader/detail routing
The play button on book cards now opens the reader directly, instead of
always going to the detail page. Cards with an active progress sync
conflict route the play button to the detail page (which hosts the
conflict dialogue and resolves before writing progress), so the user is
never silently dropped into the reader with an unresolved conflict.

Backend:
- Add HasConflict to BookInfo and stamp it via ListSyncConflictsByUser
  (MarkActiveConflicts / MarkActiveConflictsSections) on the dashboard,
  bookshelf, series, tag, and search result card builders.
- Each page issues a single conflict query regardless of card count.

BookCard:
- Restructure into a detail link (cover + meta) with the play action as a
  sibling overlay using a pointer-events split: the container passes
  clicks through to detail while only the circular button routes to the
  reader. No nested anchors.
- On touch devices (hover: none) the play button stays visible.

Fix: carousel nav buttons had opacity-0 without pointer-events-none, so
they swallowed hover/clicks over book cards on the dashboard. They are
now click-through until the carousel is hovered.
2026-08-06 07:52:20 -04:00
john-okeefe 987ece38f0 fix(reader): keep footer compact on mobile and clear text under chrome
On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.

Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.

Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.

Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
2026-08-05 21:58:42 -04:00
john-okeefe c5c2270007 fix(reader): keep footer compact on mobile and clear text under chrome
Release / build-and-push (push) Successful in 2m39s
On phones the footer's progress cell rendered the full chapter title (e.g. 'Long Chapter Name · 5 / 12') in a div with no max-width or nowrap, so the text wrapped to multiple lines and ballooned the bottom bar. Combined with viewport offsets that were computed from assumed pixel heights with ~0px margin, the book text slipped underneath the bars.

Chapter label in footer: split #progress-display into two spans (progressLabel hidden on mobile via 'hidden sm:inline', progressMain always shown) and cap it with 'truncate whitespace-nowrap max-w-[5rem] sm:max-w-none' so it can never wrap or grow the bar. Phones now show just '5 / 12'; larger screens keep 'Chapter · 5 / 12'.

Viewport offset: replace the fragile hardcoded calc() with runtime measurement. Gave the chrome bars ids (reader-topbar/reader-bottombar) and added updateViewportInsets(), which sets #reader-viewport top/bottom from each bar's real offsetHeight (which already includes env(safe-area-inset-*) padding) plus a 6px margin. It runs on init and refreshes on resize, orientationchange, and via a ResizeObserver, so the content area tracks the actual chrome height on any DPI, notch, home-indicator, or zoom level instead of guessing.

Refactored formatProgress into formatProgressParts (returns {label, main}; only chapter mode sets a label) with a setProgress() helper wiring progressLabel/progressMain/progressText across the relocate, cycleProgressMode, and applyProgressMode call sites. Rebuilt reader_templ.go and style.css.
2026-08-05 21:48:28 -04:00
john-okeefe b23f6b0bab build: regenerate stylesheet for sidebar app shell 2026-08-05 16:45:28 -04:00
john-okeefe 8ea70ea7f8 feat(ui): migrate remaining pages to the new shell
Apply the sidebar shell and bold primitives across the rest of the app so
the whole experience shares one visual language:

- Browse/organize: series (keeps stacked-covers/series-card CSS),
  collections (keeps wood-paneling + carousel classes), browse_detail.
- Account/stats: profile + form + modal, progress, analytics (stat-card
  tiles), devices, conflicts (status semantics), queue (status/priority
  badges).
- Admin: admin sidebar restyled with icons, dashboard/users/library/
  processing-issues/settings migrated to .card/.btn/.input primitives.
- Docs/setup/misc: docs (keeps prose/highlight.js), api_explorer, setup
  wizard, unlinked_books, custom_section builder.
- Modals/fragments: collection_modal, collection_rules,
  restore_system_collection_modal, filter_item, book_detail_modals — all
  overlays use --surface-overlay + --shadow-pop.

Every Alpine handler, HTMX attribute, id, name, and data-* is preserved;
only presentation changes.
2026-08-05 16:45:02 -04:00
john-okeefe 07578e0206 feat(ui): redesign dashboard, bookshelf, book detail, and auth pages
- Dashboard: page heading, bold section headers with accent icon tiles,
  carousel chevrons as SVG icons with theme-aware gradients (wood-paneling
  gradient classes preserved), and a cinematic BookCard (hover overlay with
  a quick-action button). BookCard is now fluid so it fills both the
  carousel slot and the bookshelf grid.
- Bookshelf: the filter wall becomes a search toolbar + a slide-in filter
  drawer (filtersOpen state added to the bookshelf Alpine component). Every
  filter input, the tristate cover toggle, tag autocomplete, save/load/clear
  actions, HTMX search/sort, and pagination are preserved.
- Book detail: blurred cover backdrop hero, rounded-2xl cover, bold
  typography, .btn action bar, progress/metadata cards, chip-style external
  links. All interactive rating, modals, and data-attrs preserved.
- Auth/landing: brand-gradient hero for index/login/register, icon feature
  cards, data-driven theme select (ThemeOptions). All ids (#theme-select,
  #result, #auth-result, password-requirement ids) and Alpine init preserved.
2026-08-05 16:44:52 -04:00
john-okeefe f402a3ee03 feat(ui): sidebar app shell and bold design system
Replace the top-nav with a fixed left sidebar + slim content topbar
(the Komga/Audiobookshelf layout), the signature change versus the
conservative tighten-ui branch.

- App shell: .app-sidebar (off-canvas on mobile via Alpine mobileMenuOpen,
  pinned at 16rem on lg), .app-topbar (fixed, blurred, 4rem), and
  .app-subbar for in-page sticky bars (parks under the topbar). Content is
  auto-offset via body:has(.app-sidebar) so reader.templ/error.templ (which
  have no sidebar) are untouched.
- Header rebuilt as the sidebar: logo + vertical nav (activeClass), an
  inline Appearance picker driven by ThemeOptions/WoodOptions, and an
  inline account menu / sign-in (preserving the inline htmx login). The
  search lives in the topbar so its dropdown still anchors correctly.
- Bolder primitives: .card -> rounded-2xl, cinematic .book-card-cover
  hover overlay with a quick-action affordance, .brand-gradient hero
  surface, .hero-backdrop (blurred cover) and .stat-card utilities.
2026-08-05 16:44:42 -04:00
john-okeefe f3f908eb7e feat(ui): design system foundation
Add semantic design tokens and base primitives to replace the verbose
inline var() styling that made the UI feel dated.

- Rename colliding Tailwind color tokens (bg-primary/bg-secondary) to
  semantic names (surface/surface-raised/content/content-muted/brand/line)
- Derive hover, overlay, border-strong, accent-muted and elevation tokens
  once on <body> so they adapt to every theme automatically
- Make :root mirror Tokyo Night to kill the first-paint theme flash
- Add component primitives in @layer components: .card (surface + soft
  shadow, no hard border), .btn variants, .input, .chip, .badge, .icon-btn
- Add theme-aware status/priority badges and global focus-visible styling
- Add an inline-SVG Icon() component (consistent stroke language) to
  replace the mixed emoji/SVG iconography
- Data-drive theme/wood options and add an active-nav helper
- Add skeleton shimmer + x-cloak support
2026-08-05 16:03:07 -04:00
479 changed files with 62402 additions and 22303 deletions

No files matched your search

+3
View File
@@ -20,6 +20,9 @@ logs/
# Dependencies (install fresh in container)
node_modules/
# Local-only lockfile: a stale one pins old foliate-js commits into builds
# (npm reuses name@version without re-resolving the git pin)
package-lock.json
# Environment
.env
+4
View File
@@ -35,5 +35,9 @@ DBPASS=your-secure-database-password-here
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
# Conversion Cache TTL: Override default 24h
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
# Archive Retention: days to keep archived items (files missing from disk for
# 2+ scans) before they are purged, deleting their reading history with them.
# Set 0 to keep archived items until purged manually on the library admin page.
# ARCHIVE_RETENTION_DAYS=90
# System timezone (fallback for server-side time operations, defaults to UTC)
# TZ=America/New_York
+3
View File
@@ -56,6 +56,9 @@ jobs:
context: .
file: ./Dockerfile
push: true
# Bake the version into the binary (Admin → Settings → About).
build-args: |
APP_VERSION=${{ env.TAG }}
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
# pin or roll back by setting IMAGE_TAG in .env.
+7
View File
@@ -79,3 +79,10 @@ web/static/vendor/
VALID_TOKEN
fresh_token.txt
session-*.md
# Local prod-export scratch: personal annotation/reading data, never commit
/export/
# Binary media fixtures: never commit (generate with ffmpeg at test time,
# see docs/contributing/development.md → Media fixtures)
internal/services/testdata/
+203
View File
@@ -0,0 +1,203 @@
# AGENTS.md — Bookhoard server
Standing rules for any agent or developer working in this repo.
Project-wide context lives in the sibling repos' AGENTS.md files
(../bookhoard-app/AGENTS.md is the Android client's).
## Rendering ownership (templ vs TypeScript) — ALWAYS apply
The point of templ + htmx + Alpine is: the server renders the UI, and
JavaScript is kept minimal. The rule that keeps it that way:
> **JavaScript decides what shows. templ decides what it looks like.**
- Content derived from **server data** (DB rows, search results, match
lists, conflict cards, any list/card/badge/table) is rendered by
templ. When it changes client-side, fetch a **templ partial** from an
SSR route and swap it in (`htmx.ajax(...)`), never rebuild it from a
JSON payload with `innerHTML` string templates.
- **Pure functions of client-local state** (open/closed panels, tabs,
password strength from a typed value, toasts) may render in
TS/Alpine — no server round trip needed.
- `web/src` is **TypeScript only** — no new `.js` source files.
Vendored third-party and vite build output under `web/static` are
exempt.
- Inline `<script>` in a `.templ` file needs a reason a bundled module
cannot do the job (pre-paint execution). Currently sanctioned:
`theme_flash_guard.templ` only.
Sanctioned JS islands (engines, not UI renderers — exempt from the
above): the reader (foliate-js + `web/src/reader/`), Chart.js graphs in
stats, toasts, password validation. Anything else that renders app UI
in TypeScript is a defect; if a templ twin of it exists, one of the two
must be deleted.
**Documented exception — dashboard sections**: `renderDashboardCollections`
/ `renderSectionHTML` in `web/src/dashboard.ts` re-render dashboard
sections from `/api/dashboard/sections` JSON (library switch, settings
save, refresh button) to preserve the switch fade/transition on a page
with heavy interactivity. First-paint section markup is still templ
(Dashboard handler in `internal/router/frontend.go`). This exception is
deliberate; do not clone it to new pages, and do not "fix" the dashboard
without restoring a templ-partial fade path first.
## Library visibility (family-lens rule) — ALWAYS apply
Per-user library visibility (`library_visibility` table, default-ALLOW:
every user sees every library unless an admin hides one) must be
enforced on **every endpoint that returns media metadata or content**.
It is NOT automatic — enforcement is per-handler.
- **New endpoints that list/filter media or series** MUST filter by the
requesting user's visible-library set, and MUST reject an explicit
`library_id` outside that set with 403 (never silently pass).
- Helpers (do not hand-roll):
- `handlers`: `(*MediaHandler).visibleLibrarySet` /
`(*SeriesHandler).visibleLibrarySet` — membership map + id slice.
- `router`: `visibleLibraryIDsFor` — same for SSR routes.
- `resolveLibrary` (helpers.go) only honors requested library ids
inside the visible set.
- SQL pattern: `AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR
library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))` — see
`ListMediaItems`, `GetDistinctSeries`, `SearchMediaItems`.
- Content endpoints (file bytes, page images) additionally gate per item
like `ReaderHandler.ensureItemVisible` / the download endpoint.
- Tests must pin visibility: hidden library → 403 on the endpoint, and
absent from unscoped aggregates.
History: 2026-10-13 (`431fec0`) the series/media listing surfaces
leaked hidden libraries to any authenticated account — series endpoints
were name-only across ALL libraries. Fixed; do not regress.
## Tests
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
(see the Conventions section below for the pool/cleanup rules).
- Integration tests run against the SHARED dev database: never write
destructive SQL without preserving the dev rows (libraries Manga/
Comics/Ebooks, testuser's annotations and progress are real data).
- Test-created users must use `@tests.bookhoard.internal` or
`@example.com` emails and "test"-named collections/libraries so the
harness cleanup can find them.
- **NEVER rename or edit the shared admin (`testuser`) in tests** — a
renamed admin escapes the cleanup preserve-list and cascades real
data (this wiped the dev annotations once; see user_test.go
TestUpdateProfileSelfEdit for the dedicated-user pattern).
- **`testuser` is a LIVE reader**: the dev account genuinely reads in
the web reader while the suite runs, so `reading_history` /
`reading_progress` rows accrue on it mid-test. Exact-total
assertions against that account flap (three stats/analytics tests
broke on 2026-10-14 within hours of session capture shipping).
Tests that need deterministic data use `setup.RegularToken` +
`getRegularUserID` (the fresh per-test user), or assert ranges
instead of equals. Related: **no table-wide DELETEs in test
cleanups** — sync_integration's `DELETE FROM reading_history WHERE
true` destroyed the live reader's stats data on every suite run
until scoped to the test-sync users (cascade via user delete).
- **RUN ONLY YOUR OWN TESTS while iterating**: integration tests
require the containerized dev DB — a bare host `go test
./cmd/server/tests` fails on auth on every machine not running the
compose stack. Use the filtered container runner
(`docker compose -f docker-compose.yml -f
docker-compose.dev.yml --profile tests run --rm tests go test
./cmd/server/tests -run 'TestYourPrefix' -v`) and save full-suite
runs for pre-commit validation; several tests read live shared
state (system_settings, real library folders, the periodic backup
scan's websocket broadcasts) and flap on data/timing your change
never touched. Full recipes: docs/contributing/development.md
→ Testing.
- **NEVER COMMIT BINARY MEDIA** (mp3/m4b/flac/... fixtures):
`internal/services/testdata/` is gitignored by policy; generate
media at test runtime with ffmpeg instead (test images ship it).
ffmpeg cannot write Audible freeform atoms — those paths are unit-
tested with synthetic ffprobeReport values. Recipes:
docs/contributing/development.md → Media fixtures.
## Timezones
- The server owns display conversion: clients send instants (RFC3339),
the server renders in the requesting user's timezone.
- Use `utils.FormatTimeInTimezone` / `utils.UserLocation` — never
`time.Now().Format(...)` bare (that is server-UTC).
- Analytics day-buckets must bucket in the user's timezone
(`CreatedAt.In(loc)`), and date-range end bounds must be end-of-day.
## Debug discipline (test-failure triage) — 2026-10-17
When a test or bruno request fails, suspect the TEST FIRST — most
failures in this repo are harness/schema/environment drift, not server
bugs (2026-10-17 bruno sweep: drifted body keys, bodies pre-truncated
at their first `{{var}}`, missing `/api` prefixes; host runs hitting
container-only paths/hostnames). Triage order: (1) read the
test/fixture/payload schema and diff against a known-good sibling
(bruno: the `NewDevDBSetup` files are the reference for `body.data`
form); (2) check environment assumptions (host vs container,
harness cleanup, live-DB traffic); (3) cheap probe (curl, one SQL
query) before any deep dive.
But test changes are ASYMMETRIC with code changes: server code may be
fixed freely when it is the bug; test/contract edits may only
(a) fix harness plumbing, (b) WIDEN pins additively with a
`docs/contract_pins.md` changelog entry, (c) delete tests of deleted
behavior — never narrow an assertion or flip an expectation to match
observed behavior. Contract pins were green at a known commit, so git
history arbitrates "test stale vs code changed." If a test looks right
but fails and no harness bug explains it, that is prima facie evidence
of a server bug: stop and flag the user.
## Known gotchas (do not relearn)
1. **templ does NOT interpolate `{ expr }` inside quoted attribute
values** — it emits the literal text. `x-init="f('{ col.ID }')"`
shipped a request to `/api/collections/{ collection.ID }` found by
the request logs; the whole attribute value must BE an expression:
`x-init={ "f('" + col.ID + "')" }`. (Every occurrence of this bug —
including `class="theme-{ user.Theme }"` on 22 page bodies and 26
attribute interpolations — was swept and fixed in 2026-10; when
adding attributes with dynamic values, the whole value is an
expression, and free text goes through `templates.JSONArg`.)
2. **SettingsRegistry caches**: writes must go through the settings
API (Reload on save). Direct SQL writes to system_settings leave
the cache stale until the next API-path write — never "preview"
rule changes via psql and expect them applied (2026-10-14: a live
curl test flip looked ignored until the API path was used).
3. **User collections are not dashboard sections**: nothing sets
collection show_on_dashboard for user rows, and GetDashboardSections
skips empty user sections. Collection detail (JSON + SSR) must NOT
route user rows through the dashboard-section path — a 7ce7321
regression did exactly that (static detail without library_id
returned zero books until 0a7fbd6). Only real system query types
(continue-reading etc.) route there; `query_type='filter'` is the
schema DEFAULT on every user row, not a query type.
4. **End-of-day arithmetic**: anchor the END bound to the START of the
day, never to NOW(). `now.AddDate(0,0,1) − 1ns` carries this
instant's clock time into tomorrow — a range "ending today"
resolves tomorrow's calendar date and today's rows land in
yesterday's bucket (found twice now: analytics 1601548 and stats
2026-10-14). Same trap, two deaths.
5. **Test binaries run against the LIVE dev DB**: hand-probes made in
psql during debugging (seed rows, fake sessions) will silently
pollute the next integration run — DELETE them when done (this
burned the first stats suite run: a stray 30-min probe row shipped
a phantom "185 minutes" failure).
6. **SaveProgressRequest.DeviceID is NOT always a device**: the
web/app progress paths have historically stuffed the USER id into
it (it only fed a broadcast label before stats). Anything that
dereferences it (reading_history device_id FK, device joins) must
probe DeviceExistsForUser first — stats capture does.
## Conventions (absorbed from the retired PROJECT_GUIDELINES.md —
## only the rules that still matched reality)
- **Runtime is Docker** (Makefile auto-detects docker, falls back to
podman). The old "podman only" rule is dead.
- **The server binary is only built inside the Docker build**
(`make rebuild-app`); host-side Go builds are for tests/vet only.
- **Schema changes go in place** — edit `database/schema/schema.sql`,
never create migration files, until a release splits them.
- **Business logic lives in services**, not handlers; handlers parse
requests and call services (all DB access goes through services).
- **Integration tests**: one `setupTestServer()` per test function;
test pools use `max_conns=1`; never create a `pgxpool` per subtest
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
subtests over duplicate functions.
+1 -1
View File
@@ -1065,7 +1065,7 @@ If there's a section about documentation features, mention the new search functi
## Git Commit Strategy
Follow PROJECT_GUIDELINES.md - make multiple logical commits:
Follow AGENTS.md conventions - make multiple logical commits:
1. **Backend implementation** (search.go, http_handler.go, router changes)
2. **Unit tests** (search_test.go)
+26 -4
View File
@@ -15,7 +15,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
# Copy package files and install npm dependencies (cached unless package.json changes)
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
# npm cache mount renamed from /root/.npm: the old volume held a stale
# foliate-js tarball (name@version reuse) that --no-cache and builder prune
# could not evict; a new mount id guarantees a clean fetch.
RUN --mount=type=cache,target=/root/.npm-cache2 \
npm install
# Copy Go mod files (cached unless go.mod changes)
@@ -34,22 +37,41 @@ RUN cd templates && templ generate
RUN npm run build:css:prod
RUN npm run build:ts
# App version baked into the binary (shown in Admin → Settings → About).
# Declared after `COPY . .` so a version bump only invalidates this layer.
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
ARG APP_VERSION=dev
# Build Go binary (cached unless Go files or generated code changes)
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
-o main ./cmd/server
# Materialize the Go module cache into an image layer so the test-runner
# stage can restore it. GOMODCACHE is /go/pkg/mod; cache mounts elsewhere are
# ignored by the go tool, and without this copy `go test` in the tests
# container re-downloads every dependency on each invocation.
RUN cp -r /go/pkg/mod /app/go-module-cache
# Test runner stage - includes Go runtime and test dependencies
# This stage is ONLY used for running tests, never deployed to production
FROM golang:1.26-alpine AS test-runner
RUN apk --no-cache add ca-certificates curl
# ffmpeg/ffprobe match the runtime stage: the audio ingestion (tag
# fallbacks, embedded covers, cue probing) must be exercised in tests
# exactly as it runs in production.
RUN apk --no-cache add ca-certificates curl poppler-utils ffmpeg
WORKDIR /app
# Copy everything from builder (includes compiled binary, source code, and dependencies)
COPY --from=builder /app ./
# Restore the materialized Go module cache so `go test` works offline.
RUN mkdir -p /go/pkg/mod && cp -r /app/go-module-cache/. /go/pkg/mod/ && rm -rf /app/go-module-cache
# Install kepubify for conversion tests
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
&& chmod +x /usr/bin/kepubify
@@ -65,7 +87,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
# Final stage
FROM alpine:latest
RUN apk --no-cache add ca-certificates curl
RUN apk --no-cache add ca-certificates curl poppler-utils ffmpeg
# Install kepubify for EPUB→KEPUB conversion
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
+657 -228
View File
@@ -1,232 +1,661 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for software and other kinds of works.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
“This License” refers to version 3 of the GNU General Public License.
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
A “covered work” means either the unmodified Program or a work based on the Program.
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
bookhoard
Copyright (C) 2026 john-okeefe
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
bookhoard Copyright (C) 2026 john-okeefe
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+27 -11
View File
@@ -1,4 +1,4 @@
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release
.PHONY: help test test-integration test-all rebuild-app rebuild-app-force rebuild rebuild-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down release
# Include .env file for environment variables (single source of truth)
# Ignore if .env doesn't exist yet
@@ -42,16 +42,39 @@ help:
@echo " make test-env-down - Stop test environment"
@echo ""
@echo "Verification:"
@echo " make verify-guidelines - Run comprehensive guidelines check"
@echo " make verify-quick - Run quick guidelines check"
@echo ""
@echo "Release:"
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
# Run unit tests locally (fast, no containers)
test:
test: lint-rendering
go test ./internal/... -v -short
# Rendering-ownership guard (AGENTS.md → Rendering ownership): templ renders
# UI markup, JS orchestrates. Fails when a web/src file builds more HTML
# template literals than its recorded baseline (web/src/rendering-allowlist.txt),
# or when a .templ file gains an inline <script> block (theme_flash_guard.templ
# is the only sanctioned one). Shrink baselines as fragments move to templ
# partials; grow one only with a written reason.
lint-rendering:
@violations=""; \
for f in $$(grep -rlE 'innerHTML[[:space:]]*=[[:space:]]*' web/src --include='*.ts' | grep -v 'web/src/reader/'); do \
count=$$(grep -E 'innerHTML[[:space:]]*=[[:space:]]*' $$f | grep -cvE 'innerHTML[[:space:]]*=[[:space:]]*""[[:space:]]*$$'); \
allowed=$$(grep -F "$$f:" web/src/rendering-allowlist.txt | cut -d: -f2); \
allowed=$${allowed:-0}; \
if [ $$count -gt $$allowed ]; then \
violations="$$violations $$f ($$count > $$allowed)"; \
fi; \
done; \
for f in $$(grep -rln '<script>' templates --include='*.templ'); do \
case $$f in *theme_flash_guard*) continue;; esac; \
violations="$$violations $$f (inline script)"; \
done; \
if [ -n "$$violations" ]; then \
echo "rendering-ownership violations:$$violations"; exit 1; \
fi; \
echo "rendering ownership OK"
# Run integration tests in containers (matches production environment)
test-integration:
@echo "Building test containers..."
@@ -153,14 +176,7 @@ test-env-up:
test-env-down:
$(COMPOSE) down -v
# Verify project guidelines compliance
verify-guidelines:
@echo "Running comprehensive project guidelines verification..."
@./scripts/verify-guidelines.sh
verify-quick:
@echo "Running quick project guidelines verification..."
@./scripts/verify-quick.sh
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
-480
View File
@@ -1,480 +0,0 @@
# Combined Project Guidelines for Bookhoard
## 🚨 CRITICAL PROHIBITIONS (Never violate these)
### Backend & Database
- ❌ **NEVER modify backend code when working on frontend-only tasks*
- ❌ **NEVER modify database schema** unless explicitly instructed for full-stack changes
- ❌ **NEVER use Docker** - use Podman only
- ❌ **NEVER build server binaries locally** - all builds through Dockerfile/docker-compose
- ❌ **NEVER create new migration files** - merge changes into current one until release
- ❌ **NEVER use `git checkout` on schema files** without checking what will be lost
- ❌ **NEVER break existing functionality** unless explicitly instructed
- ❌ **NEVER duplicate business logic** - keep logic in services, not handlers
- ❌ **NEVER bypass service layer** - all database operations must go through services
### Testing
- ✅ **ALWAYS use `setupTestServer()` helper from `cmd/server/tests/test_helpers_test.go`*
- ✅ **Share one test setup across all subtests** - call `setupTestServer()` once at test function level, not per subtest
- ✅ **Prefer table-driven tests** - use `t.Run()` with test cases instead of duplicate test functions
- ✅ **Configure database pools efficiently** - use `max_conns=1` for test pools (via `pgxpool.ParseConfig()`) to prevent connection exhaustion
- ❌ **NEVER create separate `pgxpool` per test** - each pool creates 4 connections by default; 78 tests = 312 potential connections > PostgreSQL's 100 limit
- ❌ **NEVER call `setupTestServer()` in loops or within subtests** - creates unnecessary database pools and exhausts connections
- ✅ **DO verify tests pass** - run full test suite before completing work
- ✅ **Use `t.Cleanup()` properly** - the `TestServerSetup` pattern automatically handles cleanup via `t.Cleanup()`
### Frontend & Styling
- ❌ **NEVER modify backend/API for frontend features without user confirmation*
- ❌ **NEVER use custom CSS** - TailwindCSS classes only
- **⚠️ EXCEPTION**: `templates/error.templ` may have inline CSS because error pages must work when main app fails (404, server errors, CSS fails to load)
- ❌ **NEVER use JavaScript** - convert all to TypeScript
- **⚠️ EXCEPTION**: Inline JS function calls in HTML attributes (e.g., `onclick="myFunction()"`) are acceptable for simple interactions
- ❌ **NEVER use Object-Oriented Programming** (no classes, inheritance, or this-capture)
- ✅ **DO use procedural/imperative style** as your default
- ✅ **DO borrow functional techniques** when they simplify code
- ✅ **DO avoid ideological purity** - the best paradigm is the one that fits the problem
- ❌ **NEVER add new Dockerfiles without user confirmation*
- ❌ **NEVER fetch initial data via AJAX on page load** - use server-side rendering instead
- ❌ **NEVER break progressive enhancement** - pages must work without JavaScript
**Note:** Go methods in the backend are fine and encouraged. This guideline applies to TypeScript/JavaScript frontend code only.
### General
- ❌ **NEVER skip pre-commit hooks** unless explicitly requested
- ❌ **NEVER force push to main/master** branches
- ❌ **NEVER commit files with secrets** (.env, credentials.json, etc.)
- ❌ **NEVER make assumptions** - ask clarifying questions when uncertain
- ❌ **NEVER delete code without reading full context first** (minimum 20 lines before/after)
- ❌ **NEVER make cascading fix-up edits without git diff review*
- After compilation error: STOP, review `git diff`, understand full impact
- Use revert/reapply pattern instead of blind fixes
- ❌ **NEVER skip post-edit verification** - must compile after each file edit
- ❌ **NEVER run git commands concurrently** - always run sequentially:
- Run `git add <files>` and wait for completion
- Run `git commit -m "<message>"` and wait for completion
- Run `git push` and wait for completion
- Never use `&&` to chain git commands together
- ✅ **Make good organized git commits for the entire project (not just what you changed) and push - run git add, commit, push sequentially as separate commands, no need to repeatedly check status*
- ✅ **Indentation is always 2 spaces unless the language prohibits it*
### Cascading Fix-up Pattern (PROHIBITED)
**WHAT NOT TO DO** - This caused critical bugs:
```go
// ❌ WRONG: Blindly making fixes after compilation error
Edit 1: Delete deprecated code
[Compilation error: undefined Register]
Edit 2: Try to fix error (over-broad deletion)
[More errors: undefined Login, GetProfile, etc.]
Edit 3: Try to fix again (worse damage)
[Even more errors: major functionality broken]
```
**CORRECT APPROACH**:
```go
// ✅ CORRECT: Stop, understand, then fix deliberately
Edit 1: Delete deprecated code
[Compilation error: undefined Register]
STOP → Review git diff → Understand Register was accidentally deleted
RESTORE → Get exact Register function from git history
VERIFY → Compile successfully
```
**Key Principle**: When compilation errors occur after edits:
1. STOP - Don't make more edits
2. ANALYZE - Use `git diff` to understand what was changed
3. RECOVER - Restore what was accidentally deleted/broken
4. VERIFY - Compile and test before proceeding
---
## 🎯 CONTEXT-SPECIFIC RULES
### When Working on Frontend-Only Tasks
- **DO NOT touch backend code** - handlers, services, database layer
- **DO NOT modify API routes** - use existing endpoints only
- **DO NOT change database schema** - work with existing structure
- **If backend change seems necessary**:
1. Identify the required change
2. Explain why you need it
3. Provide impact analysis
4. **ASK FOR USER CONFIRMATION before proceeding*
### When Working on Full-Stack Tasks
- Backend changes are allowed when explicitly part of the task
- Still follow all database protocols (atomic changes, validation, etc.)
- **If modifying database schema:** Update local database after schema.sql changes (see Database Operations section)
- Still use Podman for all builds
- Still include Bruno requests for API changes
---
## ✅ MANDATORY REQUIREMENTS
### Database Operations (Full-Stack Tasks Only)
- ✅ Follow **pgx v5 standards** for all database operations
- ✅ Treat schema changes as **ATOMIC** - complete success or complete rejection
- ✅ **⚠️ CRITICAL: This is a pre-production application (NO production deployments exist)*
- When `database/schema/schema.sql` is updated, local databases must be updated
- **Option 1 (Recommended):** Recreate database with fresh schema:
```bash
podman compose down -v # Delete volumes (WARNING: loses all data)
podman compose up -d # Start fresh with new schema
```
- **Option 2:** Manually apply schema changes to existing database using psql
- **DO NOT create migration files** - no legacy schema support needed
- ✅ Use **pre-change checklist**: read schema → identify columns → plan changes → verify → read back
- ✅ **Post-change validation**: ensure schema.sql, models.go, and queries.sql are in sync
### Build & Deployment
- ✅ Use **Podman** exclusively (not Docker)
- ✅ All builds through existing **Dockerfile** and **docker-compose.yml*
- ✅ Stop building server binaries - everything goes through containers
### API Changes (Full-Stack Tasks Only)
- ✅ Include **Bruno OpenCollection YAML requests** with all API documentation
- ✅ Integration Tests (cmd/server/tests) must be **comprehensive and cover three contexts**: no user, user, and admin
- ✅ Maintain backward compatibility for mobile apps and external consumers
### Frontend & Styling
- ✅ Always use **TailwindCSS classes** for all styling
- ✅ Convert all JavaScript to **TypeScript**
- ✅ **Never use Object-Oriented Programming** (no classes, inheritance, or this-capture)
- ✅ **Use procedural/imperative style** as your default
- ✅ **Borrow functional techniques** when they simplify code
- ✅ **Avoid ideological purity** - the best paradigm is the one that fits the problem
- ✅ **Extract domain concepts/types only when clearly beneficial** - apply YAGNI, avoid over-engineering
- ✅ **Render initial data server-side** in Go templates for fast page loads
- ✅ **Use JavaScript/HTMX for CRUD operations** (create, update, delete)
- ✅ **Ensure progressive enhancement** - pages work without JavaScript
### Service Layer Architecture
- ✅ **All business logic in services** - never in handlers
- ✅ **Services must be reusable** by both SSR handlers and API endpoints
- ✅ **Database operations through services only** - never direct from handlers
- ✅ **When adding features**: Add service logic → Create API endpoint → Use SSR for initial render → Use JS for updates
### Code Organization
- ✅ Minimize project structure changes
- ✅ Place new files in **contextually appropriate directories*
- ✅ Follow **KISS**, **DRY**, and **YAGNI** principles
- ✅ Use **multiple, logical git commits** with clear messages
- ❌ **NEVER duplicate types between handlers and templates** - define data types in handlers, reuse directly in templates
- ✅ **DO share handler types with templates** - templates should use handlers.CollectionData, handlers.BookInfo, etc. directly
- ❌ **NEVER create parallel type systems** - no templates.XxxData types for data that originates from handlers
- ✅ **DO enhance handler types with template-specific fields** when needed (e.g., add Icon, FormattedDate fields to handlers structs)
- ✅ **DO keep template-only types in templates package** - PageData, UnsafeHTML, and template-specific utilities are appropriate
- ❌ **NEVER create conversion helper functions** to map between handler and template types - use handler types directly
### Configuration & Environment
- ✅ If **.env is missing**, auto-generate secure values
- ✅ Never commit secrets to repository
### Code Modification Safety
- ✅ **Post-Edit Verification (MANDATORY for ALL file modifications)**:
- Run `go build` for affected packages immediately after each edit
- Review `git diff filename` to verify only intended changes
- Validate functionality still works as expected
- Never proceed to next file until current edit is verified
- ✅ **Backup Before Large Changes**:
- Create a stash: `git stash push -m "Pre-cleanup snapshot"` before removing >50 lines
- Or create a backup branch: `git branch backup-before-cleanup`
- This allows instant recovery if mistakes occur
- ✅ **Large Deletion Safety Pattern**:
- Read at least 20 lines before/after deletion target
- Include unique identifiers in match (function signatures, specific comments)
- Use narrow matches - avoid generic patterns
- Verify line numbers match intended section
### Documentation
**Documentation Structure** (updated with full docs system):
- ✅ **README.md** - Project overview, quick start, and setup instructions only
- ✅ **docs/** - Comprehensive documentation system with search
- ✅ **docs/developer/api/** - API reference documentation (split by endpoint/category)
- ✅ **docs/user/** - User-facing features, guides, and workflows
- ✅ **docs/user/devices/** - Device setup guides (KOBO, KOReader, etc.)
- ✅ **docs/contributing/** - Development and contribution guides
**Where to document changes**:
| Change Type | Location | Examples |
| ----------------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- |
| **User-facing features** | `docs/user/` | New features, UI changes, workflows |
| **API endpoints** | `docs/developer/api/<category>/<endpoint>.md` | New endpoints, modified responses, authentication changes |
| **API behavior** | Update existing `docs/developer/api/` files | Parameter changes, error codes, rate limits |
| **Device setup** | `docs/user/devices/` | New device support, setup instructions |
| **Development** | `docs/contributing/` | Build changes, architecture decisions |
| **Quick start/setup** | `README.md` | Installation, environment setup, first-run |
| **Breaking changes** | Both `README.md` and relevant `docs/` | Migration guides, deprecation notices |
| **Bug fixes** | Update relevant `docs/` only if user-visible | Clarifications, troubleshooting additions |
| **Bruno OpenCollection YAML requests** | `.yml` files in bruno folder in appropriate folder/sub-folder | API contract testing, examples |
**Documentation Update Workflow**:
1. **Identify the audience** (end users, developers, API consumers)
2. **Choose appropriate location** based on table above
3. **Update documentation** before or with code changes
4. **Verify documentation renders** at `/docs` endpoint
5. **Test search** finds new/updated content
6. **For API changes**: Update both `docs/developer/api/` files, integration test files (cmd/server/tests) AND Bruno OpenCollection YAML `.yml` files
7. **Commit separately** with clear message: `docs: <description>`
**When in doubt**:
- End-user visible → `docs/user/`
- API reference → `docs/developer/api/`
- Setup/onboarding → `README.md`
- Development related → `docs/contributing/`
### Process & Continuity
- ✅ If mid-task and receive "no response", **continue the task*
- ✅ Verify no regressions before modifying/removing code
---
## 🔧 TECHNICAL STANDARDS
### Backend Stack
- **Language**: Go 1.25+
- **Database**: PostgreSQL 15+ with **pgx v5 driver** only
- **Authentication**: JWT tokens with bcrypt password hashing
- **Architecture**: Service layer pattern (handlers → services → database)
### Frontend Stack
- **Rendering**: SSR-first - Go templates render initial page with all data
- **Interactivity**: Alpine.js for UI state (modals, dropdowns, transitions)
- **Dynamic Updates**: HTMX for CRUD operations (no full page reloads)
- **Styling**: TailwindCSS (no custom CSS)
- **Language**: TypeScript (no JavaScript)
- **Patterns**: Procedural/imperative with functional techniques where helpful (no OOP)
### SSR-First Principles
- ❌ **NEVER fetch initial data via AJAX** - server renders complete page
- ❌ **NEVER fetch data in Alpine x-init** - data already SSR'd
- ✅ **Use Alpine.js only for UI state** - modal visibility, dropdown toggles
- ✅ **Use HTMX for dynamic operations** - form submissions, partial page updates
- ✅ **x-init is for setup only** - event listeners, not data fetching
- ✅ **Data fetching happens after user actions** - not on page load
### Page Type Classifications
1. **Type 1: 80% SSR (most pages)**
- Backend provides all initial data
- Alpine handles modals/dropdowns only
- x-init NEVER fetches data
2. **Type 2: SSR + Interactive (dashboard, bookshelf)**
- Backend provides initial data
- Alpine handles interactivity (drag-drop, filtering)
- x-init ONLY sets up event listeners
3. **Type 3: 80% TypeScript (analytics, complex dashboards)**
- Some client-side data fetching acceptable
- Still prefer SSR when possible
### Containerization
- **Runtime**: Podman (not Docker)
- **Build**: Existing Dockerfile and docker-compose.yml only
- **No local builds** allowed
---
## 🔄 ERROR RECOVERY PROTOCOL
When code modification mistakes occur (deleted wrong code, broke compilation, etc.):
### Immediate Actions
1. **STOP** - Don't make more edits
2. **ASSESS** - What was deleted? Is it critical?
3. **REVIEW** - Run `git diff` to see exact changes
4. **RESTORE** - Choose appropriate recovery method:
- **Recent mistake**: `git checkout -- filename`
- **Complex restoration**: Use `git show HEAD:filename` to recover deleted code
- **Multiple files**: `git reset HEAD~1` (if safe)
5. **VERIFY** - Compile and test restored code
6. **DOCUMENT** - Note what went wrong for future reference
### Recovery Examples
```bash
# Recover a deleted function from original file
git show HEAD:internal/handlers/auth.go | sed -n '70,275p' > recovery.txt
# Revert entire file to original state
git checkout HEAD -- internal/handlers/auth.go
# Use git stash to save current state before rollback
git stash push -m "Broken state before fix"
git checkout -- internal/handlers/auth.go
```
### Prevention (Learn From Mistakes)
- Why did the mistake happen?
- Was it too-broad matching?
- Was it insufficient context reading?
- Was it cascading fix-up attempts?
- Update guidelines to prevent recurrence
---
## 📋 WORKFLOW CHECKLISTS
### Before Making Frontend-Only Changes
- [ ] Identify if backend modification could make implementation simpler
- [ ] Plan to use existing API endpoints only
- [ ] If backend change seems necessary, prepare confirmation request:
- Required change description
- Why it would help
- Impact analysis
- Alternative approaches considered
- [ ] Plan git commit structure (multiple logical commits)
- [ ] **Identify documentation location** (see Documentation section):
- [ ] User-facing feature → `docs/user/`
- [ ] UI/workflow changes → `docs/user/`
- [ ] Setup instructions → `README.md`
### Before Making Full-Stack Changes
- [ ] Read current schema completely (if database changes)
- [ ] Identify all columns that must be preserved
- [ ] Plan exact changes needed
- [ ] Verify Podman will be used for builds
- [ ] Plan git commit structure (multiple logical commits)
- [ ] **Identify documentation location**:
- [ ] API changes → `docs/developer/api/<category>/`
- [ ] New endpoints → Create new `.md` file in `docs/developer/api/`
- [ ] API behavior → Update existing `docs/developer/api/` files
- [ ] Breaking changes → Both `README.md` + relevant `docs/`
- [ ] Bruno OpenCollection YAML `.yml` files → Update/create alongside API changes
- [ ] Integration tests (cmd/server/tests) → Update/create alongside API changes
### During Schema Changes (Full-Stack Only)
- [ ] Read current schema completely
- [ ] Identify all columns that must be preserved
- [ ] Plan exact changes needed
- [ ] Set up verification step
- [ ] Make intended changes
- [ ] Immediately verify by reading back modified sections
- [ ] Confirm ALL expected columns are present
- [ ] Verify schema.sql, models.go, and queries.sql are in sync
- [ ] **Update local database** (choose ONE):
- [ ] **Option 1 - Recreate database** (recommended, loses data):
```bash
podman compose down -v # Delete all volumes
podman compose up -d # Start with fresh schema
```
- [ ] **Option 2 - Manual SQL migration** (preserves data):
```bash
podman exec bookhoard_db psql -U postgres -d bookhoard -c "YOUR SQL HERE"
```
- [ ] Verify database has new schema (check column types, indexes, etc.)
### After API Changes
- [ ] Create/update Bruno OpenCollection YAML requests
- [ ] Create/update Integration tests (cmd/server/tests)
- [ ] Test with no user context
- [ ] Test with regular user context
- [ ] Test with admin context
- [ ] Verify backward compatibility
### Before Committing
- [ ] **Run verification script**: `bash scripts/verify-guidelines.sh`
- [ ] **Fix any errors** - verification must pass (0 errors) to commit
- [ ] **Note warnings** - informational only, do not auto-fix
- [ ] Run tests: `go test ./... -v`
- [ ] Run lint/typecheck if available
- [ ] Ensure no secrets in changes
- [ ] Verify logical commit structure
- [ ] **Update documentation** (see Documentation section):
- [ ] User-facing changes → `docs/user/`
- [ ] API changes → `docs/developer/api/` + Integration tests + Bruno OpenCollection YAML `.yml` files
- [ ] Setup/onboarding → `README.md`
- [ ] Development changes → `docs/contributing/`
- [ ] **Verify docs render** at `/docs` endpoint
- [ ] **Test docs search** finds new content
### Error Recovery Protocol (If Code Mistakes Occur)
- [ ] **Stop immediately** - don't make more edits
- [ ] **Assess impact**: What was deleted? Is it critical?
- [ ] **Review git diff**: See exact changes made
- [ ] **Restore strategy**:
- If recent mistake: `git checkout -- filename`
- If complex: Reconstruct from git diff using `git show HEAD:filename`
- [ ] **Verify recovery**: Compile and test restored code
- [ ] **Document mistake**: Note what went wrong for future reference
### Phase Completion Verification (Before Declaring "Complete")
- [ ] All target code is removed/intact as intended
- [ ] No unintended code was deleted
- [ ] All affected files compile successfully
- [ ] Run `go build ./...` for entire project
- [ ] **Run verification script**: `bash scripts/verify-guidelines.sh` - must pass (0 errors)
- [ ] No critical functionality was broken
- [ ] Git diff shows only intended changes
- [ ] Review all modified files with `git diff --stat`
---
## 🏗 ARCHITECTURAL PATTERNS
### Current: SSR-First with Alpine.js + HTMX
```
Browser → Go template (SSR with all data) → Display instantly
↓
Alpine.js for UI state (modals, dropdowns)
↓
HTMX for CRUD (forms, updates)
↓
Shared service layer (Go)
```
**Layer Responsibilities:**
| Layer | Responsibility |
|-------|---------------|
| **Go Template** | SSR initial page with real data |
| **Alpine.js** | UI state only (x-data, x-show, transitions) |
| **HTMX** | Dynamic updates without page reload |
| **TypeScript** | Pure business logic (API calls, data processing) |
**See also:** `SSR_FIRST_ALPINE_GUIDE.md` and `ALPINE_COMPLETION_GUIDE.md`
Ultimately, whenever you are unsure just ask for confirmation.
+43 -16
View File
@@ -1,10 +1,10 @@
# 📚 Bookhoard
# <img src="web/static/favicon.svg" width="32" alt="Bookhoard logo"> Bookhoard
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring **universal cross-device sync**, beautiful dark themes, and comprehensive media management.
## ✨ Why Bookhoard?
**🔄 Universal Sync**: Your reading progress, highlights, and notes sync automatically across all your devices - KOReader, Kobo, web, and mobile.
**🔄 Universal Sync**: Your reading position, bookmarks, highlights, and notes sync automatically between KOReader and the web - with native Kobo sync and mobile apps coming later.
**📱 Multi-Library**: Organize your ebooks, comics, and manga with per-library folders and smart collections.
@@ -52,13 +52,13 @@ The first user to register automatically becomes an admin.
### Universal Cross-Platform Sync
- **Real-Time Progress**: Turn a page on your Kindle, see it on your phone
- **Real-Time Progress**: Turn a page on your e-reader, see it in your browser
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
- **Offline Queue**: Changes sync when you reconnect, priority-processed
- **Conflict Resolution**: Smart handling when same book read on multiple devices
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
- **Format Conversion**: On-the-fly EPUB→KEPUB for Kobo devices
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
### Media Management
@@ -74,7 +74,7 @@ The first user to register automatically becomes an admin.
### Smart Collections
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
- **Device Shelf Mappings**: Sync collections to Kobo shelves and KOReader categories
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
- **Test Before Creating**: Preview which books match your rules
### Library Organization
@@ -102,8 +102,8 @@ The first user to register automatically becomes an admin.
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration (coming soon)
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
@@ -111,18 +111,45 @@ The first user to register automatically becomes an admin.
### For Developers
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
- **[docs/contributing/DEVELOPMENT.md](docs/contributing/DEVELOPMENT.md)** - Development workflow
- **[docs/developer/android-app.md](docs/developer/android-app.md)** - Android app design & roadmap
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
---
## 🎯 Supported Devices
| Platform | Sync | OPDS | Status |
| ---------------- | ---- | ---- | ------------------------ |
| **Web Browser** | ✅ | ✅ | Full support |
| **KOReader** | ✅ | ✅ | Kindle, Kobo, PocketBook |
| **Kobo Devices** | ✅ | ✅ | Clara, Libra, Sage, etc. |
| **Mobile Apps** | 🚧 | 🚧 | Coming Q2 2026 |
| Platform | Sync | OPDS | Status |
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
| **Web Browser** | ✅ | ✅ | Full support |
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
| **Mobile Apps** | 🚧 | 🚧 | Native Android app in design ([docs](docs/developer/android-app.md)); iOS later |
---
## 💾 Network Storage (NAS / NFS / SMB)
Libraries on network mounts are supported for everyday use — serving,
reading, progress, and annotation sync all work over the mount — with a
few behaviors worth knowing:
- **Outage hysteresis.** When the mount drops, files miss one scan
(marked missing, hidden) then a second (archived, still recoverable).
When the mount returns — by path or by identical content elsewhere —
everything is restored automatically: annotations, progress, and
covers included.
- **Retention purge.** Archived items older than `ARCHIVE_RETENTION_DAYS`
(default **90**, `0` = never auto-purge) are deleted for good by
library scans, annotations included. A NAS offline longer than the
retention window loses those books' metadata on return.
- **Deleting while the storage is down is refused.** Deleting a book
cascades its annotations, so deletes are rejected with a clear error
while the item's library storage is unreachable. Archive the item
instead, or retry when the mount is back.
- **Watch mode does not fire on network mounts** (inotify doesn't work
over NFS/SMB). The periodic scan is the change detector.
- **Latency** — folder reachability checks are cached ~30s; large
libraries on slow mounts will still feel slower than local disk.
---
@@ -155,20 +182,20 @@ bruno run
## 📊 Project Status
**Version**: 1.0
**License**: GPL-3.0
**License**: AGPL-3.0
**Status**: Production-ready ✅
---
## 🤝 Contributing
We welcome contributions! Please see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for guidelines.
We welcome contributions! Please see [docs/developer/development.md](docs/developer/development.md) for guidelines.
---
## 📄 License
GPL-3.0 - See [LICENSE](LICENSE) file for details.
AGPL-3.0 - See [LICENSE](LICENSE) file for details.
---
+1 -1
View File
@@ -1,3 +1,3 @@
#!/bin/bash
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
+37
View File
@@ -0,0 +1,37 @@
info:
name: SetBaseUrl
type: http
seq: 3
http:
method: PUT
url: '{{base_url}}/api/system/config'
auth: inherit
body:
type: json
data: "{\n \"base_url\": \"http://localhost:8765\"\n}"
headers:
- key: Authorization
value: Bearer {{token}}
- key: Content-Type
value: application/json
settings:
encodeUrl: true
timeout: 0
followRedirects: true
maxRedirects: 5
docs: '## Set Base URL
Configures the server''s base_url during initial dev database setup.
Must be run after RegisterUser (which provides the auth token) and before
any library/device creation (which require setup to be complete).
**Method:** PUT
**Endpoint:** /api/system/config
**Auth:** Bearer token (from RegisterUser)'
-39
View File
@@ -1,39 +0,0 @@
info:
name: Get Admin Profile
type: http
seq: 4
http:
method: GET
url: '{{base_url}}/admin/profile'
auth: inherit
body:
type: none
docs: |-
## Get Admin Profile
Retrieves the admin profile information for administrative access.
**Method:** GET
**Endpoint:** /admin/profile
**Authentication:** Required (Bearer token)
**Response:**
- JSON object containing admin profile details
- `id` (string): Admin user ID
- `email` (string): Admin email
- `username` (string): Admin username
- `theme` (string): Theme preference
- `first_name` (string): First name
- `last_name` (string): Last name
- `is_admin` (boolean): Admin status
- `created_at` (string): Account creation timestamp
- `updated_at` (string): Last update timestamp
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 403: Forbidden (non-admin users)
- 404: Profile not found
+2 -37
View File
@@ -8,43 +8,8 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"media_item_ids\": [\n \"{{bookId1"
data: "{\n \"media_item_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
docs: |-
## Bulk Delete Media Items
Deletes multiple media items in a single request.
**Method:** POST
**Endpoint:** /api/media-items/bulk-delete
**Authentication:** Required (Bearer token)
**Request Body:**
- `media_item_ids` (array of strings): Array of media item UUIDs to delete
**Response:**
- `results` (array): Results for each deletion attempt
- `total` (number): Total number of media items processed
- `deleted` (number): Number of successfully deleted media items
- `failed` (number): Number of failed deletions
**Status Codes:**
- 200: Success (with partial results if some failed)
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 500: Internal server error
**Example:**
```json
{
"media_item_ids": [
"uuid-1",
"uuid-2",
"uuid-3"
]
docs: "## Bulk Delete Media Items\n\n Deletes multiple media items in a single request.\n\n **Method:** POST\n\n **Endpoint:** /api/media-items/bulk-delete\n\n **Authentication:** Required (Bearer token)\n\n **Request Body:**\n - `media_item_ids` (array of strings): Array of media item UUIDs to delete\n\n **Response:**\n - `results` (array): Results for each deletion attempt\n - `total` (number): Total number of media items processed\n - `deleted` (number): Number of successfully deleted media items\n - `failed` (number): Number of failed deletions\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"media_item_ids\": [\n \"uuid-1\",\n \"uuid-2\",\n \"uuid-3\"\n ]"
+3 -42
View File
@@ -8,48 +8,9 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"media_item_updates\": [\n {\n \"media_item_id\"\
: \"{{bookId1"
data: "{\n \"media_item_updates\": [\n {\n \"media_item_id\": \"{{bookId1"
headers:
- key: Content-Type
value: application/json
docs: |-
## Bulk Update Media Items
Updates multiple media items in a single request with different fields for each item.
**Method:** POST
**Endpoint:** /api/media-items/bulk-update
**Authentication:** Required (Bearer token)
**Request Body:**
- `media_item_updates` (array): Array of update objects
- `media_item_id` (string): Media item UUID to update
- `updates` (object): Fields to update (can include title, author, genre, tags, contributors, etc.)
**Response:**
- `results` (array): Results for each update attempt
- `total` (number): Total number of media items processed
- `updated` (number): Number of successfully updated media items
- `failed` (number): Number of failed updates
**Status Codes:**
- 200: Success (with partial results if some failed)
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 500: Internal server error
**Example:**
```json
{
"media_item_updates": [
{
"media_item_id": "uuid-1",
"updates": {
"title": "New Title",
"genre": "Fiction",
"tags": ["fiction", "adventure"]
docs: "## Bulk Update Media Items\n\n Updates multiple media items in a single request with different fields for each item.\n\n **Method:** POST\n\n **Endpoint:** /api/media-items/bulk-update\n\n **Authentication:** Required (Bearer token)\n\n **Request Body:**\n - `media_item_updates` (array): Array of update objects\n - `media_item_id` (string): Media item UUID to update\n - `updates` (object): Fields to update (can include title, author, genre, tags, contributors, etc.)\n\n **Response:**\n - `results` (array): Results for each update attempt\n - `total` (number): Total number of media items processed\n - `updated` (number): Number of successfully updated media items\n - `failed` (number): Number of failed updates\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"media_item_updates\": [\n {\n\
\ \"media_item_id\": \"uuid-1\",\n \"updates\": {\n \"title\": \"New Title\",\n \"genre\": \"Fiction\",\n \"tags\": [\"fiction\", \"adventure\"]"
+2 -28
View File
@@ -8,31 +8,5 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"book_ids\": [\n \"{{book_id_1}}"
docs: |-
## Add Books to Collection
Adds multiple books to an existing collection.
**Method:** POST
**Endpoint:** /api/collections/{collection_id}/books
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `collection_id` (string): Collection ID
**Request Body:**
- `book_ids` (array of strings, required): List of book IDs to add
**Response:**
- `message` (string): Success message
- `added_count` (integer): Number of books added
**Status Codes:**
- 200: Success
- 400: Invalid request
- 401: Unauthorized
- 404: Collection not found
data: "{\n \"book_ids\": [\n \"{{book_id_1}}"
docs: "## Add Books to Collection\n\n Adds multiple books to an existing collection.\n \n **Method:** POST\n \n **Endpoint:** /api/collections/{collection_id}/books\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `collection_id` (string): Collection ID\n \n **Request Body:**\n - `book_ids` (array of strings, required): List of book IDs to add\n \n **Response:**\n - `message` (string): Success message\n - `added_count` (integer): Number of books added\n \n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Collection not found"
+3 -37
View File
@@ -8,40 +8,6 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"name\": \"Science Fiction\",\n \"description\": \"My favorite\
\ sci-fi books\",\n \"color\": \"#ff0000\",\n \"icon\": \"\U0001F680\"\
,\n \"auto_assign_rules\": [\n {\n \"id\": \"rule-1\",\n \
\ \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\"\
: \"Science Fiction\",\n \"priority\": 8"
docs: |-
## Create Collection
Creates a new collection for organizing books.
**Method:** POST
**Endpoint:** /api/collections
**Authentication:** Required (Bearer token)
**Request Body:**
- `name` (string, required): Collection name (max 100 chars)
- `description` (string, optional): Collection description (max 500 chars)
- `color` (string, optional): Hex color code (default #3498db)
- `icon` (string, optional): Emoji icon (default 📚)
- `auto_assign_rules` (array, optional): Auto-assignment rules
- `id` (string): Rule ID
- `field` (string): Field to match (genre, author, series, etc.)
- `operator` (string): Comparison operator (equals, contains, startsWith)
- `value` (string): Value to match
- `priority` (integer): Rule priority (1-10)
**Response:**
- Collection object with generated ID
**Status Codes:**
- 201: Created
- 400: Invalid request
- 401: Unauthorized
- 409: Collection name already exists
data: "{\n \"name\": \"Science Fiction\",\n \"description\": \"My favorite sci-fi books\",\n \"color\": \"#ff0000\",\n \"icon\": \"\U0001F680\",\n \"auto_assign_rules\": [\n {\n \"id\": \"rule-1\",\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\",\n \"priority\": 8"
docs: "## Create Collection\n\n Creates a new collection for organizing books.\n \n **Method:** POST\n \n **Endpoint:** /api/collections\n \n **Authentication:** Required (Bearer token)\n \n **Request Body:**\n - `name` (string, required): Collection name (max 100 chars)\n - `description` (string, optional): Collection description (max 500 chars)\n - `color` (string, optional): Hex color code (default #3498db)\n - `icon` (string, optional): Emoji icon (default \U0001F4DA)\n - `auto_assign_rules` (array, optional): Auto-assignment rules\n - `id` (string): Rule ID\n - `field` (string): Field to match (genre, author, series, etc.)\n - `operator` (string): Comparison operator (equals, contains, startsWith)\n - `value` (string): Value to match\n - `priority` (integer): Rule priority (1-10)\n \n **Response:**\n - Collection object with generated ID\n \n **Status Codes:**\n - 201: Created\n - 400: Invalid request\n - 401: Unauthorized\n - 409: Collection name\
\ already exists"
+2 -29
View File
@@ -8,32 +8,5 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"collection_id\": \"{{collection_id}}"
docs: |-
## Create Device Mapping
Maps a collection to a device shelf for synchronization.
**Method:** POST
**Endpoint:** /api/devices/{device_id}/collections
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `device_id` (string): Device ID
**Request Body:**
- `collection_id` (string, required): Collection ID to map
- `device_shelf_name` (string, optional): Custom shelf name on device
- `sync_direction` (string, optional): sync direction (book_to_device, device_to_book, bidirectional)
**Response:**
- Mapping object with generated ID
**Status Codes:**
- 201: Created
- 400: Invalid request
- 401: Unauthorized
- 404: Device or collection not found
data: "{\n \"collection_id\": \"{{collection_id}}"
docs: "## Create Device Mapping\n\n Maps a collection to a device shelf for synchronization.\n \n **Method:** POST\n \n **Endpoint:** /api/devices/{device_id}/collections\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `device_id` (string): Device ID\n \n **Request Body:**\n - `collection_id` (string, required): Collection ID to map\n - `device_shelf_name` (string, optional): Custom shelf name on device\n - `sync_direction` (string, optional): sync direction (book_to_device, device_to_book, bidirectional)\n \n **Response:**\n - Mapping object with generated ID\n \n **Status Codes:**\n - 201: Created\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Device or collection not found"
+2 -36
View File
@@ -8,39 +8,5 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"name\": \"Sci-Fi Favorites\",\n \"description\": \"Updated\
\ description\",\n \"color\": \"#00ff00\",\n \"icon\": \"⭐\",\n \"\
auto_assign_rules\": [\n {\n \"id\": \"rule-2\",\n \"field\"\
: \"series\",\n \"operator\": \"equals\",\n \"value\": \"Foundation\"\
,\n \"priority\": 9"
docs: |-
## Update Collection
Updates an existing collection's properties.
**Method:** PUT
**Endpoint:** /api/collections/{collection_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `collection_id` (string): Collection ID
**Request Body:**
- `name` (string, optional): New collection name
- `description` (string, optional): New description
- `color` (string, optional): New hex color code
- `icon` (string, optional): New emoji icon
- `auto_assign_rules` (array, optional): Updated auto-assignment rules
**Response:**
- Updated collection object
**Status Codes:**
- 200: Success
- 400: Invalid request
- 401: Unauthorized
- 404: Collection not found
- 409: Collection name already exists
data: "{\n \"name\": \"Sci-Fi Favorites\",\n \"description\": \"Updated description\",\n \"color\": \"#00ff00\",\n \"icon\": \"⭐\",\n \"auto_assign_rules\": [\n {\n \"id\": \"rule-2\",\n \"field\": \"series\",\n \"operator\": \"equals\",\n \"value\": \"Foundation\",\n \"priority\": 9"
docs: "## Update Collection\n\n Updates an existing collection's properties.\n \n **Method:** PUT\n \n **Endpoint:** /api/collections/{collection_id}\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `collection_id` (string): Collection ID\n \n **Request Body:**\n - `name` (string, optional): New collection name\n - `description` (string, optional): New description\n - `color` (string, optional): New hex color code\n - `icon` (string, optional): New emoji icon\n - `auto_assign_rules` (array, optional): Updated auto-assignment rules\n \n **Response:**\n - Updated collection object\n \n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Collection not found\n - 409: Collection name already exists"
+2 -30
View File
@@ -8,33 +8,5 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"device_shelf_name\": \"Science Fiction\",\n \"sync_direction\"\
: \"book_to_device\""
docs: |-
## Update Device Mapping
Updates settings for a collection-to-device mapping.
**Method:** PUT
**Endpoint:** /api/devices/{device_id}/collections/{mapping_id}
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `device_id` (string): Device ID
- `mapping_id` (string): Mapping ID
**Request Body:**
- `device_shelf_name` (string, optional): Custom shelf name on device
- `sync_direction` (string, optional): Sync direction (book_to_device, device_to_book, bidirectional)
**Response:**
- Updated mapping object
**Status Codes:**
- 200: Success
- 400: Invalid request
- 401: Unauthorized
- 404: Device or mapping not found
data: "{\n \"device_shelf_name\": \"Science Fiction\",\n \"sync_direction\": \"book_to_device\""
docs: "## Update Device Mapping\n\n Updates settings for a collection-to-device mapping.\n \n **Method:** PUT\n \n **Endpoint:** /api/devices/{device_id}/collections/{mapping_id}\n \n **Authentication:** Required (Bearer token)\n \n **Path Parameters:**\n - `device_id` (string): Device ID\n - `mapping_id` (string): Mapping ID\n \n **Request Body:**\n - `device_shelf_name` (string, optional): Custom shelf name on device\n - `sync_direction` (string, optional): Sync direction (book_to_device, device_to_book, bidirectional)\n \n **Response:**\n - Updated mapping object\n \n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Device or mapping not found"
@@ -8,40 +8,9 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"My Favorites\"\
,\n \"icon\": \"⭐\",\n \"description\": \"My favorite books\",\n \"show_on_dashboard\"\
: true,\n \"auto_assign_rules\": \"\",\n \"manual_book_ids\": [\"{{media_item_id}}\"\
],\n \"match_type\": \"all\"\n}"
data: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"My Favorites\",\n \"icon\": \"⭐\",\n \"description\": \"My favorite books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"\",\n \"manual_book_ids\": [\"{{media_item_id}}\"],\n \"match_type\": \"all\"\n}"
runtime:
scripts:
- type: tests
code: "test(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n\
});\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\n\
expect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains\
\ collection name\", function() {\n expect(res.body.name).to.eql(\"My Favorites\"\
);\n });"
docs: |-
Create a custom collection with manually selected books.
**Endpoint**: POST /api/collections
**Auth**: Required (Bearer token)
## Example Request
```json
{
"library_id": "cc23c3a7-f8fb-451a-a78d-2a16df1b725a",
"name": "My Favorites",
"icon": "⭐",
"description": "My favorite books",
"show_on_dashboard": true,
"auto_assign_rules": "",
"manual_book_ids": ["book-id-1", "book-id-2"],
"match_type": "all"
}
```
## Response
Returns the created collection with generated ID.
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n});\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\nexpect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains collection name\", function() {\n expect(res.body.name).to.eql(\"My Favorites\");\n });\n}"
docs: "Create a custom collection with manually selected books.\n\n**Endpoint**: POST /api/collections\n**Auth**: Required (Bearer token)\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"name\": \"My Favorites\",\n \"icon\": \"⭐\",\n \"description\": \"My favorite books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"\",\n \"manual_book_ids\": [\"book-id-1\", \"book-id-2\"],\n \"match_type\": \"all\"\n}\n```\n\n## Response\n\nReturns the created collection with generated ID."
@@ -8,16 +8,12 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"icon\": \"📚\"\
\n}"
data: "{\n \"library_id\": \"{{library_id}}\",\n \"icon\": \"\U0001F4DA\"\n}"
runtime:
scripts:
- type: tests
code: "test(\"status must be 400 with missing name\", function() {\n expect(res.status).to.eql(400);\n\
});\n\ntest(\"response contains error message\", function() {\n expect(res.body.error).to.exist;\n\
\ });"
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 400 with missing name\", function() {\n expect(res.status).to.eql(400);\n});\n\ntest(\"response contains error message\", function() {\n expect(res.body.error).to.exist;\n });\n}"
docs: 'Test error handling when creating a collection without required fields.
docs: |-
Test error handling when creating a collection without required fields.
**Expected Response**: 400 Bad Request with error message.
**Expected Response**: 400 Bad Request with error message.'
@@ -8,57 +8,10 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"Science Fiction\
\ Books\",\n \"icon\": \"🚀\",\n \"description\": \"All my sci-fi books\",\n\
\ \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"{\\\"rules\\\":[{\\\
\"id\\\":\\\"rule1\\\",\\\"field\\\":\\\"genre\\\",\\\"operator\\\":\\\"equals\\\"\
,\\\"value\\\":\\\"Sci-Fi\\\",\\\"priority\\\":1}]}\",\n \"manual_book_ids\"\
: [],\n \"match_type\": \"all\"\n}"
data: "{\n \"library_id\": \"{{library_id}}\",\n \"name\": \"Science Fiction Books\",\n \"icon\": \"\U0001F680\",\n \"description\": \"All my sci-fi books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"{\\\"rules\\\":[{\\\"id\\\":\\\"rule1\\\",\\\"field\\\":\\\"genre\\\",\\\"operator\\\":\\\"equals\\\",\\\"value\\\":\\\"Sci-Fi\\\",\\\"priority\\\":1}]}\",\n \"manual_book_ids\": [],\n \"match_type\": \"all\"\n}"
runtime:
scripts:
- type: tests
code: "test(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n\
});\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\n\
expect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains\
\ collection name\", function() {\n expect(res.body.name).to.eql(\"Science\
\ Fiction Books\");\n });\n\ntest(\"response contains is_system false\", function()\
\ {\n expect(res.body.is_system).to.exist;\n expect(res.body.is_system).to.eql(false);\n\
\ });"
docs: |-
Create a custom collection with filter rules.
**Endpoint**: POST /api/collections
**Auth**: Required (Bearer token)
## Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| library_id | string | Yes | Library UUID |
| name | string | Yes | Collection name |
| icon | string | No | Emoji icon |
| description | string | No | Collection description |
| show_on_dashboard | boolean | No | Show on dashboard (default: true) |
| auto_assign_rules | string | No | JSON string of filter rules |
| manual_book_ids | array | No | Array of book IDs |
| match_type | string | No | "all" (AND) or "any" (OR) |
## Example Request
```json
{
"library_id": "cc23c3a7-f8fb-451a-a78d-2a16df1b725a",
"name": "Science Fiction Books",
"icon": "🚀",
"description": "All my sci-fi books",
"show_on_dashboard": true,
"auto_assign_rules": "{\"rules\":[{\"id\":\"rule1\",\"field\":\"genre\",\"operator\":\"equals\",\"value\":\"Sci-Fi\",\"priority\":1}]}",
"manual_book_ids": [],
"match_type": "all"
}
```
## Response
Returns the created collection with generated ID.
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 201 with valid request\", function() {\n expect(res.status).to.eql(201);\n});\n\ntest(\"response contains collection id\", function() {\n expect(res.body.id).to.exist;\nexpect(res.body.id).to.be.a('string');\n });\n\ntest(\"response contains collection name\", function() {\n expect(res.body.name).to.eql(\"Science Fiction Books\");\n });\n\ntest(\"response contains is_system false\", function() {\n expect(res.body.is_system).to.exist;\n expect(res.body.is_system).to.eql(false);\n });\n}"
docs: "Create a custom collection with filter rules.\n\n**Endpoint**: POST /api/collections\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| library_id | string | Yes | Library UUID |\n| name | string | Yes | Collection name |\n| icon | string | No | Emoji icon |\n| description | string | No | Collection description |\n| show_on_dashboard | boolean | No | Show on dashboard (default: true) |\n| auto_assign_rules | string | No | JSON string of filter rules |\n| manual_book_ids | array | No | Array of book IDs |\n| match_type | string | No | \"all\" (AND) or \"any\" (OR) |\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"name\": \"Science Fiction Books\",\n \"icon\": \"\U0001F680\",\n \"description\": \"All my sci-fi books\",\n \"show_on_dashboard\": true,\n \"auto_assign_rules\": \"{\\\"rules\\\":[{\\\"id\\\":\\\"rule1\\\",\\\"field\\\":\\\
\"genre\\\",\\\"operator\\\":\\\"equals\\\",\\\"value\\\":\\\"Sci-Fi\\\",\\\"priority\\\":1}]}\",\n \"manual_book_ids\": [],\n \"match_type\": \"all\"\n}\n```\n\n## Response\n\nReturns the created collection with generated ID."
+4 -48
View File
@@ -8,53 +8,9 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"rules\": [\n\
{\n \"id\": \"rule1\",\n \"field\": \"genre\",\n\
\"operator\": \"equals\",\n \"value\": \"Fiction\",\n\
\"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}"
data: "{\n \"library_id\": \"{{library_id}}\",\n \"rules\": [\n{\n \"id\": \"rule1\",\n \"field\": \"genre\",\n\"operator\": \"equals\",\n \"value\": \"Fiction\",\n\"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}"
runtime:
scripts:
- type: tests
code: "test(\"status must be 200 with valid request\", function() {\n expect(res.status).to.eql(200);\n\
});\n\ntest(\"response contains items array\", function() {\n expect(res.body.items).to.exist;\n\
expect(res.body.items).to.be.an('array');\n });"
docs: |-
Preview collection with filter rules before saving.
**Endpoint**: POST /api/collections/preview
**Auth**: Required (Bearer token)
## Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| library_id | string | Yes | Library UUID |
| rules | array | No | Filter rules to evaluate |
| manual_book_ids | array | No | Manually selected book IDs |
| limit | int | No | Max items to return (default: 20, max: 100) |
## Example Request
```json
{
"library_id": "cc23c3a7-f8fb-451a-a78d-2a16df1b725a",
"rules": [
{
"id": "rule1",
"field": "genre",
"operator": "equals",
"value": "Fiction",
"priority": 1
}
],
"manual_book_ids": [],
"limit": 20
}
```
## Response
Returns array of matching book items.
This endpoint is used by the Custom Section Builder to show users what books will match their filter rules before they save the collection.
- type: after-response
code: "function onResponse(res) {\ntest(\"status must be 200 with valid request\", function() {\n expect(res.status).to.eql(200);\n});\n\ntest(\"response contains items array\", function() {\n expect(res.body.items).to.exist;\nexpect(res.body.items).to.be.an('array');\n });\n}"
docs: "Preview collection with filter rules before saving.\n\n**Endpoint**: POST /api/collections/preview\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| library_id | string | Yes | Library UUID |\n| rules | array | No | Filter rules to evaluate |\n| manual_book_ids | array | No | Manually selected book IDs |\n| limit | int | No | Max items to return (default: 20, max: 100) |\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"rules\": [\n {\n \"id\": \"rule1\",\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Fiction\",\n \"priority\": 1\n }\n ],\n \"manual_book_ids\": [],\n \"limit\": 20\n}\n```\n\n## Response\n\nReturns array of matching book items.\n\nThis endpoint is used by the Custom Section Builder to show users what books will match their filter rules before they save the collection."
@@ -8,46 +8,11 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"operations\": [\n {\n \"collection_id\": \"{{collectionId1"
data: "{\n \"operations\": [\n {\n \"collection_id\": \"{{collectionId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Bulk Add Books to Collections
Adds multiple books to multiple collections in a single request. Each operation specifies a collection and a list of books to add.
**Method:** POST
**Endpoint:** /api/collections/bulk-add-books
**Authentication:** Bearer token
**Request Body:**
- `operations` (array): Array of collection-book operations
- `collection_id` (string): Collection UUID
- `book_ids` (array): Array of book UUIDs to add to the collection
**Response:**
- `results` (array): Results for each operation
- `total` (number): Total number of operations
- `success` (number): Number of successful operations
- `failed` (number): Number of failed operations
**Status Codes:**
- 200: Success (with partial results if some failed)
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 500: Internal server error
**Example:**
```json
{
"operations": [
{
"collection_id": "collection-uuid-1",
"book_ids": ["book-1", "book-2"]
docs: "## Bulk Add Books to Collections\n\n Adds multiple books to multiple collections in a single request. Each operation specifies a collection and a list of books to add.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/bulk-add-books\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `operations` (array): Array of collection-book operations\n - `collection_id` (string): Collection UUID\n - `book_ids` (array): Array of book UUIDs to add to the collection\n\n **Response:**\n - `results` (array): Results for each operation\n - `total` (number): Total number of operations\n - `success` (number): Number of successful operations\n - `failed` (number): Number of failed operations\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"operations\": [\n {\n \"collection_id\":\
\ \"collection-uuid-1\",\n \"book_ids\": [\"book-1\", \"book-2\"]"
@@ -8,16 +8,8 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"book_ids\": [\n \"{{bookId1"
data: "{\n \"book_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
docs: |-
## Bulk Remove Books from Collection
Removes multiple books from a collection in a single request.
**Method:** POST
**Endpoint:** /api/collections/{collection_id
docs: "## Bulk Remove Books from Collection\n\n Removes multiple books from a collection in a single request.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/{collection_id"
@@ -8,20 +8,10 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"book_ids\": []"
data: "{\n \"book_ids\": []"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Bulk Remove Books - Empty List Validation
Tests validation behavior when providing an empty book_ids array.
**Expected Result:** 400 Bad Request
**Validation Rule:** book_ids array must contain at least one book UUID.
**Purpose:** Ensures the API properly validates input and rejects empty removal requests.
docs: "## Bulk Remove Books - Empty List Validation\n\n Tests validation behavior when providing an empty book_ids array.\n\n **Expected Result:** 400 Bad Request\n\n **Validation Rule:** book_ids array must contain at least one book UUID.\n\n **Purpose:** Ensures the API properly validates input and rejects empty removal requests."
@@ -8,24 +8,10 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"book_ids\": [\n \"{{bookId1"
data: "{\n \"book_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Bulk Remove Books - Invalid IDs
Tests behavior when the book_ids array contains invalid UUID formats or non-existent books.
**Expected Result:** 200 OK with partial success
**Purpose:** Verifies that:
- Invalid UUID formats don't crash the endpoint
- Non-existent book IDs are handled gracefully
- Valid IDs in the same request are still processed
- Response includes detailed results showing which succeeded/failed
**Note:** The endpoint should process all valid IDs and report failures for invalid ones, allowing clients to handle partial failures appropriately.
docs: "## Bulk Remove Books - Invalid IDs\n\n Tests behavior when the book_ids array contains invalid UUID formats or non-existent books.\n\n **Expected Result:** 200 OK with partial success\n\n **Purpose:** Verifies that:\n - Invalid UUID formats don't crash the endpoint\n - Non-existent book IDs are handled gracefully\n - Valid IDs in the same request are still processed\n - Response includes detailed results showing which succeeded/failed\n\n **Note:** The endpoint should process all valid IDs and report failures for invalid ones, allowing clients to handle partial failures appropriately."
@@ -8,20 +8,10 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"book_ids\": [\n \"{{bookId1"
data: "{\n \"book_ids\": [\n \"{{bookId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Bulk Remove Books - Single Book
Tests that bulk remove endpoint works correctly with a single book.
**Expected Result:** 200 OK with removed: 1, total: 1
**Purpose:** Verifies the bulk remove endpoint handles single-item arrays correctly, providing flexibility for clients to use the same endpoint for both single and multiple removals.
**Note:** Using bulk remove for a single book is functionally equivalent to the single remove endpoint but allows for consistent error handling and response format.
docs: "## Bulk Remove Books - Single Book\n\n Tests that bulk remove endpoint works correctly with a single book.\n\n **Expected Result:** 200 OK with removed: 1, total: 1\n\n **Purpose:** Verifies the bulk remove endpoint handles single-item arrays correctly, providing flexibility for clients to use the same endpoint for both single and multiple removals.\n\n **Note:** Using bulk remove for a single book is functionally equivalent to the single remove endpoint but allows for consistent error handling and response format."
@@ -8,23 +8,10 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"rules\": [\n {\n \"field\": \"author\",\n \
\ \"operator\": \"contains\",\n \"value\": \"Asimov\""
data: "{\n \"rules\": [\n {\n \"field\": \"author\",\n \"operator\": \"contains\",\n \"value\": \"Asimov\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Test Collection Rules - Author Contains
Tests the "contains" operator on the author field to find books by a specific author (partial match).
**Example Use Case:** Finding all books by an author whose name contains "Asimov" (e.g., "Isaac Asimov").
**Operator:** `contains` - Matches if the field contains the specified value as a substring (case-insensitive typically).
**Expected Result:** Returns all books where the author field contains "Asimov".
**Purpose:** Demonstrates text-based partial matching for author searches, useful when you don't need the exact author name or want to find books by authors with similar names.
docs: "## Test Collection Rules - Author Contains\n\n Tests the \"contains\" operator on the author field to find books by a specific author (partial match).\n\n **Example Use Case:** Finding all books by an author whose name contains \"Asimov\" (e.g., \"Isaac Asimov\").\n\n **Operator:** `contains` - Matches if the field contains the specified value as a substring (case-insensitive typically).\n\n **Expected Result:** Returns all books where the author field contains \"Asimov\".\n\n **Purpose:** Demonstrates text-based partial matching for author searches, useful when you don't need the exact author name or want to find books by authors with similar names."
@@ -8,25 +8,10 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"rules\": [\n {\n \"field\": \"copyright_year\"\
,\n \"operator\": \"greater_than\",\n \"value\": \"2000\""
data: "{\n \"rules\": [\n {\n \"field\": \"copyright_year\",\n \"operator\": \"greater_than\",\n \"value\": \"2000\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Test Collection Rules - Copyright Year Greater Than
Tests the "greater_than" operator on the copyright_year field to find books published after a specific year.
**Example Use Case:** Creating a "Modern Books" collection with books published after 2000.
**Operator:** `greater_than` - Matches if the field value is greater than the specified value (numeric comparison).
**Field:** `copyright_year` - The year the book was copyrighted/published.
**Expected Result:** Returns all books with copyright_year greater than 2000 (i.e., published in 2001 or later).
**Purpose:** Demonstrates numeric comparison operators for creating date-based collections, useful for organizing books by publication era.
docs: "## Test Collection Rules - Copyright Year Greater Than\n\n Tests the \"greater_than\" operator on the copyright_year field to find books published after a specific year.\n\n **Example Use Case:** Creating a \"Modern Books\" collection with books published after 2000.\n\n **Operator:** `greater_than` - Matches if the field value is greater than the specified value (numeric comparison).\n\n **Field:** `copyright_year` - The year the book was copyrighted/published.\n\n **Expected Result:** Returns all books with copyright_year greater than 2000 (i.e., published in 2001 or later).\n\n **Purpose:** Demonstrates numeric comparison operators for creating date-based collections, useful for organizing books by publication era."
@@ -8,22 +8,10 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"rules\": []"
data: "{\n \"rules\": []"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Test Collection Rules - Empty Rules Array
Tests validation behavior when providing an empty rules array.
**Expected Result:** 400 Bad Request
**Validation Rule:** rules array must contain at least one rule object.
**Purpose:** Ensures the API properly validates input and rejects empty rule sets, preventing accidental queries that would return all books or cause performance issues.
**Use Case:** Client-side validation should prevent sending empty rules, but the API should also validate to catch malformed requests.
docs: "## Test Collection Rules - Empty Rules Array\n\n Tests validation behavior when providing an empty rules array.\n\n **Expected Result:** 400 Bad Request\n\n **Validation Rule:** rules array must contain at least one rule object.\n\n **Purpose:** Ensures the API properly validates input and rejects empty rule sets, preventing accidental queries that would return all books or cause performance issues.\n\n **Use Case:** Client-side validation should prevent sending empty rules, but the API should also validate to catch malformed requests."
@@ -8,27 +8,10 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"rules\": [\n {\n \"field\": \"genre\",\n \
\ \"operator\": \"equals\",\n \"value\": \"NonExistentGenre123456\""
data: "{\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"NonExistentGenre123456\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Test Collection Rules - No Matches
Tests behavior when collection rules don't match any books in the library.
**Example Use Case:** Validating that a new genre name doesn't exist before creating a collection for it, or testing edge cases.
**Expected Result:** 200 OK with empty matches array and total: 0
**Purpose:** Verifies that the API handles zero-match scenarios gracefully:
- Returns 200 (success) not 404
- Returns empty array, not null
- Returns total: 0 for clarity
- No errors thrown for no results
**Note:** An empty result set is a valid response and doesn't indicate an error. This allows users to test rules confidently before creating collections.
docs: "## Test Collection Rules - No Matches\n\n Tests behavior when collection rules don't match any books in the library.\n\n **Example Use Case:** Validating that a new genre name doesn't exist before creating a collection for it, or testing edge cases.\n\n **Expected Result:** 200 OK with empty matches array and total: 0\n\n **Purpose:** Verifies that the API handles zero-match scenarios gracefully:\n - Returns 200 (success) not 404\n - Returns empty array, not null\n - Returns total: 0 for clarity\n - No errors thrown for no results\n\n **Note:** An empty result set is a valid response and doesn't indicate an error. This allows users to test rules confidently before creating collections."
@@ -8,60 +8,11 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"rules\": [\n {\n \"field\": \"genre\",\n \
\ \"operator\": \"equals\",\n \"value\": \"Science Fiction\""
data: "{\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\""
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Test Collection Rules
Tests collection rules against the library to see which books match, without creating a collection. Useful for previewing what books would be included in a collection with specific rules.
**Method:** POST
**Endpoint:** /api/collections/test-rules
**Authentication:** Bearer token
**Request Body:**
- `rules` (array): Array of rule objects to test
- `field` (string): Field to test (genre, author, copyright_year, tags, etc.)
- `operator` (string): Comparison operator
- `equals`: Exact match
- `contains`: Contains substring (for text fields)
- `greater_than`: Greater than (for numeric fields)
- `less_than`: Less than (for numeric fields)
- `not_equals`: Not equal to
- `starts_with`: Starts with
- `ends_with`: Ends with
- `is_empty`: Field is empty or null
- `is_not_empty`: Field is not empty and not null
- `value` (string): Value to compare against (not required for is_empty/is_not_empty)
**Response:**
- `matches` (array): Array of matching books
- `id` (string): Book UUID
- `title` (string): Book title
- `author` (string): Book author
- `genre` (string): Book genre
- Additional book metadata
- `total` (number): Total number of matching books
**Status Codes:**
- 200: Success - returns matching books
- 400: Invalid request (empty rules array, invalid field/operator)
- 401: Unauthorized
- 500: Internal server error
**Example Request:**
```json
{
"rules": [
{
"field": "genre",
"operator": "equals",
"value": "Science Fiction"
docs: "## Test Collection Rules\n\n Tests collection rules against the library to see which books match, without creating a collection. Useful for previewing what books would be included in a collection with specific rules.\n\n **Method:** POST\n\n **Endpoint:** /api/collections/test-rules\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `rules` (array): Array of rule objects to test\n - `field` (string): Field to test (genre, author, copyright_year, tags, etc.)\n - `operator` (string): Comparison operator\n - `equals`: Exact match\n - `contains`: Contains substring (for text fields)\n - `greater_than`: Greater than (for numeric fields)\n - `less_than`: Less than (for numeric fields)\n - `not_equals`: Not equal to\n - `starts_with`: Starts with\n - `ends_with`: Ends with\n - `is_empty`: Field is empty or null\n - `is_not_empty`: Field is not empty and not null\n - `value` (string): Value to compare against (not required\
\ for is_empty/is_not_empty)\n\n **Response:**\n - `matches` (array): Array of matching books\n - `id` (string): Book UUID\n - `title` (string): Book title\n - `author` (string): Book author\n - `genre` (string): Book genre\n - Additional book metadata\n - `total` (number): Total number of matching books\n\n **Status Codes:**\n - 200: Success - returns matching books\n - 400: Invalid request (empty rules array, invalid field/operator)\n - 401: Unauthorized\n - 500: Internal server error\n\n **Example Request:**\n ```json\n {\n \"rules\": [\n {\n \"field\": \"genre\",\n \"operator\": \"equals\",\n \"value\": \"Science Fiction\""
+2 -12
View File
@@ -8,18 +8,8 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"winner\": \"koreader\",\n \"manual_data\": null,\n \"\
apply_to_all_future_conflicts\": false,\n \"reason\": \"User chose more recent\
\ progress\""
data: "{\n \"winner\": \"koreader\",\n \"manual_data\": null,\n \"apply_to_all_future_conflicts\": false,\n \"reason\": \"User chose more recent progress\""
headers:
- key: Authorization
value: Bearer {{token
docs: |-
## Resolve Conflict
Resolves a sync conflict by choosing which source to use for the conflicting data.
**Method:** POST
**Endpoint:** /api/conflicts/{conflict_id
docs: "## Resolve Conflict\n\n Resolves a sync conflict by choosing which source to use for the conflicting data.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/{conflict_id"
@@ -8,42 +8,10 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"conflict_ids\": [\n \"{{conflictId1"
data: "{\n \"conflict_ids\": [\n \"{{conflictId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Bulk Dismiss Conflicts
Dismisses multiple sync conflicts without resolving them. This removes them from the conflict list while leaving the data unchanged.
**Method:** POST
**Endpoint:** /api/conflicts/bulk-dismiss
**Authentication:** Bearer token
**Request Body:**
- `conflict_ids` (array): Array of conflict UUIDs to dismiss
**Response:**
- `results` (array): Results for each dismissal
- `total` (number): Total number of conflicts processed
- `success` (number): Number of successfully dismissed conflicts
- `failed` (number): Number of failed dismissals
**Status Codes:**
- 200: Success
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 404: One or more conflicts not found
- 500: Internal server error
**Example:**
```json
{
"conflict_ids": ["uuid-1", "uuid-2"]
docs: "## Bulk Dismiss Conflicts\n\n Dismisses multiple sync conflicts without resolving them. This removes them from the conflict list while leaving the data unchanged.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-dismiss\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to dismiss\n\n **Response:**\n - `results` (array): Results for each dismissal\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully dismissed conflicts\n - `failed` (number): Number of failed dismissals\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 404: One or more conflicts not found\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"conflict_ids\": [\"uuid-1\", \"uuid-2\"]"
@@ -8,48 +8,11 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"conflict_ids\": [\n \"{{conflictId1"
data: "{\n \"conflict_ids\": [\n \"{{conflictId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Bulk Resolve Conflicts
Resolves multiple sync conflicts in a single request using a specified resolution strategy.
**Method:** POST
**Endpoint:** /api/conflicts/bulk-resolve
**Authentication:** Bearer token
**Request Body:**
- `conflict_ids` (array): Array of conflict UUIDs to resolve
- `strategy` (string): Resolution strategy
- `most_recent`: Use the most recently updated progress
- `highest_progress`: Use the reading progress with the highest percent read
- `server`: Always prefer server-side data
- `device`: Always prefer device-side data
**Response:**
- `results` (array): Results for each conflict resolution
- `total` (number): Total number of conflicts processed
- `success` (number): Number of successfully resolved conflicts
- `failed` (number): Number of failed resolutions
**Status Codes:**
- 200: Success (with partial results if some failed)
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 404: One or more conflicts not found
- 500: Internal server error
**Example:**
```json
{
"conflict_ids": ["uuid-1", "uuid-2", "uuid-3"],
"strategy": "most_recent"
docs: "## Bulk Resolve Conflicts\n\n Resolves multiple sync conflicts in a single request using a specified resolution strategy.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-resolve\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to resolve\n - `strategy` (string): Resolution strategy\n - `most_recent`: Use the most recently updated progress\n - `highest_progress`: Use the reading progress with the highest percent read\n - `server`: Always prefer server-side data\n - `device`: Always prefer device-side data\n\n **Response:**\n - `results` (array): Results for each conflict resolution\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully resolved conflicts\n - `failed` (number): Number of failed resolutions\n\n **Status Codes:**\n - 200: Success (with partial results if some failed)\n - 400: Invalid request data\n - 401: Unauthorized\n\
\ - 403: Forbidden\n - 404: One or more conflicts not found\n - 500: Internal server error\n\n **Example:**\n ```json\n {\n \"conflict_ids\": [\"uuid-1\", \"uuid-2\", \"uuid-3\"],\n \"strategy\": \"most_recent\""
@@ -8,39 +8,11 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"conflict_ids\": [\n \"{{conflictId1"
data: "{\n \"conflict_ids\": [\n \"{{conflictId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Bulk Resolve with Highest Progress Strategy
Resolves multiple sync conflicts using the "highest_progress" strategy, which keeps the reading progress with the highest percentage read.
**Method:** POST
**Endpoint:** /api/conflicts/bulk-resolve
**Authentication:** Bearer token
**Request Body:**
- `conflict_ids` (array): Array of conflict UUIDs to resolve
- `strategy` (string): Must be "highest_progress"
**Response:**
- `results` (array): Results for each conflict resolution
- `total` (number): Total number of conflicts processed
- `success` (number): Number of successfully resolved conflicts
- `failed` (number): Number of failed resolutions
**Status Codes:**
- 200: Success
- 400: Invalid request data
- 401: Unauthorized
- 403: Forbidden
- 500: Internal server error
**Note:** The highest progress strategy is ideal when you want to preserve the most reading progress across devices. Use this when you've been reading on multiple devices and want to keep the furthest position.
docs: "## Bulk Resolve with Highest Progress Strategy\n\n Resolves multiple sync conflicts using the \"highest_progress\" strategy, which keeps the reading progress with the highest percentage read.\n\n **Method:** POST\n\n **Endpoint:** /api/conflicts/bulk-resolve\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `conflict_ids` (array): Array of conflict UUIDs to resolve\n - `strategy` (string): Must be \"highest_progress\"\n\n **Response:**\n - `results` (array): Results for each conflict resolution\n - `total` (number): Total number of conflicts processed\n - `success` (number): Number of successfully resolved conflicts\n - `failed` (number): Number of failed resolutions\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 403: Forbidden\n - 500: Internal server error\n\n **Note:** The highest progress strategy is ideal when you want to preserve the most reading progress across devices. Use this when you've been\
\ reading on multiple devices and want to keep the furthest position."
+3 -34
View File
@@ -8,40 +8,9 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"collection_name\": \"continue-reading\"\n}"
data: "{\n \"collection_name\": \"continue-reading\"\n}"
runtime:
scripts:
- type: tests
code: "test(\"status must be 200 with valid collection\", function() {\n expect(res.status).to.eql(200);\n\
});\n\ntest(\"response has success message\", function() {\n expect(res.body.message).to.exist;\n\
});"
docs: |-
Restore a system collection to its default state.
**Endpoint**: POST /api/dashboard/restore-system-collection
**Auth**: Required (Bearer token)
## Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| collection_name | string | Yes | Name of system collection to restore |
Valid collection names:
- `continue-reading`: Books you're currently reading (0 < progress < 1)
- `recently-added`: Newly added items to this library
- `recently-read`: Books you've finished (progress >= 1)
- `not-started`: Books you haven't read yet (progress = 0 or no record)
## Example Request
```json
{
"collection_name": "continue-reading"
}
```
## Response
Returns success message on restore.
code: "test(\"status must be 200 with valid collection\", function() {\n expect(res.status).to.eql(200);\n});\n\ntest(\"response has success message\", function() {\n expect(res.body.message).to.exist;\n});"
docs: "Restore a system collection to its default state.\n\n**Endpoint**: POST /api/dashboard/restore-system-collection\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| collection_name | string | Yes | Name of system collection to restore |\n\nValid collection names:\n- `continue-reading`: Books you're currently reading (0 < progress < 1)\n- `recently-added`: Newly added items to this library\n- `recently-read`: Books you've finished (progress >= 1)\n- `not-started`: Books you haven't read yet (progress = 0 or no record)\n\n## Example Request\n\n```json\n{\n \"collection_name\": \"continue-reading\"\n}\n```\n\n## Response\n\nReturns success message on restore."
+3 -35
View File
@@ -8,41 +8,9 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"library_id\": \"{{library_id}}\",\n \"hidden_collections\"\
: [\"not-started\"],\n \"collection_order\": [\"recently-added\", \"continue-reading\"\
, \"recently-read\"],\n \"items_per_section\": 20\n}"
data: "{\n \"library_id\": \"{{library_id}}\",\n \"hidden_collections\": [\"not-started\"],\n \"collection_order\": [\"recently-added\", \"continue-reading\", \"recently-read\"],\n \"items_per_section\": 20\n}"
runtime:
scripts:
- type: tests
code: "test(\"status must be 200 with valid request\", function() {\n expect(res.status).to.eql(200);\n\
});"
docs: |-
Update dashboard preferences for the authenticated user.
**Endpoint**: PUT /api/dashboard/preferences
**Auth**: Required (Bearer token)
## Request Body
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| library_id | string | Yes | Library UUID |
| hidden_collections | array | No | Collection names to hide from dashboard |
| collection_order | array | No | Collection names in custom display order |
| items_per_section | int | No | Items to show per section (default: 20) |
## Example Request
```json
{
"library_id": "cc23c3a7-f8fb-451a-a78d-2a16df1b725a",
"hidden_collections": ["not-started"],
"collection_order": ["recently-added", "continue-reading", "recently-read"],
"items_per_section": 20
}
```
## Response
Returns updated preferences object on success.
code: "test(\"status must be 200 with valid request\", function() {\n expect(res.status).to.eql(200);\n});"
docs: "Update dashboard preferences for the authenticated user.\n\n**Endpoint**: PUT /api/dashboard/preferences\n**Auth**: Required (Bearer token)\n\n## Request Body\n\n| Field | Type | Required | Description |\n|-------|------|----------|-------------|\n| library_id | string | Yes | Library UUID |\n| hidden_collections | array | No | Collection names to hide from dashboard |\n| collection_order | array | No | Collection names in custom display order |\n| items_per_section | int | No | Items to show per section (default: 20) |\n\n## Example Request\n\n```json\n{\n \"library_id\": \"cc23c3a7-f8fb-451a-a78d-2a16df1b725a\",\n \"hidden_collections\": [\"not-started\"],\n \"collection_order\": [\"recently-added\", \"continue-reading\", \"recently-read\"],\n \"items_per_section\": 20\n}\n```\n\n## Response\n\nReturns updated preferences object on success."
-22
View File
@@ -1,22 +0,0 @@
info:
name: Bookhoard Device Management API
type: collection
seq: 1
http:
method: POST
url: '"http://localhost:8765/api"'
docs: |-
## Bookhoard Device Management API
Collection of endpoints for managing e-reader devices and their synchronization settings.
**Base URL:** http://localhost:8765/api
**Authentication:** Most endpoints require Bearer token authentication
**Endpoints:**
- Device registration and management
- Device authentication tokens
- Sync configuration
- Device-to-collection mappings
+7
View File
@@ -0,0 +1,7 @@
info:
name: Bookhoard Device Management API
type: folder
seq: 1
request:
auth: inherit
docs: "## Bookhoard Device Management API\n\n Collection of endpoints for managing e-reader devices and their synchronization settings.\n\n **Base URL:** http://localhost:8765/api\n\n **Authentication:** Most endpoints require Bearer token authentication\n\n **Endpoints:**\n - Device registration and management\n - Device authentication tokens\n - Sync configuration\n - Device-to-collection mappings"
+7 -86
View File
@@ -2,95 +2,16 @@ info:
name: Get Analytics Tests
type: http
seq: 7
http:
method: POST
url: '{{base_url}}/api/sync/kobo/v1/analytics/gettests'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/v1/analytics/gettests'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: Content-Type
value: application/json
body:
type: json
json:
platform: android
firmware_version: 4.30.19023
docs: |-
## Get Kobo Analytics Tests
Retrieves A/B testing configuration and feature flags for Kobo device.
**Method:** POST
**Endpoint:** /api/sync/kobo/v1/analytics/gettests
**Authentication:** Bearer token
**Request Body:**
- `platform` (string): Device platform
- `android`: Kobo Android app
- `kobo`: Native Kobo firmware
- `firmware_version` (string): Firmware version (e.g., "4.30.19023")
- `device_model` (string, optional): Device model identifier
- `locale` (string, optional): Device locale (e.g., "en_US")
**Response:**
- `tests` (array): Active A/B tests
- `test_name` (string): Test identifier
- `variant` (string): Assigned variant (A, B, C, etc.)
- `enabled` (boolean): Whether test is active
- `parameters` (object): Test-specific parameters
- `features` (object): Feature flags
- `feature_name` (boolean/string): Feature state
- `configuration` (object): Device configuration
- `sync_interval_minutes` (integer): Recommended sync frequency
- `batch_size` (integer): Max items per batch sync
- `timeout_seconds` (integer): Request timeout
- `version` (string): Configuration version
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 400: Invalid request parameters
**Analytics Testing Purpose:**
- Kobo uses A/B testing for UX features
- Feature flags for gradual rollout
- Performance monitoring configuration
- Sync behavior optimization
- Device-specific tuning
**Common Tests:**
- Sync frequency optimization
- UI/UX variations
- Network usage patterns
- Battery life improvements
- Feature set variations by model
**Feature Flags:**
- New sync features
- Beta functionality
- Platform-specific capabilities
- Experimental features
**Configuration Parameters:**
- Optimal sync intervals for device
- Batch size limits based on device capabilities
- Timeout values for network conditions
- Retry logic configuration
- Cache policy settings
**Usage:**
- Called during device initialization
- Refreshed daily or weekly
- Cached on device
- Affects sync behavior
- Can be overridden by server
**Use Cases:**
- Device initialization
- Feature rollout testing
- Performance optimization
- UX experiment participation
- Configuration management
data: "{\n \"platform\": \"android\",\n \"firmware_version\": \"4.30.19023\"\n}"
docs: "## Get Kobo Analytics Tests\n\nRetrieves A/B testing configuration and feature flags for Kobo device.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/kobo/v1/analytics/gettests\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `platform` (string): Device platform\n - `android`: Kobo Android app\n - `kobo`: Native Kobo firmware\n- `firmware_version` (string): Firmware version (e.g., \"4.30.19023\")\n- `device_model` (string, optional): Device model identifier\n- `locale` (string, optional): Device locale (e.g., \"en_US\")\n\n**Response:**\n- `tests` (array): Active A/B tests\n - `test_name` (string): Test identifier\n - `variant` (string): Assigned variant (A, B, C, etc.)\n - `enabled` (boolean): Whether test is active\n - `parameters` (object): Test-specific parameters\n- `features` (object): Feature flags\n - `feature_name` (boolean/string): Feature state\n- `configuration` (object): Device configuration\n - `sync_interval_minutes` (integer): Recommended sync frequency\n\
\ - `batch_size` (integer): Max items per batch sync\n - `timeout_seconds` (integer): Request timeout\n- `version` (string): Configuration version\n\n**Status Codes:**\n- 200: Success\n- 401: Unauthorized\n- 400: Invalid request parameters\n\n**Analytics Testing Purpose:**\n- Kobo uses A/B testing for UX features\n- Feature flags for gradual rollout\n- Performance monitoring configuration\n- Sync behavior optimization\n- Device-specific tuning\n\n**Common Tests:**\n- Sync frequency optimization\n- UI/UX variations\n- Network usage patterns\n- Battery life improvements\n- Feature set variations by model\n\n**Feature Flags:**\n- New sync features\n- Beta functionality\n- Platform-specific capabilities\n- Experimental features\n\n**Configuration Parameters:**\n- Optimal sync intervals for device\n- Batch size limits based on device capabilities\n- Timeout values for network conditions\n- Retry logic configuration\n- Cache policy settings\n\n**Usage:**\n- Called during device initialization\n\
- Refreshed daily or weekly\n- Cached on device\n- Affects sync behavior\n- Can be overridden by server\n\n**Use Cases:**\n- Device initialization\n- Feature rollout testing\n- Performance optimization\n- UX experiment participation\n- Configuration management"
+1 -1
View File
@@ -5,7 +5,7 @@ info:
http:
method: GET
url: '{{base_url}}/api/sync/kobo/library'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/v1/initialization'
auth: inherit
docs: |-
@@ -4,7 +4,7 @@ info:
seq: 1
http:
method: GET
url: '{{base_url}}/sync/unlinked-books/{{unlinkedBookId}}/suggestions'
url: '{{base_url}}/api/sync/unlinked-books/{{unlinkedBookId}}/suggestions'
auth: inherit
body:
type: none
-22
View File
@@ -1,22 +0,0 @@
info:
name: Bookhoard Kobo Sync API
type: collection
seq: 1
http:
method: POST
url: '"http://localhost:8765/api"'
docs: |-
## Bookhoard Kobo Sync API
Collection of endpoints specifically for Kobo e-reader device synchronization.
**Base URL:** http://localhost:8765/api
**Authentication:** Device token or Bearer token required
**Endpoints:**
- Kobo-specific sync protocols
- Kobo store integration
- Metadata synchronization
- Reading progress sync
+7
View File
@@ -0,0 +1,7 @@
info:
name: Bookhoard Kobo Sync API
type: folder
seq: 1
request:
auth: inherit
docs: "## Bookhoard Kobo Sync API\n\n Collection of endpoints specifically for Kobo e-reader device synchronization.\n\n **Base URL:** http://localhost:8765/api\n\n **Authentication:** Device token or Bearer token required\n\n **Endpoints:**\n - Kobo-specific sync protocols\n - Kobo store integration\n - Metadata synchronization\n - Reading progress sync"
@@ -4,42 +4,14 @@ info:
seq: 1
http:
method: POST
url: '{{base_url}}/sync/auto-link-books'
url: '{{base_url}}/api/sync/auto-link-books'
auth: inherit
body:
type: json
jsonBody: "{\n \"confidence_threshold\": 0.8,\n \"limit\": 50"
data: "{\n \"confidence_threshold\": 0.8,\n \"limit\": 50"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Auto-Link Unlinked Books
Automatically links unlinked books to media items based on title and author matching with a configurable confidence threshold.
**Method:** POST
**Endpoint:** /sync/auto-link-books
**Authentication:** Bearer token
**Request Body:**
- `confidence_threshold` (number, optional): Minimum confidence score for auto-linking (0-1, default: 0.8)
- `limit` (number, optional): Maximum number of books to auto-link (default: 50)
**Response:**
- `results` (array): Results for each auto-link attempt
- `total` (number): Total number of books processed
- `success` (number): Number of successful links
- `failed` (number): Number of failed links
**Status Codes:**
- 200: Success
- 400: Invalid request data
- 401: Unauthorized
- 500: Internal server error
**Note:** Higher confidence thresholds produce fewer but more accurate matches. Consider the tradeoff between automation and accuracy.
docs: "## Auto-Link Unlinked Books\n\n Automatically links unlinked books to media items based on title and author matching with a configurable confidence threshold.\n\n **Method:** POST\n\n **Endpoint:** /sync/auto-link-books\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `confidence_threshold` (number, optional): Minimum confidence score for auto-linking (0-1, default: 0.8)\n - `limit` (number, optional): Maximum number of books to auto-link (default: 50)\n\n **Response:**\n - `results` (array): Results for each auto-link attempt\n - `total` (number): Total number of books processed\n - `success` (number): Number of successful links\n - `failed` (number): Number of failed links\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 500: Internal server error\n\n **Note:** Higher confidence thresholds produce fewer but more accurate matches. Consider the tradeoff between automation and accuracy."
@@ -4,44 +4,14 @@ info:
seq: 1
http:
method: POST
url: '{{base_url}}/sync/bulk-link-books'
url: '{{base_url}}/api/sync/bulk-link-books'
auth: inherit
body:
type: json
jsonBody: "{\n \"links\": [\n {\n \"unlinked_book_id\": \"{{unlinkedBookId1"
data: "{\n \"links\": [\n {\n \"unlinked_book_id\": \"{{unlinkedBookId1"
headers:
- key: Content-Type
value: application/json
- key: Authorization
value: Bearer {{authToken
docs: |-
## Bulk Link Unlinked Books
Links multiple unlinked books to media items in a single request.
**Method:** POST
**Endpoint:** /sync/bulk-link-books
**Authentication:** Bearer token
**Request Body:**
- `links` (array): Array of link objects
- `unlinked_book_id` (string): Unlinked book UUID
- `media_item_id` (string): Media item UUID to link to
- `confidence_score` (number): Match confidence (0-1)
**Response:**
- `results` (array): Results for each link attempt
- `total` (number): Total number of links processed
- `success` (number): Number of successful links
- `failed` (number): Number of failed links
**Status Codes:**
- 200: Success
- 400: Invalid request data
- 401: Unauthorized
- 500: Internal server error
**Note:** Use this endpoint after reviewing suggestions from the Get Unlinked Book Suggestions endpoint.
docs: "## Bulk Link Unlinked Books\n\n Links multiple unlinked books to media items in a single request.\n\n **Method:** POST\n\n **Endpoint:** /sync/bulk-link-books\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `links` (array): Array of link objects\n - `unlinked_book_id` (string): Unlinked book UUID\n - `media_item_id` (string): Media item UUID to link to\n - `confidence_score` (number): Match confidence (0-1)\n\n **Response:**\n - `results` (array): Results for each link attempt\n - `total` (number): Total number of links processed\n - `success` (number): Number of successful links\n - `failed` (number): Number of failed links\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request data\n - 401: Unauthorized\n - 500: Internal server error\n\n **Note:** Use this endpoint after reviewing suggestions from the Get Unlinked Book Suggestions endpoint."
@@ -5,7 +5,7 @@ info:
http:
method: GET
url: '{{base_url}}/api/sync/kobo/v1/initialization'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/v1/initialization'
auth: inherit
docs: |-
@@ -4,50 +4,13 @@ info:
seq: 1
http:
method: POST
url: '{{baseURL}}/api/sync/kobo/sync-from-server'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/sync-from-server'
auth: inherit
body:
type: json
jsonBody: "[\n {\n \"ContentId\": \"{{bookUUID"
data: "[\n {\n \"ContentId\": \"{{bookUUID"
headers:
- key: Authorization
value: Bearer {{koboToken
docs: |-
## Sync from Bookhoard to Kobo
Server-initiated sync pushing progress, bookmarks, and highlights from Bookhoard to Kobo device. Two-way sync endpoint.
**Method:** POST
**Endpoint:** /api/sync/kobo/sync-from-server
**Authentication:** Bearer token with Kobo device identification
**Headers:**
- `x-kobo-device` (string): JSON string containing Kobo device info
- `DeviceId`: Kobo device ID
- `Model`: Kobo device model
- `SerialNumber`: Kobo device serial number
**Request Body:** Array of sync data objects
- `ContentId` (string): Book UUID
- `PercentRead` (number): Reading progress percentage (0-100)
- `LastModified` (string): ISO 8601 timestamp
- `Bookmarks` (array, optional): Array of bookmark objects
- `BookmarkId`: Unique bookmark ID
- `ContentId`: Book UUID
- `BookmarkText`: Bookmark text/note
- `BookmarkType`: Type (bookmark, annotation, etc.)
- `BookmarkTitle`: Bookmark title
- `Highlights` (array, optional): Array of highlight objects (same structure as bookmarks)
**Response:**
- Sync result confirmation
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
**Note:** Allows Bookhoard server to push updates to Kobo device, including reading progress, bookmarks, and highlights.
docs: "## Sync from Bookhoard to Kobo\n\n Server-initiated sync pushing progress, bookmarks, and highlights from Bookhoard to Kobo device. Two-way sync endpoint.\n\n **Method:** POST\n\n **Endpoint:** /api/sync/kobo/sync-from-server\n\n **Authentication:** Bearer token with Kobo device identification\n\n **Headers:**\n - `x-kobo-device` (string): JSON string containing Kobo device info\n - `DeviceId`: Kobo device ID\n - `Model`: Kobo device model\n - `SerialNumber`: Kobo device serial number\n\n **Request Body:** Array of sync data objects\n - `ContentId` (string): Book UUID\n - `PercentRead` (number): Reading progress percentage (0-100)\n - `LastModified` (string): ISO 8601 timestamp\n - `Bookmarks` (array, optional): Array of bookmark objects\n - `BookmarkId`: Unique bookmark ID\n - `ContentId`: Book UUID\n - `BookmarkText`: Bookmark text/note\n - `BookmarkType`: Type (bookmark, annotation, etc.)\n - `BookmarkTitle`: Bookmark title\n - `Highlights` (array,\
\ optional): Array of highlight objects (same structure as bookmarks)\n\n **Response:**\n - Sync result confirmation\n\n **Status Codes:**\n - 200: Success\n - 401: Unauthorized\n - 500: Internal server error\n\n **Note:** Allows Bookhoard server to push updates to Kobo device, including reading progress, bookmarks, and highlights."
@@ -2,105 +2,16 @@ info:
name: Sync Books from Server to Kobo
type: http
seq: 9
http:
method: POST
url: '{{base_url}}/api/sync/kobo/sync-from-server'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/sync-from-server'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: Content-Type
value: application/json
body:
type: json
json:
force_sync: true
books:
- book-uuid-1
- book-uuid-2
docs: |-
## Sync Books from Server to Kobo
Pulls reading progress, annotations, and bookmarks from server to Kobo device.
**Method:** POST
**Endpoint:** /api/sync/kobo/sync-from-server
**Authentication:** Bearer token
**Request Body:**
- `force_sync` (boolean): Force full sync (ignore last sync time)
- `books` (array, optional): List of book UUIDs to sync
- If empty, syncs all books with server data
- If specified, only syncs listed books
- `sync_options` (object, optional):
- `since_date` (string): ISO 8601 timestamp for incremental sync
- `include_annotations` (boolean): Include highlights/notes
- `include_progress` (boolean): Include reading progress
- `conflict_resolution` (string): `most_recent`, `server_wins`, `client_wins`
**Response:**
- `synced` (integer): Number of books synced
- `skipped` (integer): Books with no server changes
- `failed` (integer): Books that failed to sync
- `results` (array): Per-book sync results
- `book_id` (string): Book UUID
- `status` (string): `synced`, `skipped`, `failed`
- `progress_pulled` (boolean): Whether progress was downloaded
- `annotations_pulled` (integer): Number of annotations downloaded
- `error` (string, optional): Error message if failed
- `server_timestamp` (string): Server timestamp of sync
**Status Codes:**
- 200: Sync completed
- 207: Multi-status (some succeeded, some failed)
- 401: Unauthorized
- 400: Invalid request
**Sync Direction:**
- **Server → Device** (this endpoint)
- Device → Server: Use `/sync/kobo/markup` endpoint
- Bidirectional sync achieved by calling both
**Pull Sync Use Cases:**
- New device setup (download all progress)
- Device replacement (restore from server)
- Multi-device sync (pull changes from other devices)
- Conflict resolution (server wins)
- Manual "Download from server" operation
**Conflict Resolution:**
- `most_recent`: Latest timestamp wins (default)
- `server_wins`: Server data always used
- `client_wins`: Device data preserved
- Applied per-book, per-item
**Sync Optimization:**
- Incremental sync by default (since last sync)
- Force sync does full comparison
- Book-level batching (10 books per batch)
- Delta transfer (only changed items)
- Compression for large annotation sets
**Kobo Device Behavior:**
- Device updates local database
- Progress reflected in reading view
- Annotations appear in Notebook
- Bookmarks updated in navigation
- Conflict warnings shown to user
- Sync progress displayed on screen
**Performance:**
- Small sync (1-10 books): 2-5 seconds
- Medium sync (10-50 books): 5-15 seconds
- Large sync (50-200 books): 15-45 seconds
- Timeout: 120 seconds
**Use Cases:**
- Initial device sync
- After firmware update
- From another device's changes
- Manual sync request
- Conflict recovery
- Data restoration
data: "{\n \"force_sync\": true,\n \"books\": [\n \"book-uuid-1\",\n \"book-uuid-2\"\n ]\n}"
docs: "## Sync Books from Server to Kobo\n\nPulls reading progress, annotations, and bookmarks from server to Kobo device.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/kobo/sync-from-server\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `force_sync` (boolean): Force full sync (ignore last sync time)\n- `books` (array, optional): List of book UUIDs to sync\n - If empty, syncs all books with server data\n - If specified, only syncs listed books\n- `sync_options` (object, optional):\n - `since_date` (string): ISO 8601 timestamp for incremental sync\n - `include_annotations` (boolean): Include highlights/notes\n - `include_progress` (boolean): Include reading progress\n - `conflict_resolution` (string): `most_recent`, `server_wins`, `client_wins`\n\n**Response:**\n- `synced` (integer): Number of books synced\n- `skipped` (integer): Books with no server changes\n- `failed` (integer): Books that failed to sync\n- `results` (array): Per-book sync results\n - `book_id` (string):\
\ Book UUID\n - `status` (string): `synced`, `skipped`, `failed`\n - `progress_pulled` (boolean): Whether progress was downloaded\n - `annotations_pulled` (integer): Number of annotations downloaded\n - `error` (string, optional): Error message if failed\n- `server_timestamp` (string): Server timestamp of sync\n\n**Status Codes:**\n- 200: Sync completed\n- 207: Multi-status (some succeeded, some failed)\n- 401: Unauthorized\n- 400: Invalid request\n\n**Sync Direction:**\n- **Server → Device** (this endpoint)\n- Device → Server: Use `/sync/kobo/markup` endpoint\n- Bidirectional sync achieved by calling both\n\n**Pull Sync Use Cases:**\n- New device setup (download all progress)\n- Device replacement (restore from server)\n- Multi-device sync (pull changes from other devices)\n- Conflict resolution (server wins)\n- Manual \"Download from server\" operation\n\n**Conflict Resolution:**\n- `most_recent`: Latest timestamp wins (default)\n- `server_wins`: Server data always used\n- `client_wins`:\
\ Device data preserved\n- Applied per-book, per-item\n\n**Sync Optimization:**\n- Incremental sync by default (since last sync)\n- Force sync does full comparison\n- Book-level batching (10 books per batch)\n- Delta transfer (only changed items)\n- Compression for large annotation sets\n\n**Kobo Device Behavior:**\n- Device updates local database\n- Progress reflected in reading view\n- Annotations appear in Notebook\n- Bookmarks updated in navigation\n- Conflict warnings shown to user\n- Sync progress displayed on screen\n\n**Performance:**\n- Small sync (1-10 books): 2-5 seconds\n- Medium sync (10-50 books): 5-15 seconds\n- Large sync (50-200 books): 15-45 seconds\n- Timeout: 120 seconds\n\n**Use Cases:**\n- Initial device sync\n- After firmware update\n- From another device's changes\n- Manual sync request\n- Conflict recovery\n- Data restoration"
@@ -2,90 +2,17 @@ info:
name: Sync Multiple Books Progress
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/sync/kobo/markup'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/markup'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Aura"}'
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Aura"}'
body:
type: json
json:
ReadingSync:
- ContentId: book-1-uuid
PercentRead: 25.0
EntitlementId: entitlement-1
RemainingTimeMinutes: 240
FirstReadTime: '2026-01-25T10:00:00Z'
LastModified: '2026-01-30T18:00:00Z'
- ContentId: book-2-uuid
PercentRead: 78.5
EntitlementId: entitlement-2
RemainingTimeMinutes: 45
FirstReadTime: '2026-01-25T14:00:00Z'
LastModified: '2026-01-30T20:00:00Z'
BookmarkSync: []
docs: |-
## Sync Multiple Books Progress
Synchronizes reading progress for multiple books in a single request.
**Method:** POST
**Endpoint:** /api/sync/kobo/markup
**Authentication:** Bearer token
**Headers:**
- `Authorization`: Bearer {{kobo_device_token}}
- `x-kobo-device`: Device information (Model: "Kobo Aura")
**Request Body:**
- `ReadingSync` (array): Multiple progress items
- Each item contains: ContentId, PercentRead, EntitlementId, etc.
- `BookmarkSync` (array): Empty for progress-only batch
**Response:**
- `total` (integer): Total items in request
- `synced` (integer): Successfully synced
- `failed` (integer): Failed items
- `results` (array): Per-item results
- `ContentId` (string): Book UUID
- `status` (string): `synced`, `failed`, `skipped`
- `error` (string, optional): Error message if failed
**Status Codes:**
- 200: Batch sync completed
- 207: Multi-status (some succeeded, some failed)
- 401: Unauthorized
- 413: Payload too large (>1MB)
**Batch Sync Advantages:**
- Efficient sync of entire library
- Reduces HTTP overhead
- Faster for devices with many books
- Atomic operation (all or nothing by default)
**Kobo Batch Sync Behavior:**
- Triggered when device connects after being offline
- Occurs during manual "Sync now" operation
- Limited to 100 books per request
- Progress updates shown on device
- Failed items retried individually
**Performance:**
- Typical batch: 10-50 books in 1-3 seconds
- Large batch: 50-100 books in 3-8 seconds
- Timeout: 30 seconds
- Rate limit: 10 batches per minute per device
**Use Cases:**
- Initial device sync after registration
- Catch-up sync after extended offline period
- Library-wide progress update
- Pre-sync before device firmware update
data: "{\n \"ReadingSync\": [\n {\n \"ContentId\": \"book-1-uuid\",\n \"PercentRead\": 25.0,\n \"EntitlementId\": \"entitlement-1\",\n \"RemainingTimeMinutes\": 240,\n \"FirstReadTime\": \"2026-01-25T10:00:00Z\",\n \"LastModified\": \"2026-01-30T18:00:00Z\"\n },\n {\n \"ContentId\": \"book-2-uuid\",\n \"PercentRead\": 78.5,\n \"EntitlementId\": \"entitlement-2\",\n \"RemainingTimeMinutes\": 45,\n \"FirstReadTime\": \"2026-01-25T14:00:00Z\",\n \"LastModified\": \"2026-01-30T20:00:00Z\"\n }\n ],\n \"BookmarkSync\": []\n}"
docs: "## Sync Multiple Books Progress\n\nSynchronizes reading progress for multiple books in a single request.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/kobo/markup\n\n**Authentication:** Bearer token\n\n**Headers:**\n- `Authorization`: Bearer {{kobo_device_token}}\n- `x-kobo-device`: Device information (Model: \"Kobo Aura\")\n\n**Request Body:**\n- `ReadingSync` (array): Multiple progress items\n - Each item contains: ContentId, PercentRead, EntitlementId, etc.\n- `BookmarkSync` (array): Empty for progress-only batch\n\n**Response:**\n- `total` (integer): Total items in request\n- `synced` (integer): Successfully synced\n- `failed` (integer): Failed items\n- `results` (array): Per-item results\n - `ContentId` (string): Book UUID\n - `status` (string): `synced`, `failed`, `skipped`\n - `error` (string, optional): Error message if failed\n\n**Status Codes:**\n- 200: Batch sync completed\n- 207: Multi-status (some succeeded, some failed)\n- 401: Unauthorized\n- 413: Payload too large\
\ (>1MB)\n\n**Batch Sync Advantages:**\n- Efficient sync of entire library\n- Reduces HTTP overhead\n- Faster for devices with many books\n- Atomic operation (all or nothing by default)\n\n**Kobo Batch Sync Behavior:**\n- Triggered when device connects after being offline\n- Occurs during manual \"Sync now\" operation\n- Limited to 100 books per request\n- Progress updates shown on device\n- Failed items retried individually\n\n**Performance:**\n- Typical batch: 10-50 books in 1-3 seconds\n- Large batch: 50-100 books in 3-8 seconds\n- Timeout: 30 seconds\n- Rate limit: 10 batches per minute per device\n\n**Use Cases:**\n- Initial device sync after registration\n- Catch-up sync after extended offline period\n- Library-wide progress update\n- Pre-sync before device firmware update"
@@ -2,83 +2,17 @@ info:
name: Sync Progress with Bookmarks
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/sync/kobo/markup'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/markup'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Clara"}'
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Clara"}'
body:
type: json
json:
ReadingSync:
- ContentId: book-uuid
PercentRead: 42.3
EntitlementId: entitlement-id
RemainingTimeMinutes: 138
FirstReadTime: '2026-01-25T10:00:00Z'
LastModified: '2026-01-30T20:00:00Z'
BookmarkSync:
- ContentId: book-uuid
BookmarkText: highlighted text passage
BookmarkType: annotation
BookmarkTitle: Chapter 3
docs: |-
## Sync Progress with Bookmarks
Synchronizes reading progress and highlights/annotations from Kobo device.
**Method:** POST
**Endpoint:** /api/sync/kobo/markup
**Authentication:** Bearer token
**Headers:**
- `Authorization`: Bearer {{kobo_device_token}}
- `x-kobo-device`: Device information JSON
**Request Body:**
- `ReadingSync` (array): Progress items (see Sync Reading Progress)
- `BookmarkSync` (array): Highlights and annotations
- `ContentId` (string): Book UUID
- `BookmarkText` (string): Highlighted text or note content
- `BookmarkType` (string): Type of bookmark
- `annotation`: Highlighted text
- `note`: Personal note
- `bookmark`: Location bookmark
- `BookmarkTitle` (string): Reference (e.g., chapter name)
- `ChapterID` (string, optional): Chapter identifier
- `DateCreated` (string, optional): Creation timestamp
**Response:**
- `progress_synced` (integer): Progress items synced
- `bookmarks_synced` (integer): Bookmark items synced
- `conflicts_resolved` (integer): Number of conflicts auto-resolved
- `timestamp` (string): Sync timestamp
- `details` (object): Sync breakdown by type
**Status Codes:**
- 200: Successful sync
- 401: Unauthorized
- 400: Invalid data
**Kobo Highlight Features:**
- 5 highlight colors (yellow, green, blue, pink, orange)
- Chapter-based organization
- Linked to reading progress
- Appears in Kobo "Notebook" view
- Can be exported from device
- Syncs across all user devices
**Annotation Sync:**
- Highlight text preserved exactly
- Color mapped to system colors
- Chapter reference maintained
- Location data converted to standard format
- Notes attached to highlights synced separately
data: "{\n \"ReadingSync\": [\n {\n \"ContentId\": \"book-uuid\",\n \"PercentRead\": 42.3,\n \"EntitlementId\": \"entitlement-id\",\n \"RemainingTimeMinutes\": 138,\n \"FirstReadTime\": \"2026-01-25T10:00:00Z\",\n \"LastModified\": \"2026-01-30T20:00:00Z\"\n }\n ],\n \"BookmarkSync\": [\n {\n \"ContentId\": \"book-uuid\",\n \"BookmarkText\": \"highlighted text passage\",\n \"BookmarkType\": \"annotation\",\n \"BookmarkTitle\": \"Chapter 3\"\n }\n ]\n}"
docs: "## Sync Progress with Bookmarks\n\nSynchronizes reading progress and highlights/annotations from Kobo device.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/kobo/markup\n\n**Authentication:** Bearer token\n\n**Headers:**\n- `Authorization`: Bearer {{kobo_device_token}}\n- `x-kobo-device`: Device information JSON\n\n**Request Body:**\n- `ReadingSync` (array): Progress items (see Sync Reading Progress)\n- `BookmarkSync` (array): Highlights and annotations\n - `ContentId` (string): Book UUID\n - `BookmarkText` (string): Highlighted text or note content\n - `BookmarkType` (string): Type of bookmark\n - `annotation`: Highlighted text\n - `note`: Personal note\n - `bookmark`: Location bookmark\n - `BookmarkTitle` (string): Reference (e.g., chapter name)\n - `ChapterID` (string, optional): Chapter identifier\n - `DateCreated` (string, optional): Creation timestamp\n\n**Response:**\n- `progress_synced` (integer): Progress items synced\n- `bookmarks_synced` (integer): Bookmark\
\ items synced\n- `conflicts_resolved` (integer): Number of conflicts auto-resolved\n- `timestamp` (string): Sync timestamp\n- `details` (object): Sync breakdown by type\n\n**Status Codes:**\n- 200: Successful sync\n- 401: Unauthorized\n- 400: Invalid data\n\n**Kobo Highlight Features:**\n- 5 highlight colors (yellow, green, blue, pink, orange)\n- Chapter-based organization\n- Linked to reading progress\n- Appears in Kobo \"Notebook\" view\n- Can be exported from device\n- Syncs across all user devices\n\n**Annotation Sync:**\n- Highlight text preserved exactly\n- Color mapped to system colors\n- Chapter reference maintained\n- Location data converted to standard format\n- Notes attached to highlights synced separately"
@@ -2,83 +2,18 @@ info:
name: Sync Reading Progress
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/sync/kobo/markup'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/markup'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Clara"}'
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Clara"}'
body:
type: json
json:
ReadingSync:
- ContentId: book-uuid-here
PercentRead: 45.6
EntitlementId: entitlement-id-here
RemainingTimeMinutes: 120
FirstReadTime: '2026-01-25T10:00:00Z'
LastModified: '2026-01-30T20:00:00Z'
BookmarkSync: []
docs: |-
## Sync Kobo Reading Progress
Synchronizes reading progress from a Kobo device to the server using Bearer token authentication.
**Method:** POST
**Endpoint:** /api/sync/kobo/markup
**Authentication:** Bearer token (in Authorization header)
**Headers:**
- `Authorization`: Bearer {{kobo_device_token}}
- `x-kobo-device`: JSON-encoded device info
- `DeviceId`: Kobo device identifier
- `Model`: Device model (e.g., "Kobo Clara", "Kobo Libra", "Kobo Aura")
**Request Body:**
- `ReadingSync` (array): Reading progress items
- `ContentId` (string): Book/Content UUID
- `PercentRead` (number): Reading progress 0-100
- `EntitlementId` (string): Kobo entitlement ID
- `RemainingTimeMinutes` (integer): Estimated reading time remaining
- `FirstReadTime` (string): ISO 8601 timestamp when first opened
- `LastModified` (string): ISO 8601 timestamp of last progress update
- `BookmarkSync` (array): Empty array for progress-only sync
**Response:**
- `synced` (integer): Number of items synced
- `failed` (integer): Number of items that failed to sync
- `timestamp` (string): Server timestamp of sync
- `books` (array): Synced book data
- `ContentId` (string): Book UUID
- `status` (string): `synced`, `failed`, `skipped`
- `server_percent` (number): Server-side progress (for conflict detection)
**Status Codes:**
- 200: Sync successful
- 401: Invalid device token
- 403: Device not approved
- 400: Invalid request format
**Authentication Methods:**
This endpoint uses **Bearer token authentication** (token in Authorization header).
Alternative: Use `/sync/kobo/{token}/markup` with token in URL path.
**Kobo Sync Features:**
- Native Kobo sync protocol
- Supports Kobo Clara, Libra, Aura, Forma, Sage, Elipsa
- Progress percentage tracking
- Reading time estimation
- Cross-device synchronization
- Automatic conflict resolution (most recent wins)
**Sync Frequency:**
- Kobo devices auto-sync every 15-30 minutes when connected to WiFi
- Manual sync available from device settings
- Sync triggers on: device wake, book close, WiFi connection
data: "{\n \"ReadingSync\": [\n {\n \"ContentId\": \"book-uuid-here\",\n \"PercentRead\": 45.6,\n \"EntitlementId\": \"entitlement-id-here\",\n \"RemainingTimeMinutes\": 120,\n \"FirstReadTime\": \"2026-01-25T10:00:00Z\",\n \"LastModified\": \"2026-01-30T20:00:00Z\"\n }\n ],\n \"BookmarkSync\": []\n}"
docs: "## Sync Kobo Reading Progress\n\nSynchronizes reading progress from a Kobo device to the server using Bearer token authentication.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/kobo/markup\n\n**Authentication:** Bearer token (in Authorization header)\n\n**Headers:**\n- `Authorization`: Bearer {{kobo_device_token}}\n- `x-kobo-device`: JSON-encoded device info\n - `DeviceId`: Kobo device identifier\n - `Model`: Device model (e.g., \"Kobo Clara\", \"Kobo Libra\", \"Kobo Aura\")\n\n**Request Body:**\n- `ReadingSync` (array): Reading progress items\n - `ContentId` (string): Book/Content UUID\n - `PercentRead` (number): Reading progress 0-100\n - `EntitlementId` (string): Kobo entitlement ID\n - `RemainingTimeMinutes` (integer): Estimated reading time remaining\n - `FirstReadTime` (string): ISO 8601 timestamp when first opened\n - `LastModified` (string): ISO 8601 timestamp of last progress update\n- `BookmarkSync` (array): Empty array for progress-only sync\n\n**Response:**\n\
- `synced` (integer): Number of items synced\n- `failed` (integer): Number of items that failed to sync\n- `timestamp` (string): Server timestamp of sync\n- `books` (array): Synced book data\n - `ContentId` (string): Book UUID\n - `status` (string): `synced`, `failed`, `skipped`\n - `server_percent` (number): Server-side progress (for conflict detection)\n\n**Status Codes:**\n- 200: Sync successful\n- 401: Invalid device token\n- 403: Device not approved\n- 400: Invalid request format\n\n**Authentication Methods:**\nThis endpoint uses **Bearer token authentication** (token in Authorization header).\nAlternative: Use `/sync/kobo/{token}/markup` with token in URL path.\n\n**Kobo Sync Features:**\n- Native Kobo sync protocol\n- Supports Kobo Clara, Libra, Aura, Forma, Sage, Elipsa\n- Progress percentage tracking\n- Reading time estimation\n- Cross-device synchronization\n- Automatic conflict resolution (most recent wins)\n\n**Sync Frequency:**\n- Kobo devices auto-sync every 15-30 minutes\
\ when connected to WiFi\n- Manual sync available from device settings\n- Sync triggers on: device wake, book close, WiFi connection"
@@ -2,91 +2,18 @@ info:
name: Sync Single Bookmark
type: http
seq: 5
http:
method: POST
url: '{{base_url}}/api/sync/kobo/bookmark'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/bookmark'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Clara"}'
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Clara"}'
body:
type: json
json:
ContentId: book-uuid
BookmarkText: Bookmarked passage
BookmarkType: annotation
BookmarkTitle: Chapter 3
docs: |-
## Sync Single Bookmark
Synchronizes an individual bookmark/highlight from Kobo device.
**Method:** POST
**Endpoint:** /api/sync/kobo/bookmark
**Authentication:** Bearer token
**Headers:**
- `Authorization`: Bearer {{kobo_device_token}}
- `x-kobo-device`: Device information JSON
**Request Body:**
- `ContentId` (string): Book UUID
- `BookmarkText` (string): Highlighted text or bookmark description
- `BookmarkType` (string): Type of bookmark
- `annotation`: Highlighted text passage
- `note`: Personal note
- `bookmark`: Location marker
- `BookmarkTitle` (string): Reference title (e.g., chapter name)
- `ChapterID` (string, optional): Chapter identifier
- `DateCreated` (string, optional): ISO 8601 timestamp
- `highlight_color` (string, optional): Color name (yellow, green, blue, pink, orange)
**Response:**
- `id` (string): Server bookmark ID
- `ContentId` (string): Associated book UUID
- `status` (string): `created`, `updated`, `skipped` (duplicate)
- `timestamp` (string): Server timestamp
- `url` (string): API URL to retrieve bookmark
**Status Codes:**
- 201: Bookmark created
- 200: Bookmark updated (duplicate found)
- 409: Duplicate bookmark (unchanged)
- 401: Unauthorized
- 400: Invalid bookmark data
**Single Bookmark Sync vs Batch:**
- **Single bookmark endpoint:** Real-time, immediate sync
- **Batch markup endpoint:** Deferred, periodic sync
- Use single when user explicitly creates highlight
- Use batch for periodic background sync
**Kobo Trigger:**
- User highlights text → immediate single sync
- User adds note → immediate single sync
- Device goes online → batch sync of all changes
**Duplicate Detection:**
- Same ContentId + similar BookmarkText + same location
- Updates existing if text modified
- Skips if identical bookmark exists
- Preserves creation date of original
**Use Cases:**
- Real-time highlight sync
- Instant note backup
- Immediate annotation sharing
- Quick single annotation update
- Testing annotation sync
**Notes:**
- Much faster than full markup sync
- Lower bandwidth usage
- Ideal for intermittent connectivity
- Can be called multiple times safely
data: "{\n \"ContentId\": \"book-uuid\",\n \"BookmarkText\": \"Bookmarked passage\",\n \"BookmarkType\": \"annotation\",\n \"BookmarkTitle\": \"Chapter 3\"\n}"
docs: "## Sync Single Bookmark\n\nSynchronizes an individual bookmark/highlight from Kobo device.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/kobo/bookmark\n\n**Authentication:** Bearer token\n\n**Headers:**\n- `Authorization`: Bearer {{kobo_device_token}}\n- `x-kobo-device`: Device information JSON\n\n**Request Body:**\n- `ContentId` (string): Book UUID\n- `BookmarkText` (string): Highlighted text or bookmark description\n- `BookmarkType` (string): Type of bookmark\n - `annotation`: Highlighted text passage\n - `note`: Personal note\n - `bookmark`: Location marker\n- `BookmarkTitle` (string): Reference title (e.g., chapter name)\n- `ChapterID` (string, optional): Chapter identifier\n- `DateCreated` (string, optional): ISO 8601 timestamp\n- `highlight_color` (string, optional): Color name (yellow, green, blue, pink, orange)\n\n**Response:**\n- `id` (string): Server bookmark ID\n- `ContentId` (string): Associated book UUID\n- `status` (string): `created`, `updated`, `skipped` (duplicate)\n\
- `timestamp` (string): Server timestamp\n- `url` (string): API URL to retrieve bookmark\n\n**Status Codes:**\n- 201: Bookmark created\n- 200: Bookmark updated (duplicate found)\n- 409: Duplicate bookmark (unchanged)\n- 401: Unauthorized\n- 400: Invalid bookmark data\n\n**Single Bookmark Sync vs Batch:**\n- **Single bookmark endpoint:** Real-time, immediate sync\n- **Batch markup endpoint:** Deferred, periodic sync\n- Use single when user explicitly creates highlight\n- Use batch for periodic background sync\n\n**Kobo Trigger:**\n- User highlights text → immediate single sync\n- User adds note → immediate single sync\n- Device goes online → batch sync of all changes\n\n**Duplicate Detection:**\n- Same ContentId + similar BookmarkText + same location\n- Updates existing if text modified\n- Skips if identical bookmark exists\n- Preserves creation date of original\n\n**Use Cases:**\n- Real-time highlight sync\n- Instant note backup\n- Immediate annotation sharing\n- Quick single annotation\
\ update\n- Testing annotation sync\n\n**Notes:**\n- Much faster than full markup sync\n- Lower bandwidth usage\n- Ideal for intermittent connectivity\n- Can be called multiple times safely"
@@ -2,105 +2,17 @@ info:
name: Sync with Annotations
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/sync/kobo/markup'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/markup'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Libra"}'
- key: Content-Type
value: application/json
- key: x-kobo-device
value: '{"DeviceId":"{{kobo_device_id}}","Model":"Kobo Libra"}'
body:
type: json
json:
ReadingSync:
- ContentId: book-uuid
PercentRead: 55.0
EntitlementId: entitlement-id
RemainingTimeMinutes: 120
FirstReadTime: '2026-01-25T10:00:00Z'
LastModified: '2026-01-30T20:00:00Z'
BookmarkSync:
- ContentId: book-uuid
BookmarkText: Important quote
BookmarkType: annotation
BookmarkTitle: Chapter 4 - The Truth
- ContentId: book-uuid
BookmarkText: Another quote
BookmarkType: annotation
BookmarkTitle: Chapter 5
- ContentId: book-uuid
BookmarkText: Note to myself
BookmarkType: note
BookmarkTitle: Personal note
docs: |-
## Sync with Multiple Annotations
Synchronizes reading progress with multiple highlights and notes.
**Method:** POST
**Endpoint:** /api/sync/kobo/markup
**Authentication:** Bearer token
**Headers:**
- `Authorization`: Bearer {{kobo_device_token}}
- `x-kobo-device`: Device info (Model: "Kobo Libra")
**Request Body:**
- `ReadingSync` (array): Single book progress
- `BookmarkSync` (array): Multiple annotations
- Can include highlights (annotation type)
- Can include notes (note type)
- Each has: ContentId, BookmarkText, BookmarkType, BookmarkTitle
**Response:**
- `progress_synced` (boolean): Progress update status
- `annotations_synced` (integer): Number of annotations synced
- `highlights_count` (integer): Highlights synced
- `notes_count` (integer): Notes synced
- `conflicts` (array): Any annotation conflicts resolved
- `timestamp` (string): Sync completion time
**Status Codes:**
- 200: Successful sync
- 401: Unauthorized
- 400: Invalid annotation data
**Kobo Annotation Types:**
- **Highlights:** Selected text passages
- 5 preset colors available
- Can have chapter titles
- Exportable to PDF/Mobile
- **Notes:** Personal annotations
- Free-form text
- Can be attached to highlights
- Separate from highlights
- **Bookmarks:** Location markers
- Chapter positions
- Quick navigation
**Sync Behavior:**
- Duplicates detected by content matching
- Most recent edit wins conflicts
- Annotations linked to book content
- Chapter references preserved
- Order maintained from device
**Kobo Notebook Export:**
- All annotations appear in Kobo "Notebook"
- Can be exported to PDF
- Can be exported to Mobile (text)
- Organized by book
- Shows highlight context
**Use Cases:**
- Study and research
- Book club discussion prep
- Content review
- Sharing insights
- Personal learning archive
data: "{\n \"ReadingSync\": [\n {\n \"ContentId\": \"book-uuid\",\n \"PercentRead\": 55.0,\n \"EntitlementId\": \"entitlement-id\",\n \"RemainingTimeMinutes\": 120,\n \"FirstReadTime\": \"2026-01-25T10:00:00Z\",\n \"LastModified\": \"2026-01-30T20:00:00Z\"\n }\n ],\n \"BookmarkSync\": [\n {\n \"ContentId\": \"book-uuid\",\n \"BookmarkText\": \"Important quote\",\n \"BookmarkType\": \"annotation\",\n \"BookmarkTitle\": \"Chapter 4 - The Truth\"\n },\n {\n \"ContentId\": \"book-uuid\",\n \"BookmarkText\": \"Another quote\",\n \"BookmarkType\": \"annotation\",\n \"BookmarkTitle\": \"Chapter 5\"\n },\n {\n \"ContentId\": \"book-uuid\",\n \"BookmarkText\": \"Note to myself\",\n \"BookmarkType\": \"note\",\n \"BookmarkTitle\": \"Personal note\"\n }\n ]\n}"
docs: "## Sync with Multiple Annotations\n\nSynchronizes reading progress with multiple highlights and notes.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/kobo/markup\n\n**Authentication:** Bearer token\n\n**Headers:**\n- `Authorization`: Bearer {{kobo_device_token}}\n- `x-kobo-device`: Device info (Model: \"Kobo Libra\")\n\n**Request Body:**\n- `ReadingSync` (array): Single book progress\n- `BookmarkSync` (array): Multiple annotations\n - Can include highlights (annotation type)\n - Can include notes (note type)\n - Each has: ContentId, BookmarkText, BookmarkType, BookmarkTitle\n\n**Response:**\n- `progress_synced` (boolean): Progress update status\n- `annotations_synced` (integer): Number of annotations synced\n- `highlights_count` (integer): Highlights synced\n- `notes_count` (integer): Notes synced\n- `conflicts` (array): Any annotation conflicts resolved\n- `timestamp` (string): Sync completion time\n\n**Status Codes:**\n- 200: Successful sync\n- 401: Unauthorized\n- 400: Invalid\
\ annotation data\n\n**Kobo Annotation Types:**\n- **Highlights:** Selected text passages\n - 5 preset colors available\n - Can have chapter titles\n - Exportable to PDF/Mobile\n- **Notes:** Personal annotations\n - Free-form text\n - Can be attached to highlights\n - Separate from highlights\n- **Bookmarks:** Location markers\n - Chapter positions\n - Quick navigation\n\n**Sync Behavior:**\n- Duplicates detected by content matching\n- Most recent edit wins conflicts\n- Annotations linked to book content\n- Chapter references preserved\n- Order maintained from device\n\n**Kobo Notebook Export:**\n- All annotations appear in Kobo \"Notebook\"\n- Can be exported to PDF\n- Can be exported to Mobile (text)\n- Organized by book\n- Shows highlight context\n\n**Use Cases:**\n- Study and research\n- Book club discussion prep\n- Content review\n- Sharing insights\n- Personal learning archive"
+3 -33
View File
@@ -4,42 +4,12 @@ info:
seq: 3
http:
method: POST
url: '{{base_url}}/api/v1/kobo/bookmark'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/bookmark'
auth: inherit
body:
type: json
jsonBody: "{\n \"BookmarkSync\": [\n {\n \"BookmarkId\": \"bookmark_2\"\
,\n \"ContentId\": \"kobo_xyz789\",\n \"BookmarkText\": \"Important\
\ note\",\n \"BookmarkType\": \"bookmark\",\n \"DateCreated\"\
: \"2026-01-31T12:00:00Z\""
data: "{\n \"BookmarkSync\": [\n {\n \"BookmarkId\": \"bookmark_2\",\n \"ContentId\": \"kobo_xyz789\",\n \"BookmarkText\": \"Important note\",\n \"BookmarkType\": \"bookmark\",\n \"DateCreated\": \"2026-01-31T12:00:00Z\""
headers:
- key: Authorization
value: Bearer {{device_token
docs: |-
## Kobo Bookmark Sync
Synchronizes bookmarks from a Kobo device to the Bookhoard server.
**Method:** POST
**Endpoint:** /api/v1/kobo/bookmark
**Authentication:** Bearer token (device token)
**Request Body:**
- `BookmarkSync` (array): Array of bookmark objects
- `BookmarkId` (string): Unique bookmark ID
- `ContentId` (string): Book/content ID
- `BookmarkText` (string): Bookmark text or note
- `BookmarkType` (string): Type (bookmark, highlight, note)
- `DateCreated` (string): ISO 8601 timestamp
**Response:**
- `Status` (string): Sync status (Success, Partial)
- `BookmarksSynced` (number): Number of bookmarks synced
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
docs: "## Kobo Bookmark Sync\n\n Synchronizes bookmarks from a Kobo device to the Bookhoard server.\n\n **Method:** POST\n\n **Endpoint:** /api/v1/kobo/bookmark\n\n **Authentication:** Bearer token (device token)\n\n **Request Body:**\n - `BookmarkSync` (array): Array of bookmark objects\n - `BookmarkId` (string): Unique bookmark ID\n - `ContentId` (string): Book/content ID\n - `BookmarkText` (string): Bookmark text or note\n - `BookmarkType` (string): Type (bookmark, highlight, note)\n - `DateCreated` (string): ISO 8601 timestamp\n\n **Response:**\n - `Status` (string): Sync status (Success, Partial)\n - `BookmarksSynced` (number): Number of bookmarks synced\n\n **Status Codes:**\n - 200: Success\n - 401: Unauthorized\n - 500: Internal server error"
@@ -4,7 +4,7 @@ info:
seq: 4
http:
method: GET
url: '{{base_url}}/sync/kobo/{{kobo_device_token}}/v1/initialization'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/v1/initialization'
auth: none
body:
type: none
@@ -4,7 +4,7 @@ info:
seq: 3
http:
method: GET
url: '{{base_url}}/sync/kobo/{{kobo_device_token}}/library'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/v1/initialization'
auth: none
body:
type: none
@@ -4,7 +4,7 @@ info:
seq: 4
http:
method: GET
url: '{{base_url}}/api/sync/unlinked-books'
url: '{{base_url}}/api/devices/{{device_id}}/sync/unlinked-books'
auth: inherit
body:
type: none
@@ -1,34 +0,0 @@
info:
name: Kobo Initialization
type: http
seq: 1
http:
method: GET
url: '{{base_url}}/api/v1/kobo/initialization'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{device_token
docs: |-
## Kobo Initialization
Initializes Kobo device sync, returning device resources and account information.
**Method:** GET
**Endpoint:** /api/v1/kobo/initialization
**Authentication:** Bearer token (device token)
**Response:**
- `ContentId` (string): Device content ID
- `Categories` (array): Available categories/collections
- `BookhoardUUID` (string): Bookhoard instance UUID
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
+3 -31
View File
@@ -4,40 +4,12 @@ info:
seq: 5
http:
method: POST
url: '{{base_url}}/api/sync/link-book'
url: '{{base_url}}/api/devices/{{device_id}}/sync/link-book'
auth: inherit
body:
type: json
jsonBody: "{\n \"unlinked_book_id\": \"{{unlinked_book_id"
data: "{\n \"unlinked_book_id\": \"{{unlinked_book_id"
headers:
- key: Authorization
value: Bearer {{user_token
docs: |-
## Link Unlinked Book - Manual Resolution
Manually links an unlinked book to a media item in the library.
**Method:** POST
**Endpoint:** /api/sync/link-book
**Authentication:** Bearer token
**Request Body:**
- `unlinked_book_id` (string): Unlinked book UUID
- `media_item_id` (string): Media item UUID to link to
- `confidence_score` (number): Match confidence (0-1, 1.0 for manual)
**Response:**
- `status` (string): Link status (linked)
- `unlinked_book_id` (string): Unlinked book UUID
- `media_item_id` (string): Media item UUID
- `message` (string): Success message
**Status Codes:**
- 200: Success - book linked
- 400: Invalid request
- 401: Unauthorized
- 404: Book or media item not found
- 500: Internal server error
docs: "## Link Unlinked Book - Manual Resolution\n\n Manually links an unlinked book to a media item in the library.\n\n **Method:** POST\n\n **Endpoint:** /api/sync/link-book\n\n **Authentication:** Bearer token\n\n **Request Body:**\n - `unlinked_book_id` (string): Unlinked book UUID\n - `media_item_id` (string): Media item UUID to link to\n - `confidence_score` (number): Match confidence (0-1, 1.0 for manual)\n\n **Response:**\n - `status` (string): Link status (linked)\n - `unlinked_book_id` (string): Unlinked book UUID\n - `media_item_id` (string): Media item UUID\n - `message` (string): Success message\n\n **Status Codes:**\n - 200: Success - book linked\n - 400: Invalid request\n - 401: Unauthorized\n - 404: Book or media item not found\n - 500: Internal server error"
@@ -1,52 +0,0 @@
info:
name: Kobo Markup Sync
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/v1/kobo/markup'
auth: inherit
body:
type: json
jsonBody: "{\n \"ReadingSync\": [\n {\n \"ContentId\": \"kobo_abc123def456\"\
,\n \"PercentRead\": 60.0,\n \"RemainingTimeMin\": 120,\n \
\ \"ReadingEvent\": \"BookRead\",\n \"LastModified\": \"2026-01-31T12:00:00Z\""
headers:
- key: Authorization
value: Bearer {{device_token
docs: |-
## Kobo Markup Sync
Synchronizes reading progress and markup (highlights, bookmarks) from a Kobo device.
**Method:** POST
**Endpoint:** /api/v1/kobo/markup
**Authentication:** Bearer token (device token)
**Request Body:**
- `ReadingSync` (array, optional): Reading progress data
- `ContentId` (string): Book/content ID
- `PercentRead` (number): Percentage read (0-100)
- `RemainingTimeMin` (number): Remaining time in minutes
- `ReadingEvent` (string): Event type (BookRead, etc.)
- `LastModified` (string): ISO 8601 timestamp
- `BookmarkSync` (array, optional): Bookmark/highlight data
- `BookmarkId` (string): Unique bookmark ID
- `ContentId` (string): Book/content ID
- `BookmarkText` (string): Highlighted/bookmarked text
- `BookmarkType` (string): Type (annotation, bookmark)
- `DateCreated` (string): ISO 8601 timestamp
- `Metadata` (boolean): Whether to include metadata
**Response:**
- `Status` (string): Sync status (Success, Partial)
- `MarkupsSynced` (number): Number of markups synced
- `BookmarksSynced` (number): Number of bookmarks synced
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
@@ -4,22 +4,12 @@ info:
seq: 2
http:
method: POST
url: '{{base_url}}/sync/kobo/{{kobo_device_token}}/bookmark'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/bookmark'
auth: none
body:
type: json
jsonBody: "{\n \"ContentId\": \"book-uuid\",\n \"BookmarkText\": \"Highlighted\
\ text\",\n \"BookmarkType\": \"annotation\",\n \"BookmarkTitle\": \"\
Chapter 3\""
data: "{\n \"ContentId\": \"book-uuid\",\n \"BookmarkText\": \"Highlighted text\",\n \"BookmarkType\": \"annotation\",\n \"BookmarkTitle\": \"Chapter 3\""
headers:
- key: Content-Type
value: application/json
docs: |-
## Sync Bookmark - URL Path Token
Synchronizes bookmarks and annotations from Kobo device using token in URL path.
**Method:** POST
**Endpoint:** /sync/kobo/{kobo_device_token
docs: "## Sync Bookmark - URL Path Token\n\n Synchronizes bookmarks and annotations from Kobo device using token in URL path.\n\n **Method:** POST\n\n **Endpoint:** /sync/kobo/{kobo_device_token"
@@ -4,23 +4,12 @@ info:
seq: 1
http:
method: POST
url: '{{base_url}}/sync/kobo/{{kobo_device_token}}/markup'
url: '{{base_url}}/api/sync/kobo/{{kobo_device_token}}/markup'
auth: none
body:
type: json
jsonBody: "{\n \"ReadingSync\": [\n {\n \"ContentId\": \"book-uuid\"\
,\n \"PercentRead\": 45.6,\n \"EntitlementId\": \"entitlement-id\"\
,\n \"RemainingTimeMinutes\": 120,\n \"FirstReadTime\": \"2026-01-25T10:00:00Z\"\
,\n \"LastModified\": \"2026-01-30T20:00:00Z\""
data: "{\n \"ReadingSync\": [\n {\n \"ContentId\": \"book-uuid\",\n \"PercentRead\": 45.6,\n \"EntitlementId\": \"entitlement-id\",\n \"RemainingTimeMinutes\": 120,\n \"FirstReadTime\": \"2026-01-25T10:00:00Z\",\n \"LastModified\": \"2026-01-30T20:00:00Z\""
headers:
- key: Content-Type
value: application/json
docs: |-
## Sync Reading Progress - URL Path Token
Synchronizes reading progress from Kobo device using token in URL path.
**Method:** POST
**Endpoint:** /sync/kobo/{kobo_device_token
docs: "## Sync Reading Progress - URL Path Token\n\n Synchronizes reading progress from Kobo device using token in URL path.\n\n **Method:** POST\n\n **Endpoint:** /sync/kobo/{kobo_device_token"
+11 -89
View File
@@ -1,92 +1,14 @@
info:
name: Get Book Metadata
type: http
seq: 8
docs: "## Get KOReader Book Metadata\n\nRetrieves metadata for a specific book from the server.\n\n**Method:** GET\n\n**Endpoint:** /api/sync/koreader/metadata/:book_uuid\n\n**Authentication:** Bearer token\n\n**Path Parameters:**\n- `book_uuid` (string): SHA-256 based book identifier\n\n**Response:**\n- `uuid` (string): Book UUID (SHA-256)\n- `title` (string): Book title\n- `authors` (array): Author names\n- `series` (string, optional): Series name\n- `series_index` (number, optional): Position in series\n- `publisher` (string, optional): Publisher name\n- `publication_date` (string, optional): Release date\n- `language` (string, optional): ISO 639-1 language code\n- `description` (string, optional): Book description\n- `cover_url` (string, optional): Cover image URL\n- `cover_thumbnail_url` (string, optional): Thumbnail URL\n- `identifiers` (object): Various identifiers\n - `isbn` (string, optional): ISBN-13\n - `asin` (string, optional): Amazon ASIN\n - `goodreads` (string, optional):\
\ Goodreads ID\n - `google_books` (string, optional): Google Books ID\n- `metadata_sources` (array): Sources metadata was pulled from\n- `last_synced` (string): Last metadata sync timestamp\n- `file_metadata` (object):\n - `file_size` (integer): File size in bytes\n - `format` (string): File format (epub, pdf, mobi, etc.)\n - `pages` (integer, optional): Page count\n - `word_count` (integer, optional): Estimated words\n\n**Status Codes:**\n- 200: Success\n- 401: Unauthorized\n- 404: Book not found\n\n**Metadata Purpose:**\n- Enrich book information on device\n- Improve book organization\n- Enable better search\n- Support series sorting\n- Provide cover images\n- Link to external sources\n\n**KOReader Usage:**\n- Display in book info dialog\n- Used for library sorting\n- Shown in file browser\n- Series grouping\n- Cover display\n- Search optimization\n\n**SHA-256 Book ID:**\n- Primary identifier in KOReader\n- Universal across devices\n- Format-independent\n- Generated from file content\n\
- Survives metadata changes\n\n**Metadata Sources:**\n- Google Books API\n- Open Library\n- Goodreads API\n- ISBN database lookup\n- Publisher metadata\n- User-provided metadata\n\n**Use Cases:**\n- Initial book import\n- Metadata refresh\n- Cover image download\n- Series organization\n- Duplicate detection\n- Library management"
http:
headers: []
method: GET
url: '{{base_url}}/api/sync/koreader/metadata/{{book_uuid}}'
auth: inherit
docs: |-
## Get KOReader Book Metadata
Retrieves metadata for a specific book from the server.
**Method:** GET
**Endpoint:** /api/sync/koreader/metadata/:book_uuid
**Authentication:** Bearer token
**Path Parameters:**
- `book_uuid` (string): SHA-256 based book identifier
**Response:**
- `uuid` (string): Book UUID (SHA-256)
- `title` (string): Book title
- `authors` (array): Author names
- `series` (string, optional): Series name
- `series_index` (number, optional): Position in series
- `publisher` (string, optional): Publisher name
- `publication_date` (string, optional): Release date
- `language` (string, optional): ISO 639-1 language code
- `description` (string, optional): Book description
- `cover_url` (string, optional): Cover image URL
- `cover_thumbnail_url` (string, optional): Thumbnail URL
- `identifiers` (object): Various identifiers
- `isbn` (string, optional): ISBN-13
- `asin` (string, optional): Amazon ASIN
- `goodreads` (string, optional): Goodreads ID
- `google_books` (string, optional): Google Books ID
- `metadata_sources` (array): Sources metadata was pulled from
- `last_synced` (string): Last metadata sync timestamp
- `file_metadata` (object):
- `file_size` (integer): File size in bytes
- `format` (string): File format (epub, pdf, mobi, etc.)
- `pages` (integer, optional): Page count
- `word_count` (integer, optional): Estimated words
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 404: Book not found
**Metadata Purpose:**
- Enrich book information on device
- Improve book organization
- Enable better search
- Support series sorting
- Provide cover images
- Link to external sources
**KOReader Usage:**
- Display in book info dialog
- Used for library sorting
- Shown in file browser
- Series grouping
- Cover display
- Search optimization
**SHA-256 Book ID:**
- Primary identifier in KOReader
- Universal across devices
- Format-independent
- Generated from file content
- Survives metadata changes
**Metadata Sources:**
- Google Books API
- Open Library
- Goodreads API
- ISBN database lookup
- Publisher metadata
- User-provided metadata
**Use Cases:**
- Initial book import
- Metadata refresh
- Cover image download
- Series organization
- Duplicate detection
- Library management
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Get Book Metadata
seq: 8
type: http
+11 -31
View File
@@ -1,34 +1,14 @@
info:
name: KOReader Get Library
type: http
seq: 3
docs: "## KOReader Get Library\n\n Retrieves the user's library for KOReader sync operations.\n\n **Method:** GET\n\n **Endpoint:** /api/sync/koreader/library\n\n **Authentication:** Bearer token (device token)\n\n **Response:**\n - `library_sync` (object): Library sync data\n - `total_books` (number): Total number of books\n - `books` (array): Array of book objects\n\n **Status Codes:**\n - 200: Success\n - 401: Unauthorized\n - 500: Internal server error"
http:
method: GET
url: '{{base_url}}/api/sync/koreader/library'
auth: inherit
body:
type: none
headers:
- key: Authorization
value: Bearer {{device_token
docs: |-
## KOReader Get Library
Retrieves the user's library for KOReader sync operations.
**Method:** GET
**Endpoint:** /api/sync/koreader/library
**Authentication:** Bearer token (device token)
**Response:**
- `library_sync` (object): Library sync data
- `total_books` (number): Total number of books
- `books` (array): Array of book objects
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
headers: []
method: GET
url: '{{base_url}}/api/sync/koreader/library'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: KOReader Get Library
seq: 3
type: http
@@ -1,103 +1,15 @@
info:
name: Get User Library for KOReader
type: http
seq: 9
docs: "## Get KOReader User Library\n\nRetrieves the user's book library for KOReader device sync.\n\n**Method:** GET\n\n**Endpoint:** /api/sync/koreader/library\n\n**Authentication:** Bearer token (koreader_device_token)\n\n**Query Parameters:**\nNone (returns entire library)\n\n**Response:**\n- `books` (array): Library items\n - `uuid` (string): SHA-256 based book identifier\n - `title` (string): Book title\n - `authors` (array): Author names\n - `series` (string, optional): Series name\n - `series_index` (number, optional): Series position\n - `publisher` (string, optional): Publisher\n - `publication_date` (string, optional): Release date\n - `language` (string, optional): Language code\n - `description` (string, optional): Description\n - `cover_url` (string, optional): Cover image URL\n - `cover_thumbnail_url` (string, optional): Thumbnail URL\n - `file_metadata` (object):\n - `format` (string): File format\n - `file_size` (integer): Size in bytes\n - `pages` (integer,\
\ optional): Page count\n - `progress` (object, optional): Reading progress\n - `percentage` (number): Progress 0-100\n - `last_read` (string): Last read timestamp\n - `epubcfi` (string): Current position\n - `annotations_count` (object, optional): Annotation stats\n - `highlights` (integer): Highlight count\n - `notes` (integer): Note count\n - `bookmarks` (integer): Bookmark count\n- `library_info` (object):\n - `total_books` (integer): Total book count\n - `last_sync` (string): Last library sync timestamp\n - `has_updates` (boolean): Updates available\n- `user_info` (object):\n - `user_id` (string): User identifier\n - `email` (string): Email (masked)\n - `libraries` (array): Available library IDs\n\n**Status Codes:**\n- 200: Success\n- 401: Unauthorized\n- 403: Device not approved\n\n**Library Purpose for KOReader:**\n- Discover books available for download\n- Browse catalog on device\n- Sync reading progress across books\n- Download covers/metadata\n- Series-based\
\ organization\n- Cloud library access\n\n**KOReader Device Features:**\n- **OPDS catalog**: Native OPDS client\n- **File browser**: See server books\n- **Cloud download**: Download books on-demand\n- **Metadata sync**: Automatic metadata fetching\n- **Cover images**: Display in library view\n- **Progress sync**: See progress across all books\n- **Search**: Search library by title/author\n\n**KOReader-Specific Features:**\n- SHA-256 based book IDs\n- Multi-format support (EPUB, FB2, PDF, DJVU, MOBI, etc.)\n- Series sorting and grouping\n- Language filtering\n- Cover image caching\n- Metadata for file browser enhancement\n- Integration with KOReader's OPDS client\n\n**Performance:**\n- Typical response: 100-500KB for 100 books\n- Processing time: 300ms-1s\n- Cache duration: 5 minutes\n- Pagination support for libraries >500 books\n\n**Use Cases:**\n- Initial device setup\n- Library browsing on device\n- Book download\n- Metadata refresh\n- Cover image sync\n- Progress overview\n- Series-based\
\ reading"
http:
headers: []
method: GET
url: '{{base_url}}/api/sync/koreader/library'
auth: inherit
docs: |-
## Get KOReader User Library
Retrieves the user's book library for KOReader device sync.
**Method:** GET
**Endpoint:** /api/sync/koreader/library
**Authentication:** Bearer token (koreader_device_token)
**Query Parameters:**
None (returns entire library)
**Response:**
- `books` (array): Library items
- `uuid` (string): SHA-256 based book identifier
- `title` (string): Book title
- `authors` (array): Author names
- `series` (string, optional): Series name
- `series_index` (number, optional): Series position
- `publisher` (string, optional): Publisher
- `publication_date` (string, optional): Release date
- `language` (string, optional): Language code
- `description` (string, optional): Description
- `cover_url` (string, optional): Cover image URL
- `cover_thumbnail_url` (string, optional): Thumbnail URL
- `file_metadata` (object):
- `format` (string): File format
- `file_size` (integer): Size in bytes
- `pages` (integer, optional): Page count
- `progress` (object, optional): Reading progress
- `percentage` (number): Progress 0-100
- `last_read` (string): Last read timestamp
- `epubcfi` (string): Current position
- `annotations_count` (object, optional): Annotation stats
- `highlights` (integer): Highlight count
- `notes` (integer): Note count
- `bookmarks` (integer): Bookmark count
- `library_info` (object):
- `total_books` (integer): Total book count
- `last_sync` (string): Last library sync timestamp
- `has_updates` (boolean): Updates available
- `user_info` (object):
- `user_id` (string): User identifier
- `email` (string): Email (masked)
- `libraries` (array): Available library IDs
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 403: Device not approved
**Library Purpose for KOReader:**
- Discover books available for download
- Browse catalog on device
- Sync reading progress across books
- Download covers/metadata
- Series-based organization
- Cloud library access
**KOReader Device Features:**
- **OPDS catalog**: Native OPDS client
- **File browser**: See server books
- **Cloud download**: Download books on-demand
- **Metadata sync**: Automatic metadata fetching
- **Cover images**: Display in library view
- **Progress sync**: See progress across all books
- **Search**: Search library by title/author
**KOReader-Specific Features:**
- SHA-256 based book IDs
- Multi-format support (EPUB, FB2, PDF, DJVU, MOBI, etc.)
- Series sorting and grouping
- Language filtering
- Cover image caching
- Metadata for file browser enhancement
- Integration with KOReader's OPDS client
**Performance:**
- Typical response: 100-500KB for 100 books
- Processing time: 300ms-1s
- Cache duration: 5 minutes
- Pagination support for libraries >500 books
**Use Cases:**
- Initial device setup
- Library browsing on device
- Book download
- Metadata refresh
- Cover image sync
- Progress overview
- Series-based reading
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Get User Library for KOReader
seq: 9
type: http
-22
View File
@@ -1,22 +0,0 @@
info:
name: Bookhoard KOReader Sync API
type: collection
seq: 1
http:
method: POST
url: '"http://localhost:8765/api"'
docs: |-
## Bookhoard KOReader Sync API
Collection of endpoints specifically for KOReader e-reader device synchronization.
**Base URL:** http://localhost:8765/api
**Authentication:** Device token or Bearer token required
**Endpoints:**
- KOReader-specific sync protocols
- Metadata synchronization
- Reading progress sync
- Highlights and notes sync
+7
View File
@@ -0,0 +1,7 @@
info:
name: Bookhoard KOReader Sync API
type: folder
seq: 1
request:
auth: inherit
docs: "## Bookhoard KOReader Sync API\n\n Collection of endpoints specifically for KOReader e-reader device synchronization.\n\n **Base URL:** http://localhost:8765/api\n\n **Authentication:** Device token or Bearer token required\n\n **Endpoints:**\n - KOReader-specific sync protocols\n - Metadata synchronization\n - Reading progress sync\n - Highlights and notes sync"
@@ -1,123 +1,19 @@
info:
name: Checkpoint Sync
type: http
seq: 11
docs: "## Checkpoint Sync\n\nIncremental sync using checkpoint-based change tracking.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/koreader/progress\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `sync_mode` (string): Must be `checkpoint`\n- `checkpoint_id` (string): Unique checkpoint identifier\n- `since_timestamp` (string): ISO 8601 timestamp for incremental sync\n- `books` (array): Book progress items\n - `uuid` (string): Book UUID\n - `percentage` (number): Progress 0.0-1.0\n - `chapter` (integer): Current chapter\n - `epubcfi` (string, optional): Current position\n - `modified_since` (boolean, optional): Whether modified since checkpoint\n\n**Response:**\n- `checkpoint_id` (string): Server checkpoint ID\n- `checkpoint_timestamp` (string): Checkpoint creation time\n- `processed` (integer): Books processed\n- `changes_only` (boolean): Whether only changed items synced\n- `next_checkpoint_id` (string): ID for next checkpoint sync\n- `results` (array): Per-book results\n\
\n**Status Codes:**\n- 200: Checkpoint sync completed\n- 401: Unauthorized\n- 400: Invalid checkpoint or timestamp\n\n**Checkpoint Sync Benefits:**\n- **Incremental**: Only sync changed items\n- **Efficient**: Smaller payloads\n- **Fast**: Reduced processing time\n- **Reliable**: Checkpoint-based tracking\n- **Resumable**: Can continue from last checkpoint\n\n**Checkpoint Mechanism:**\n- Server tracks changes since checkpoint\n- Client provides checkpoint ID or timestamp\n- Only modified books returned/processed\n- Checkpoint ID advances on each sync\n- Supports large libraries efficiently\n\n**Use Cases:**\n- Large libraries (100+ books)\n- Intermittent connectivity\n- Bandwidth optimization\n- Battery conservation\n- Background sync\n- Periodic sync (every 5-15 minutes)\n\n**Checkpoint Lifecycle:**\n1. Initial sync: No checkpoint (full sync)\n2. Server returns checkpoint_id\n3. Next sync: Client sends checkpoint_id\n4. Server processes only changes\n5. New checkpoint_id returned\n6.\
\ Repeat from step 3\n\n**Sync Optimization:**\n- Only books with progress changes\n- Skips unmodified books\n- Delta transfer\n- Compression for large payloads\n- Batch processing\n\n**Failure Handling:**\n- Checkpoint ID preserved on failure\n- Retry with same checkpoint\n- Full sync if checkpoint expired\n- Checkpoint validity: 24 hours\n- Auto-fallback to full sync\n\n**Performance:**\n- Small changes (1-10 books): 100-300ms\n- Medium changes (10-50 books): 300ms-1s\n- Large changes (50-100 books): 1-3s\n- Typical: 5-10x faster than full sync\n\n**When to Use:**\n- Default sync mode for most users\n- Periodic background sync\n- Large library management\n- Mobile/network-constrained environments\n- Battery-powered devices\n\n**Configuration:**\n- Checkpoint expiration: 24 hours\n- Max history: 100 checkpoints\n- Auto-cleanup of old checkpoints\n- Configurable sync interval"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth: inherit
headers:
- key: Content-Type
value: application/json
body:
type: json
json:
sync_mode: checkpoint
checkpoint_id: checkpoint-uuid
since_timestamp: '2026-01-30T19:00:00Z'
books:
- uuid: book-uuid-1
percentage: 0.45
chapter: 3
- uuid: book-uuid-2
percentage: 0.75
chapter: 8
docs: |-
## Checkpoint Sync
Incremental sync using checkpoint-based change tracking.
**Method:** POST
**Endpoint:** /api/sync/koreader/progress
**Authentication:** Bearer token
**Request Body:**
- `sync_mode` (string): Must be `checkpoint`
- `checkpoint_id` (string): Unique checkpoint identifier
- `since_timestamp` (string): ISO 8601 timestamp for incremental sync
- `books` (array): Book progress items
- `uuid` (string): Book UUID
- `percentage` (number): Progress 0.0-1.0
- `chapter` (integer): Current chapter
- `epubcfi` (string, optional): Current position
- `modified_since` (boolean, optional): Whether modified since checkpoint
**Response:**
- `checkpoint_id` (string): Server checkpoint ID
- `checkpoint_timestamp` (string): Checkpoint creation time
- `processed` (integer): Books processed
- `changes_only` (boolean): Whether only changed items synced
- `next_checkpoint_id` (string): ID for next checkpoint sync
- `results` (array): Per-book results
**Status Codes:**
- 200: Checkpoint sync completed
- 401: Unauthorized
- 400: Invalid checkpoint or timestamp
**Checkpoint Sync Benefits:**
- **Incremental**: Only sync changed items
- **Efficient**: Smaller payloads
- **Fast**: Reduced processing time
- **Reliable**: Checkpoint-based tracking
- **Resumable**: Can continue from last checkpoint
**Checkpoint Mechanism:**
- Server tracks changes since checkpoint
- Client provides checkpoint ID or timestamp
- Only modified books returned/processed
- Checkpoint ID advances on each sync
- Supports large libraries efficiently
**Use Cases:**
- Large libraries (100+ books)
- Intermittent connectivity
- Bandwidth optimization
- Battery conservation
- Background sync
- Periodic sync (every 5-15 minutes)
**Checkpoint Lifecycle:**
1. Initial sync: No checkpoint (full sync)
2. Server returns checkpoint_id
3. Next sync: Client sends checkpoint_id
4. Server processes only changes
5. New checkpoint_id returned
6. Repeat from step 3
**Sync Optimization:**
- Only books with progress changes
- Skips unmodified books
- Delta transfer
- Compression for large payloads
- Batch processing
**Failure Handling:**
- Checkpoint ID preserved on failure
- Retry with same checkpoint
- Full sync if checkpoint expired
- Checkpoint validity: 24 hours
- Auto-fallback to full sync
**Performance:**
- Small changes (1-10 books): 100-300ms
- Medium changes (10-50 books): 300ms-1s
- Large changes (50-100 books): 1-3s
- Typical: 5-10x faster than full sync
**When to Use:**
- Default sync mode for most users
- Periodic background sync
- Large library management
- Mobile/network-constrained environments
- Battery-powered devices
**Configuration:**
- Checkpoint expiration: 24 hours
- Max history: 100 checkpoints
- Auto-cleanup of old checkpoints
- Configurable sync interval
data: "{\n \"books\": [\n {\n \"chapter\": 3,\n \"percentage\": 0.45,\n \"uuid\": \"{{media_item_id}}\"\n },\n {\n \"chapter\": 8,\n \"percentage\": 0.75,\n \"uuid\": \"{{media_item_id}}\"\n }\n ],\n \"checkpoint_id\": \"checkpoint-uuid\",\n \"since_timestamp\": \"2026-01-30T19:00:00Z\",\n \"sync_mode\": \"checkpoint\"\n}"
headers:
- key: Content-Type
value: application/json
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Checkpoint Sync
seq: 11
type: http
@@ -1,105 +1,19 @@
info:
name: Immediate Sync - Page Turn
type: http
seq: 10
docs: "## Immediate Sync - Page Turn\n\nReal-time progress sync triggered immediately on page turn.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/koreader/progress\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `sync_mode` (string): Must be `immediate`\n- `books` (array): Current book progress\n - `uuid` (string): Book UUID\n - `percentage` (number): Precise progress (0.45678 = 45.678%)\n - `chapter` (integer): Current chapter\n - `timestamp` (string): ISO 8601 timestamp\n - `epubcfi` (string, optional): Current position\n - `page` (integer, optional): Current page\n\n**Response:**\n- `synced` (boolean): Immediate sync status\n- `timestamp` (string): Server timestamp\n- `next_sync_suggested` (string): Suggested next sync time\n\n**Status Codes:**\n- 200: Sync queued\n- 202: Accepted for processing\n- 401: Unauthorized\n- 429: Too many immediate sync requests (rate limited)\n\n**Immediate Sync Mode:**\n- **Purpose**: Real-time progress updates\n- **Trigger**: Every page\
\ turn (configurable)\n- **Priority**: High priority processing\n- **Latency**: <100ms typical\n- **Best effort**: May be queued under load\n\n**Rate Limiting:**\n- Max 60 requests per minute per device\n- Throttled after limit reached\n- Suggests switching to periodic sync\n- Prevents server overload\n\n**Use Cases:**\n- Real-time multi-device reading\n- Live progress sharing\n- Instant position backup\n- Critical reading points\n- Test/profiling mode\n\n**Performance:**\n- Ultra-fast sync\n- Minimal payload\n- Optimized for speed\n- Async processing\n- No confirmation wait\n\n**Battery Considerations:**\n- More frequent network use\n- Higher battery consumption\n- WiFi recommended\n- Can reduce sync frequency in settings\n\n**Configuration:**\n- Can enable/disable per device\n- Adjustable frequency (every page, every N pages)\n- Automatic fallback to periodic sync on error\n- Respects device power-save mode\n\n**When to Use:**\n- Critical reading sessions\n- Multi-device concurrent reading\n\
- Research and study\n- Testing sync functionality\n- When power source available\n\n**When NOT to Use:**\n- Battery conservation needed\n- Unstable network\n- Extended reading sessions\n- Background sync preferred"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth: inherit
headers:
- key: Content-Type
value: application/json
body:
type: json
json:
sync_mode: immediate
books:
- uuid: book-uuid
percentage: 0.45678
chapter: 3
timestamp: '2026-01-30T20:00:00Z'
docs: |-
## Immediate Sync - Page Turn
Real-time progress sync triggered immediately on page turn.
**Method:** POST
**Endpoint:** /api/sync/koreader/progress
**Authentication:** Bearer token
**Request Body:**
- `sync_mode` (string): Must be `immediate`
- `books` (array): Current book progress
- `uuid` (string): Book UUID
- `percentage` (number): Precise progress (0.45678 = 45.678%)
- `chapter` (integer): Current chapter
- `timestamp` (string): ISO 8601 timestamp
- `epubcfi` (string, optional): Current position
- `page` (integer, optional): Current page
**Response:**
- `synced` (boolean): Immediate sync status
- `timestamp` (string): Server timestamp
- `next_sync_suggested` (string): Suggested next sync time
**Status Codes:**
- 200: Sync queued
- 202: Accepted for processing
- 401: Unauthorized
- 429: Too many immediate sync requests (rate limited)
**Immediate Sync Mode:**
- **Purpose**: Real-time progress updates
- **Trigger**: Every page turn (configurable)
- **Priority**: High priority processing
- **Latency**: <100ms typical
- **Best effort**: May be queued under load
**Rate Limiting:**
- Max 60 requests per minute per device
- Throttled after limit reached
- Suggests switching to periodic sync
- Prevents server overload
**Use Cases:**
- Real-time multi-device reading
- Live progress sharing
- Instant position backup
- Critical reading points
- Test/profiling mode
**Performance:**
- Ultra-fast sync
- Minimal payload
- Optimized for speed
- Async processing
- No confirmation wait
**Battery Considerations:**
- More frequent network use
- Higher battery consumption
- WiFi recommended
- Can reduce sync frequency in settings
**Configuration:**
- Can enable/disable per device
- Adjustable frequency (every page, every N pages)
- Automatic fallback to periodic sync on error
- Respects device power-save mode
**When to Use:**
- Critical reading sessions
- Multi-device concurrent reading
- Research and study
- Testing sync functionality
- When power source available
**When NOT to Use:**
- Battery conservation needed
- Unstable network
- Extended reading sessions
- Background sync preferred
data: "{\n \"books\": [\n {\n \"chapter\": 3,\n \"percentage\": 0.45678,\n \"timestamp\": \"2026-01-30T20:00:00Z\",\n \"uuid\": \"{{media_item_id}}\"\n }\n ],\n \"sync_mode\": \"immediate\"\n}"
headers:
- key: Content-Type
value: application/json
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Immediate Sync - Page Turn
seq: 10
type: http
@@ -1,44 +1,15 @@
info:
name: KOReader Sync Annotations - Per-Book SHA-256
type: http
seq: 4
docs: "## KOReader Sync Annotations - Per-Book SHA-256\n\n Synchronizes annotations (highlights) from KOReader with per-annotation SHA-256 hashes for multi-book sync.\n\n **Method:** POST\n\n **Endpoint:** /api/v1/koreader/sync/bookmarks\n\n **Authentication:** Bearer token (KOReader device token)\n\n **Request Body:**\n - `book_uuid` (string): Primary book UUID\n - `highlights` (array): Array of highlight objects\n - `text` (string): Highlighted text\n - `pos0`, `pos1` (string): EPUB CFI positions\n - `color` (string): Highlight color (hex)\n - `page` (number): Page number\n - `book_sha256` (string): SHA-256 hash for this specific book\n\n **Response:**\n - `highlights_synced` (number): Number of highlights synced\n\n **Status Codes:**\n - 200: Success\n - 401: Unauthorized\n - 500: Internal server error\n\n **Note:** Each highlight can include its own book_sha256, allowing annotations from multiple books in a single request."
http:
method: POST
url: '{{base_url}}/api/v1/koreader/sync/bookmarks'
auth: inherit
body:
type: json
jsonBody: "{\n \"book_uuid\": \"{{book_uuid"
headers:
- key: Authorization
value: Bearer {{koreader_device_token
docs: |-
## KOReader Sync Annotations - Per-Book SHA-256
Synchronizes annotations (highlights) from KOReader with per-annotation SHA-256 hashes for multi-book sync.
**Method:** POST
**Endpoint:** /api/v1/koreader/sync/bookmarks
**Authentication:** Bearer token (KOReader device token)
**Request Body:**
- `book_uuid` (string): Primary book UUID
- `highlights` (array): Array of highlight objects
- `text` (string): Highlighted text
- `pos0`, `pos1` (string): EPUB CFI positions
- `color` (string): Highlight color (hex)
- `page` (number): Page number
- `book_sha256` (string): SHA-256 hash for this specific book
**Response:**
- `highlights_synced` (number): Number of highlights synced
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
**Note:** Each highlight can include its own book_sha256, allowing annotations from multiple books in a single request.
data: "{\n \"book_sha256\": \"{{book_sha256}}\",\n \"bookmarks\": [\n {\n \"chapter\": 3,\n \"datetime\": \"2026-01-30T19:55:00Z\",\n \"page\": 45,\n \"pos0\": \"p1\",\n \"text\": \"Per-book annotation\",\n \"type\": \"bookmark\"\n }\n ]\n}"
headers: []
method: POST
url: '{{base_url}}/api/sync/koreader/bookmarks'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: KOReader Sync Annotations - Per-Book SHA-256
seq: 4
type: http
@@ -1,40 +1,15 @@
info:
name: KOReader Sync Bookmarks - SHA-256
type: http
seq: 3
docs: "## KOReader Sync Bookmarks - SHA-256\n\n Synchronizes bookmarks, notes, and highlights from KOReader using SHA-256 hash for book identification.\n\n **Method:** POST\n\n **Endpoint:** /api/v1/koreader/sync/bookmarks\n\n **Authentication:** Bearer token (KOReader device token)\n\n **Request Body:**\n - `book_sha256` (string): SHA-256 hash of book file\n - `bookmarks` (array): Array of bookmarks\n - `notes` (array): Array of notes\n - `highlights` (array): Array of highlights\n\n **Response:**\n - `sync_status` (string): Sync status\n - `total_synced` (number): Total items synced\n\n **Status Codes:**\n - 200: Success\n - 401: Unauthorized\n - 500: Internal server error"
http:
method: POST
url: '{{base_url}}/api/v1/koreader/sync/bookmarks'
auth: inherit
body:
type: json
jsonBody: "{\n \"book_sha256\": \"{{book_sha256"
headers:
- key: Authorization
value: Bearer {{koreader_device_token
docs: |-
## KOReader Sync Bookmarks - SHA-256
Synchronizes bookmarks, notes, and highlights from KOReader using SHA-256 hash for book identification.
**Method:** POST
**Endpoint:** /api/v1/koreader/sync/bookmarks
**Authentication:** Bearer token (KOReader device token)
**Request Body:**
- `book_sha256` (string): SHA-256 hash of book file
- `bookmarks` (array): Array of bookmarks
- `notes` (array): Array of notes
- `highlights` (array): Array of highlights
**Response:**
- `sync_status` (string): Sync status
- `total_synced` (number): Total items synced
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 500: Internal server error
data: "{\n \"book_sha256\": \"{{book_sha256}}\",\n \"bookmarks\": [\n {\n \"chapter\": 3,\n \"datetime\": \"2026-01-30T19:55:00Z\",\n \"page\": 45,\n \"pos0\": \"p1\",\n \"text\": \"SHA-256 bookmark\",\n \"type\": \"bookmark\"\n }\n ]\n}"
headers: []
method: POST
url: '{{base_url}}/api/sync/koreader/bookmarks'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: KOReader Sync Bookmarks - SHA-256
seq: 3
type: http
@@ -1,105 +1,19 @@
info:
name: Sync Bookmarks
type: http
seq: 5
docs: "## Sync KOReader Bookmarks\n\nSynchronizes bookmarks separately from progress for KOReader.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/koreader/bookmarks\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `library_id` (string, optional): Library UUID\n- `books` (array): Books with bookmarks\n - `uuid` (string): Book UUID\n - `bookmarks` (array): Bookmark items\n - `chapter` (integer): Chapter number\n - `datetime` (string): ISO 8601 timestamp\n - `notes` (string): Bookmark description\n - `pos0` (string): EPUB CFI start position\n - `pos1` (string): EPUB CFI end position\n - `page` (integer): Page number\n - `text` (string): Displayed text\n - `type` (string): `bookmark`, `highlight`, or `note`\n\n**Response:**\n- `synced` (integer): Number of bookmarks synced\n- `duplicates_skipped` (integer): Duplicate bookmarks skipped\n- `results` (array): Per-bookmark results\n- `timestamp` (string): Sync timestamp\n\n**Status Codes:**\n- 200: Success\n\
- 401: Unauthorized\n- 400: Invalid data\n\n**Dedicated Bookmark Endpoint:**\n- **Purpose**: Sync bookmarks independently\n- **Use case**: More frequent bookmark updates\n- **Advantage**: Separate from progress sync\n- **Efficiency**: Smaller payloads\n\n**KOReader Bookmark Features:**\n- Chapter-based organization\n- Quick navigation markers\n- Hierarchical bookmarks (via plugins)\n- Custom bookmark titles\n- Date/time tracking\n- EPUB CFI precision\n\n**Bookmark Types in KOReader:**\n- **Location bookmarks**: Quick navigation points\n- **Chapter marks**: Auto-generated chapter markers\n- **Progress bookmarks**: Last read positions\n- **Custom bookmarks**: User-created markers\n- **Search bookmarks**: Saved search results\n\n**Sync Behavior:**\n- Duplicate detection by position + text\n- Most recent wins on conflicts\n- Chapter order preserved\n- Auto-generated vs manual bookmarks differentiated\n- Merge with existing bookmarks\n\n**KOReader Device Integration:**\n- Created via \"Add\
\ bookmark\" menu\n- Shown in \"Bookmarks\" panel\n- Quick access via \"Go to bookmark\"\n- Exportable to JSON\n- Can be edited/deleted\n\n**Use Cases:**\n- Quick navigation aids\n- Chapter markers\n- Important passages\n- Reading progress points\n- Study session markers"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/bookmarks'
auth: inherit
headers:
- key: Content-Type
value: application/json
body:
type: json
json:
library_id: optional-library-uuid
books:
- uuid: book-uuid
bookmarks:
- chapter: 3
datetime: '2026-01-30T19:55:00Z'
notes: Marked this chapter as important
pos0: 'epubcfi(/6/4/2:15)'
pos1: 'epubcfi(/6/4/2:20)'
page: 45
text: Important passage
type: bookmark
docs: |-
## Sync KOReader Bookmarks
Synchronizes bookmarks separately from progress for KOReader.
**Method:** POST
**Endpoint:** /api/sync/koreader/bookmarks
**Authentication:** Bearer token
**Request Body:**
- `library_id` (string, optional): Library UUID
- `books` (array): Books with bookmarks
- `uuid` (string): Book UUID
- `bookmarks` (array): Bookmark items
- `chapter` (integer): Chapter number
- `datetime` (string): ISO 8601 timestamp
- `notes` (string): Bookmark description
- `pos0` (string): EPUB CFI start position
- `pos1` (string): EPUB CFI end position
- `page` (integer): Page number
- `text` (string): Displayed text
- `type` (string): `bookmark`, `highlight`, or `note`
**Response:**
- `synced` (integer): Number of bookmarks synced
- `duplicates_skipped` (integer): Duplicate bookmarks skipped
- `results` (array): Per-bookmark results
- `timestamp` (string): Sync timestamp
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 400: Invalid data
**Dedicated Bookmark Endpoint:**
- **Purpose**: Sync bookmarks independently
- **Use case**: More frequent bookmark updates
- **Advantage**: Separate from progress sync
- **Efficiency**: Smaller payloads
**KOReader Bookmark Features:**
- Chapter-based organization
- Quick navigation markers
- Hierarchical bookmarks (via plugins)
- Custom bookmark titles
- Date/time tracking
- EPUB CFI precision
**Bookmark Types in KOReader:**
- **Location bookmarks**: Quick navigation points
- **Chapter marks**: Auto-generated chapter markers
- **Progress bookmarks**: Last read positions
- **Custom bookmarks**: User-created markers
- **Search bookmarks**: Saved search results
**Sync Behavior:**
- Duplicate detection by position + text
- Most recent wins on conflicts
- Chapter order preserved
- Auto-generated vs manual bookmarks differentiated
- Merge with existing bookmarks
**KOReader Device Integration:**
- Created via "Add bookmark" menu
- Shown in "Bookmarks" panel
- Quick access via "Go to bookmark"
- Exportable to JSON
- Can be edited/deleted
**Use Cases:**
- Quick navigation aids
- Chapter markers
- Important passages
- Reading progress points
- Study session markers
data: "{\n \"book_uuid\": \"{{media_item_id}}\",\n \"bookmarks\": [\n {\n \"chapter\": 3,\n \"datetime\": \"2026-01-30T19:55:00Z\",\n \"page\": 45,\n \"pos0\": \"epubcfi(/6/4/2:15)\",\n \"pos1\": \"epubcfi(/6/4/2:20)\",\n \"text\": \"Important passage\",\n \"type\": \"bookmark\"\n }\n ]\n}"
headers:
- key: Content-Type
value: application/json
method: POST
url: '{{base_url}}/api/sync/koreader/bookmarks'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Sync Bookmarks
seq: 5
type: http
@@ -1,110 +1,16 @@
info:
name: Sync Highlights
type: http
seq: 6
docs: "## Sync KOReader Highlights\n\nSynchronizes text highlights separately from other annotations.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/koreader/highlights\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `library_id` (string, optional): Library UUID\n- `books` (array): Books with highlights\n - `uuid` (string): Book UUID\n - `highlights` (array): Highlight items\n - `datetime` (string): Creation timestamp\n - `text` (string): Highlighted text content\n - `chapter` (integer): Chapter number\n - `pos0` (string): EPUB CFI start position\n - `pos1` (string): EPUB CFI end position\n - `page_start` (integer): Start page\n - `page_end` (integer): End page\n - `color` (string, optional): Color name or hex\n - `note` (string, optional): Attached note\n\n**Response:**\n- `synced` (integer): Highlights synced\n- `duplicates_skipped` (integer): Duplicates found\n- `with_notes` (integer): Highlights that have notes attached\n- `timestamp` (string):\
\ Sync timestamp\n\n**KOReader Highlight Features:**\n- Precise text selection (EPUB CFI)\n- Custom color support (via plugins)\n- Multi-page highlights\n- Chapter references\n- Timestamps for sorting\n- Attached notes support\n- Full text preservation\n\n**Highlight Colors (via plugins):**\n- Yellow (default): General highlighting\n- Green: Important concepts\n- Blue: Key information\n- Red/pink: Critical content\n- Orange: Interesting quotes\n- Custom RGB colors available\n\n**EPUB CFI Advantages:**\n- Precise start/end positions\n- Works across font size changes\n- Survives text reflow\n- Device-independent\n- Standardized format\n\n**Dedicated Highlight Endpoint:**\n- **Separate from progress**: Sync highlights independently\n- **Smaller payload**: Just highlights, no progress\n- **More frequent**: Can sync highlights immediately\n- **Focused**: Single-purpose endpoint\n\n**Sync Behavior:**\n- Exact text matching for duplicates\n- Position-based conflict resolution\n- Color preservation\
\ across devices\n- Note attachments preserved\n- Order maintained by position\n\n**KOReader Device Features:**\n- Created via long-press or selection\n- Color picker available\n- Can add notes immediately\n- Shows in \"Highlights\" panel\n- Exportable to Evernote, etc.\n- Searchable by content\n\n**Use Cases:**\n- Study and research\n- Content curation\n- Quote collection\n- Academic work\n- Sharing insights"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/highlights'
auth: inherit
headers:
- key: Content-Type
value: application/json
body:
type: json
json:
library_id: optional-library-uuid
books:
- uuid: book-uuid
highlights:
- datetime: '2026-01-30T19:50:00Z'
text: Important quote from book
chapter: 4
pos0: 'epubcfi(/6/4/2:20)'
pos1: 'epubcfi(/6/4/2:30)'
page_start: 78
page_end: 79
docs: |-
## Sync KOReader Highlights
Synchronizes text highlights separately from other annotations.
**Method:** POST
**Endpoint:** /api/sync/koreader/highlights
**Authentication:** Bearer token
**Request Body:**
- `library_id` (string, optional): Library UUID
- `books` (array): Books with highlights
- `uuid` (string): Book UUID
- `highlights` (array): Highlight items
- `datetime` (string): Creation timestamp
- `text` (string): Highlighted text content
- `chapter` (integer): Chapter number
- `pos0` (string): EPUB CFI start position
- `pos1` (string): EPUB CFI end position
- `page_start` (integer): Start page
- `page_end` (integer): End page
- `color` (string, optional): Color name or hex
- `note` (string, optional): Attached note
**Response:**
- `synced` (integer): Highlights synced
- `duplicates_skipped` (integer): Duplicates found
- `with_notes` (integer): Highlights that have notes attached
- `timestamp` (string): Sync timestamp
**KOReader Highlight Features:**
- Precise text selection (EPUB CFI)
- Custom color support (via plugins)
- Multi-page highlights
- Chapter references
- Timestamps for sorting
- Attached notes support
- Full text preservation
**Highlight Colors (via plugins):**
- Yellow (default): General highlighting
- Green: Important concepts
- Blue: Key information
- Red/pink: Critical content
- Orange: Interesting quotes
- Custom RGB colors available
**EPUB CFI Advantages:**
- Precise start/end positions
- Works across font size changes
- Survives text reflow
- Device-independent
- Standardized format
**Dedicated Highlight Endpoint:**
- **Separate from progress**: Sync highlights independently
- **Smaller payload**: Just highlights, no progress
- **More frequent**: Can sync highlights immediately
- **Focused**: Single-purpose endpoint
**Sync Behavior:**
- Exact text matching for duplicates
- Position-based conflict resolution
- Color preservation across devices
- Note attachments preserved
- Order maintained by position
**KOReader Device Features:**
- Created via long-press or selection
- Color picker available
- Can add notes immediately
- Shows in "Highlights" panel
- Exportable to Evernote, etc.
- Searchable by content
**Use Cases:**
- Study and research
- Content curation
- Quote collection
- Academic work
- Sharing insights
data: "{\n \"book_uuid\": \"786b9c39-e8c3-47ab-a48a-c7f6cd769930\",\n \"highlights\": [\n {\n \"datetime\": \"2026-01-30T19:50:00Z\",\n \"text\": \"bruno sweep highlight\",\n \"chapter\": 1,\n \"page\": 1,\n \"pos0\": \"1\",\n \"pos1\": \"9\",\n \"color\": \"#ffff00\"\n }\n ]\n}"
method: POST
url: '{{base_url}}/api/sync/koreader/bookmarks'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Sync Highlights
seq: 6
type: http
+16 -105
View File
@@ -1,108 +1,19 @@
info:
name: Sync Notes
type: http
seq: 7
docs: "## Sync KOReader Notes\n\nSynchronizes user notes separately from highlights and bookmarks.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/koreader/notes\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `library_id` (string, optional): Library UUID\n- `books` (array): Books with notes\n - `uuid` (string): Book UUID\n - `notes` (array): Note items\n - `datetime` (string): Creation/modification timestamp\n - `text` (string): Note content\n - `chapter` (integer): Chapter number\n - `pos0` (string, optional): Related EPUB CFI position\n - `page` (integer, optional): Page number\n - `highlighted_text` (string, optional): Related highlight\n\n**Response:**\n- `synced` (integer): Notes synced\n- `duplicates_skipped` (integer): Duplicate notes found\n- `attached_to_highlights` (integer): Notes linked to highlights\n- `timestamp` (string): Sync timestamp\n\n**KOReader Note Features:**\n- Free-form text notes\n- Can be standalone or attached to highlights\n- Chapter-based\
\ organization\n- Timestamped for sorting\n- Markdown support (some versions)\n- Longer form content\n- No character limit\n\n**Note Types:**\n- **Standalone notes**: Independent notes about chapter/section\n- **Attached notes**: Notes attached to specific highlights\n- **Chapter notes**: Notes about entire chapter\n- **Book notes**: General notes about the book\n\n**Dedicated Notes Endpoint:**\n- **Separate sync**: Notes sync independently\n- **Flexible**: Not tied to highlights\n- **Efficient**: Smaller, focused payload\n- **Immediate**: Can sync right after note creation\n\n**Attached Notes:**\n- Linked to specific highlight\n- Shares highlight's position\n- Shown together with highlight\n- Deleted when highlight deleted (optional)\n- Color matches highlight\n\n**Sync Behavior:**\n- Text-based duplicate detection\n- Time-based conflict resolution\n- Chapter organization preserved\n- Markdown formatting preserved\n- Attachment links maintained\n\n**KOReader Device Integration:**\n- Created\
\ via \"Add note\" option\n- Edited in note editor\n- Shown in \"Notes\" panel\n- Can be organized by chapter\n- Export functionality available\n- Search support\n\n**Advanced Features:**\n- **Markdown**: Bold, italic, lists (some versions)\n- **Tags**: User-defined tags (via plugins)\n- **Links**: Internal/external links\n- **Formatting**: Rich text in newer versions\n\n**Use Cases:**\n- Study notes\n- Personal reflections\n- Academic annotations\n- Research insights\n- Book club discussion prep\n- Knowledge management"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/notes'
auth: inherit
headers:
- key: Content-Type
value: application/json
body:
type: json
json:
library_id: optional-library-uuid
books:
- uuid: book-uuid
notes:
- datetime: '2026-01-30T19:52:00Z'
text: My personal note about this chapter
chapter: 4
docs: |-
## Sync KOReader Notes
Synchronizes user notes separately from highlights and bookmarks.
**Method:** POST
**Endpoint:** /api/sync/koreader/notes
**Authentication:** Bearer token
**Request Body:**
- `library_id` (string, optional): Library UUID
- `books` (array): Books with notes
- `uuid` (string): Book UUID
- `notes` (array): Note items
- `datetime` (string): Creation/modification timestamp
- `text` (string): Note content
- `chapter` (integer): Chapter number
- `pos0` (string, optional): Related EPUB CFI position
- `page` (integer, optional): Page number
- `highlighted_text` (string, optional): Related highlight
**Response:**
- `synced` (integer): Notes synced
- `duplicates_skipped` (integer): Duplicate notes found
- `attached_to_highlights` (integer): Notes linked to highlights
- `timestamp` (string): Sync timestamp
**KOReader Note Features:**
- Free-form text notes
- Can be standalone or attached to highlights
- Chapter-based organization
- Timestamped for sorting
- Markdown support (some versions)
- Longer form content
- No character limit
**Note Types:**
- **Standalone notes**: Independent notes about chapter/section
- **Attached notes**: Notes attached to specific highlights
- **Chapter notes**: Notes about entire chapter
- **Book notes**: General notes about the book
**Dedicated Notes Endpoint:**
- **Separate sync**: Notes sync independently
- **Flexible**: Not tied to highlights
- **Efficient**: Smaller, focused payload
- **Immediate**: Can sync right after note creation
**Attached Notes:**
- Linked to specific highlight
- Shares highlight's position
- Shown together with highlight
- Deleted when highlight deleted (optional)
- Color matches highlight
**Sync Behavior:**
- Text-based duplicate detection
- Time-based conflict resolution
- Chapter organization preserved
- Markdown formatting preserved
- Attachment links maintained
**KOReader Device Integration:**
- Created via "Add note" option
- Edited in note editor
- Shown in "Notes" panel
- Can be organized by chapter
- Export functionality available
- Search support
**Advanced Features:**
- **Markdown**: Bold, italic, lists (some versions)
- **Tags**: User-defined tags (via plugins)
- **Links**: Internal/external links
- **Formatting**: Rich text in newer versions
**Use Cases:**
- Study notes
- Personal reflections
- Academic annotations
- Research insights
- Book club discussion prep
- Knowledge management
data: "{\n \"book_uuid\": \"{{media_item_id}}\",\n \"notes\": [\n {\n \"datetime\": \"2026-01-30T19:52:00Z\",\n \"notes\": \"A personal note about this chapter\",\n \"chapter\": 4,\n \"page\": 57,\n \"pos0\": \"3\"\n }\n ]\n}"
headers:
- key: Content-Type
value: application/json
method: POST
url: '{{base_url}}/api/sync/koreader/bookmarks'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Sync Notes
seq: 7
type: http
@@ -1,47 +1,15 @@
info:
name: KOReader Sync Progress - SHA-256 Only
type: http
seq: 2
docs: "## KOReader Sync Progress - SHA-256 Only\n\n Synchronizes reading progress using only SHA-256 hash for book identification (when UUID is not available).\n\n **Method:** POST\n\n **Endpoint:** /api/v1/koreader/sync/progress\n\n **Authentication:** Bearer token (KOReader device token)\n\n **Request Body:**\n - `sync_mode` (string): Sync mode (immediate, deferred)\n - `books` (array): Array of book progress objects\n - `sha256` (string): SHA-256 hash of book file\n - `file_path` (string): Path to book file\n - `percentage` (number): Progress percentage\n - `page` (number): Current page\n - `total_pages` (number): Total pages\n\n **Response:**\n - `sync_status` (string): Sync status\n - `books_synced` (number): Number of books synced\n\n **Status Codes:**\n - 200: Success\n - 202: Accepted\n - 401: Unauthorized\n - 500: Internal server error\n\n **Note:** Use this when book UUID is not available, falling back to SHA-256 hash for identification."
http:
method: POST
url: '{{base_url}}/api/v1/koreader/sync/progress'
auth: inherit
body:
type: json
jsonBody: "{\n \"sync_mode\": \"immediate\",\n \"books\": [\n {\n \
\ \"sha256\": \"{{book_sha256"
headers:
- key: Authorization
value: Bearer {{koreader_device_token
docs: |-
## KOReader Sync Progress - SHA-256 Only
Synchronizes reading progress using only SHA-256 hash for book identification (when UUID is not available).
**Method:** POST
**Endpoint:** /api/v1/koreader/sync/progress
**Authentication:** Bearer token (KOReader device token)
**Request Body:**
- `sync_mode` (string): Sync mode (immediate, deferred)
- `books` (array): Array of book progress objects
- `sha256` (string): SHA-256 hash of book file
- `file_path` (string): Path to book file
- `percentage` (number): Progress percentage
- `page` (number): Current page
- `total_pages` (number): Total pages
**Response:**
- `sync_status` (string): Sync status
- `books_synced` (number): Number of books synced
**Status Codes:**
- 200: Success
- 202: Accepted
- 401: Unauthorized
- 500: Internal server error
**Note:** Use this when book UUID is not available, falling back to SHA-256 hash for identification.
data: "{\n \"sync_mode\": \"immediate\",\n \"books\": [\n {\n \"sha256\": \"{{book_sha256}}\",\n \"percentage\": 0.5\n }\n ]\n}"
headers: []
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: KOReader Sync Progress - SHA-256 Only
seq: 2
type: http
@@ -1,51 +1,16 @@
info:
name: KOReader Sync Progress - SHA-256
type: http
seq: 1
docs: "## KOReader Sync Progress - SHA-256\n\n Synchronizes reading progress from a KOReader device using SHA-256 book hash for identification.\n\n **Method:** POST\n\n **Endpoint:** /api/v1/koreader/sync/progress\n\n **Authentication:** Bearer token (KOReader device token)\n\n **Request Body:**\n - `sync_mode` (string): Sync mode (immediate, deferred)\n - `books` (array): Array of book progress objects\n - `uuid` (string): Book UUID\n - `sha256` (string): SHA-256 hash of book file for identification\n - `file_path` (string): Path to book file on device\n - `percentage` (number): Progress percentage (0-1)\n - `chapter` (number): Current chapter\n - `page` (number): Current page\n - `total_pages` (number): Total pages\n - `epubcfi` (string): EPUB location\n - `last_read` (string): ISO 8601 timestamp\n - `title` (string): Book title\n - `authors` (array): List of authors\n\n **Response:**\n - `sync_status` (string): Sync status\n - `books_synced`\
\ (number): Number of books synced\n\n **Status Codes:**\n - 200: Success\n - 202: Accepted - processing\n - 401: Unauthorized\n - 500: Internal server error"
http:
method: POST
url: '{{base_url}}/api/v1/koreader/sync/progress'
auth: inherit
body:
type: json
jsonBody: "{\n \"sync_mode\": \"immediate\",\n \"books\": [\n {\n \
\ \"uuid\": \"{{book_uuid"
headers:
- key: Authorization
value: Bearer {{koreader_device_token
docs: |-
## KOReader Sync Progress - SHA-256
Synchronizes reading progress from a KOReader device using SHA-256 book hash for identification.
**Method:** POST
**Endpoint:** /api/v1/koreader/sync/progress
**Authentication:** Bearer token (KOReader device token)
**Request Body:**
- `sync_mode` (string): Sync mode (immediate, deferred)
- `books` (array): Array of book progress objects
- `uuid` (string): Book UUID
- `sha256` (string): SHA-256 hash of book file for identification
- `file_path` (string): Path to book file on device
- `percentage` (number): Progress percentage (0-1)
- `chapter` (number): Current chapter
- `page` (number): Current page
- `total_pages` (number): Total pages
- `epubcfi` (string): EPUB location
- `last_read` (string): ISO 8601 timestamp
- `title` (string): Book title
- `authors` (array): List of authors
**Response:**
- `sync_status` (string): Sync status
- `books_synced` (number): Number of books synced
**Status Codes:**
- 200: Success
- 202: Accepted - processing
- 401: Unauthorized
- 500: Internal server error
data: "{\n \"sync_mode\": \"immediate\",\n \"books\": [\n {\n \"sha256\": \"{{book_sha256}}\",\n \"percentage\": 0.5\n }\n ]\n}"
headers: []
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: KOReader Sync Progress - SHA-256
seq: 1
type: http
@@ -1,90 +1,18 @@
info:
name: Sync Progress - Multiple Books
type: http
seq: 2
docs: "## Sync KOReader Progress - Multiple Books\n\nSynchronizes reading progress for multiple books in a single request.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/koreader/progress\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `library_id` (string, optional): Library UUID\n- `books` (array): Multiple book progress items\n - Each item contains: uuid, title, authors, progress, percentage, last_read, chapter, epubcfi\n\n**Response:**\n- `synced` (integer): Number of books successfully synced\n- `failed` (integer): Number of books that failed\n- `results` (array): Per-book sync results\n- `timestamp` (string): Sync timestamp\n\n**Status Codes:**\n- 200: Batch sync completed\n- 207: Multi-status (partial success)\n- 401: Unauthorized\n- 413: Payload too large\n\n**Batch Sync Advantages:**\n- Efficient sync of entire library\n- Single HTTP request for multiple books\n- Faster periodic sync\n- Reduced battery usage vs individual syncs\n- Better for batch processing\n\n**KOReader\
\ Batch Behavior:**\n- Triggered on device wake from sleep\n- Occurs during \"Sync now\" operation\n- Runs on WiFi connection\n- Limited to 100 books per request\n- Automatic retry on failed books\n\n**Performance:**\n- Small batch (2-10 books): 200-500ms\n- Medium batch (10-50 books): 500ms-2s\n- Large batch (50-100 books): 2-5s\n- Timeout: 30 seconds\n\n**Use Cases:**\n- Device initialization sync\n- Periodic background sync\n- Post-offline catch-up sync\n- Library-wide progress update\n- Before firmware update"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth: inherit
headers:
- key: Content-Type
value: application/json
body:
type: json
json:
library_id: optional-library-uuid
books:
- uuid: book-1-uuid
title: Book One
authors:
- Author One
progress: 0.25
percentage: 0.25
last_read: '2026-01-30T19:00:00Z'
chapter: 1
epubcfi: 'epubcfi(/6/4/2:10)'
- uuid: book-2-uuid
title: Book Two
authors:
- Author Two
progress: 0.75
percentage: 0.75
last_read: '2026-01-30T20:00:00Z'
chapter: 8
epubcfi: 'epubcfi(/6/4/2:50)'
docs: |-
## Sync KOReader Progress - Multiple Books
Synchronizes reading progress for multiple books in a single request.
**Method:** POST
**Endpoint:** /api/sync/koreader/progress
**Authentication:** Bearer token
**Request Body:**
- `library_id` (string, optional): Library UUID
- `books` (array): Multiple book progress items
- Each item contains: uuid, title, authors, progress, percentage, last_read, chapter, epubcfi
**Response:**
- `synced` (integer): Number of books successfully synced
- `failed` (integer): Number of books that failed
- `results` (array): Per-book sync results
- `timestamp` (string): Sync timestamp
**Status Codes:**
- 200: Batch sync completed
- 207: Multi-status (partial success)
- 401: Unauthorized
- 413: Payload too large
**Batch Sync Advantages:**
- Efficient sync of entire library
- Single HTTP request for multiple books
- Faster periodic sync
- Reduced battery usage vs individual syncs
- Better for batch processing
**KOReader Batch Behavior:**
- Triggered on device wake from sleep
- Occurs during "Sync now" operation
- Runs on WiFi connection
- Limited to 100 books per request
- Automatic retry on failed books
**Performance:**
- Small batch (2-10 books): 200-500ms
- Medium batch (10-50 books): 500ms-2s
- Large batch (50-100 books): 2-5s
- Timeout: 30 seconds
**Use Cases:**
- Device initialization sync
- Periodic background sync
- Post-offline catch-up sync
- Library-wide progress update
- Before firmware update
data: "{\n \"books\": [\n {\n \"authors\": [\n \"Author One\"\n ],\n \"chapter\": 1,\n \"epubcfi\": \"epubcfi(/6/4/2:10)\",\n \"last_read\": \"2026-01-30T19:00:00Z\",\n \"percentage\": 0.25,\n \"progress\": 0.25,\n \"title\": \"Book One\",\n \"uuid\": \"{{media_item_id}}\"\n },\n {\n \"authors\": [\n \"Author Two\"\n ],\n \"chapter\": 8,\n \"epubcfi\": \"epubcfi(/6/4/2:50)\",\n \"last_read\": \"2026-01-30T20:00:00Z\",\n \"percentage\": 0.75,\n \"progress\": 0.75,\n \"title\": \"Book Two\",\n \"uuid\": \"{{media_item_id}}\"\n }\n ],\n \"library_id\": \"optional-library-uuid\"\n}"
headers:
- key: Content-Type
value: application/json
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Sync Progress - Multiple Books
seq: 2
type: http
@@ -1,92 +1,19 @@
info:
name: Sync Progress - Single Book
type: http
seq: 1
docs: "## Sync KOReader Progress - Single Book\n\nSynchronizes reading progress for a single book from KOReader device.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/koreader/progress\n\n**Authentication:** Bearer token (koreader_device_token)\n\n**Request Body:**\n- `library_id` (string, optional): Library UUID for multi-library setups\n- `books` (array): Array with single book progress\n - `uuid` (string): Unique book identifier (often SHA-256 hash)\n - `title` (string): Book title\n - `authors` (array): List of authors\n - `progress` (number): Progress decimal (0.0 to 1.0)\n - `percentage` (number): Progress percentage (0.45 = 45%)\n - `last_read` (string): ISO 8601 timestamp of last read\n - `chapter` (integer): Current chapter number\n - `epubcfi` (string): EPUB Canonical Fragment Identifier\n - `character` (integer): Character position in book\n - `page` (integer, optional): Current page number\n\n**Response:**\n- `synced` (integer): Number of books synced\n- `timestamp`\
\ (string): Server sync timestamp\n- `books` (array): Sync results\n - `uuid` (string): Book UUID\n - `status` (string): `synced`, `updated`, `skipped`\n - `server_progress` (object): Server-side progress data\n\n**Status Codes:**\n- 200: Success\n- 401: Unauthorized\n- 400: Invalid request format\n\n**KOReader Progress Tracking:**\n- SHA-256 based book identification (universal across devices)\n- EPUB CFI for precise location (standard format)\n- Chapter-based navigation\n- Character-level precision\n- Supports EPUB, FB2, PDF, DJVU, MOBI formats\n\n**EPUB CFI Format:**\n- Standardized location format for EPUBs\n- Example: `epubcfi(/6/4/2:15)`\n- Identifies exact position even after reflow\n- Works across different devices/apps\n- Preserved after file modifications\n\n**Book Identification:**\n- Primary: SHA-256 hash of book file\n- Universal: Same book = same UUID across devices\n- Format-agnostic: Works for any supported format\n- Case-sensitive: Hash must match exactly\n\n**Use Cases:**\n\
- Real-time page turn sync\n- Progress backup\n- Cross-device continuity\n- Reading time tracking\n- Chapter completion detection"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth: inherit
headers:
- key: Content-Type
value: application/json
body:
type: json
json:
library_id: optional-library-uuid
books:
- uuid: book-uuid-here
title: Book Title
authors:
- Author Name
progress: 0.45
percentage: 0.45
last_read: '2026-01-30T20:00:00Z'
chapter: 3
epubcfi: 'epubcfi(/6/4/2:15)'
character: 15432
docs: |-
## Sync KOReader Progress - Single Book
Synchronizes reading progress for a single book from KOReader device.
**Method:** POST
**Endpoint:** /api/sync/koreader/progress
**Authentication:** Bearer token (koreader_device_token)
**Request Body:**
- `library_id` (string, optional): Library UUID for multi-library setups
- `books` (array): Array with single book progress
- `uuid` (string): Unique book identifier (often SHA-256 hash)
- `title` (string): Book title
- `authors` (array): List of authors
- `progress` (number): Progress decimal (0.0 to 1.0)
- `percentage` (number): Progress percentage (0.45 = 45%)
- `last_read` (string): ISO 8601 timestamp of last read
- `chapter` (integer): Current chapter number
- `epubcfi` (string): EPUB Canonical Fragment Identifier
- `character` (integer): Character position in book
- `page` (integer, optional): Current page number
**Response:**
- `synced` (integer): Number of books synced
- `timestamp` (string): Server sync timestamp
- `books` (array): Sync results
- `uuid` (string): Book UUID
- `status` (string): `synced`, `updated`, `skipped`
- `server_progress` (object): Server-side progress data
**Status Codes:**
- 200: Success
- 401: Unauthorized
- 400: Invalid request format
**KOReader Progress Tracking:**
- SHA-256 based book identification (universal across devices)
- EPUB CFI for precise location (standard format)
- Chapter-based navigation
- Character-level precision
- Supports EPUB, FB2, PDF, DJVU, MOBI formats
**EPUB CFI Format:**
- Standardized location format for EPUBs
- Example: `epubcfi(/6/4/2:15)`
- Identifies exact position even after reflow
- Works across different devices/apps
- Preserved after file modifications
**Book Identification:**
- Primary: SHA-256 hash of book file
- Universal: Same book = same UUID across devices
- Format-agnostic: Works for any supported format
- Case-sensitive: Hash must match exactly
**Use Cases:**
- Real-time page turn sync
- Progress backup
- Cross-device continuity
- Reading time tracking
- Chapter completion detection
data: "{\n \"books\": [\n {\n \"authors\": [\n \"Author Name\"\n ],\n \"chapter\": 3,\n \"character\": 15432,\n \"epubcfi\": \"epubcfi(/6/4/2:15)\",\n \"last_read\": \"2026-01-30T20:00:00Z\",\n \"percentage\": 0.45,\n \"progress\": 0.45,\n \"title\": \"Book Title\",\n \"uuid\": \"{{media_item_id}}\"\n }\n ],\n \"library_id\": \"optional-library-uuid\"\n}"
headers:
- key: Content-Type
value: application/json
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Sync Progress - Single Book
seq: 1
type: http
@@ -1,106 +1,19 @@
info:
name: Sync Progress - With Bookmarks
type: http
seq: 3
docs: "## Sync Progress with Bookmarks\n\nSynchronizes reading progress with bookmarks/highlights from KOReader.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/koreader/progress\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `library_id` (string, optional): Library UUID\n- `books` (array): Books with progress and bookmarks\n - `uuid`, `title`, `authors`, `progress`, etc.\n - `bookmarks` (array): Bookmark items\n - `chapter` (integer): Chapter number\n - `datetime` (string): ISO 8601 timestamp\n - `notes` (string): Note content or highlighted text\n - `pos0` (string): EPUB CFI start position\n - `pos1` (string): EPUB CFI end position\n - `page` (integer): Page number\n - `text` (string): Displayed text excerpt\n - `type` (string): `highlight`, `bookmark`, or `note`\n\n**Response:**\n- `progress_synced` (integer): Progress items synced\n- `bookmarks_synced` (integer): Bookmark items synced\n- `highlights_synced` (integer): Highlight count\n- `timestamp`\
\ (string): Sync timestamp\n\n**KOReader Bookmark Types:**\n- **highlight**: Selected text passages\n- **bookmark**: Location markers\n- **note**: Text annotations (can be attached to highlights)\n\n**KOReader Highlight Features:**\n- Custom colors (via color extensions)\n- Multi-color support\n- Precise EPUB CFI positioning\n- Chapter-based organization\n- Text excerpts preserved\n- Date/time stamped\n- Page number tracking\n\n**EPUB CFI in Bookmarks:**\n- `pos0`: Start position (highlight start)\n- `pos1`: End position (highlight end)\n- Exact text selection boundaries\n- Survives text reflow\n- Works across different font sizes\n\n**Sync Behavior:**\n- Duplicate detection by text + position\n- Most recent edit wins\n- Chapter references maintained\n- Order preserved from device\n- Merges with existing server bookmarks\n\n**KOReader Device Integration:**\n- Created in KOReader highlight interface\n- Shows in \"Bookmarks\" menu\n- Can be edited/deleted on device\n- Exportable to JSON/XML\n\
- Searchable by content\n\n**Use Cases:**\n- Study and research\n- Content review\n- Passage tracking\n- Quick navigation\n- Cross-device bookmark access"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth: inherit
headers:
- key: Content-Type
value: application/json
body:
type: json
json:
library_id: optional-library-uuid
books:
- uuid: book-uuid-here
title: Book Title
authors:
- Author Name
progress: 0.45
percentage: 0.45
last_read: '2026-01-30T20:00:00Z'
bookmarks:
- chapter: 3
datetime: '2026-01-30T19:55:00Z'
notes: highlighted text
pos0: 'epubcfi(/6/4/2:15)'
pos1: 'epubcfi(/6/4/2:20)'
page: 45
text: highlighted text excerpt
type: highlight
docs: |-
## Sync Progress with Bookmarks
Synchronizes reading progress with bookmarks/highlights from KOReader.
**Method:** POST
**Endpoint:** /api/sync/koreader/progress
**Authentication:** Bearer token
**Request Body:**
- `library_id` (string, optional): Library UUID
- `books` (array): Books with progress and bookmarks
- `uuid`, `title`, `authors`, `progress`, etc.
- `bookmarks` (array): Bookmark items
- `chapter` (integer): Chapter number
- `datetime` (string): ISO 8601 timestamp
- `notes` (string): Note content or highlighted text
- `pos0` (string): EPUB CFI start position
- `pos1` (string): EPUB CFI end position
- `page` (integer): Page number
- `text` (string): Displayed text excerpt
- `type` (string): `highlight`, `bookmark`, or `note`
**Response:**
- `progress_synced` (integer): Progress items synced
- `bookmarks_synced` (integer): Bookmark items synced
- `highlights_synced` (integer): Highlight count
- `timestamp` (string): Sync timestamp
**KOReader Bookmark Types:**
- **highlight**: Selected text passages
- **bookmark**: Location markers
- **note**: Text annotations (can be attached to highlights)
**KOReader Highlight Features:**
- Custom colors (via color extensions)
- Multi-color support
- Precise EPUB CFI positioning
- Chapter-based organization
- Text excerpts preserved
- Date/time stamped
- Page number tracking
**EPUB CFI in Bookmarks:**
- `pos0`: Start position (highlight start)
- `pos1`: End position (highlight end)
- Exact text selection boundaries
- Survives text reflow
- Works across different font sizes
**Sync Behavior:**
- Duplicate detection by text + position
- Most recent edit wins
- Chapter references maintained
- Order preserved from device
- Merges with existing server bookmarks
**KOReader Device Integration:**
- Created in KOReader highlight interface
- Shows in "Bookmarks" menu
- Can be edited/deleted on device
- Exportable to JSON/XML
- Searchable by content
**Use Cases:**
- Study and research
- Content review
- Passage tracking
- Quick navigation
- Cross-device bookmark access
data: "{\n \"books\": [\n {\n \"authors\": [\n \"Author Name\"\n ],\n \"bookmarks\": [\n {\n \"chapter\": 3,\n \"datetime\": \"2026-01-30T19:55:00Z\",\n \"notes\": \"highlighted text\",\n \"page\": 45,\n \"pos0\": \"epubcfi(/6/4/2:15)\",\n \"pos1\": \"epubcfi(/6/4/2:20)\",\n \"text\": \"highlighted text excerpt\",\n \"type\": \"highlight\"\n }\n ],\n \"last_read\": \"2026-01-30T20:00:00Z\",\n \"percentage\": 0.45,\n \"progress\": 0.45,\n \"title\": \"Book Title\",\n \"uuid\": \"{{media_item_id}}\"\n }\n ],\n \"library_id\": \"optional-library-uuid\"\n}"
headers:
- key: Content-Type
value: application/json
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Sync Progress - With Bookmarks
seq: 3
type: http
@@ -1,117 +1,19 @@
info:
name: Sync Progress - With Highlights and Notes
type: http
seq: 4
docs: "## Sync Progress with Highlights and Notes\n\nSynchronizes reading progress with separate highlights and notes arrays.\n\n**Method:** POST\n\n**Endpoint:** /api/sync/koreader/progress\n\n**Authentication:** Bearer token\n\n**Request Body:**\n- `library_id` (string, optional): Library UUID\n- `books` (array): Books with progress and annotations\n - `uuid`, `title`, `authors`, `progress`, `percentage`, `last_read`\n - `highlights` (array): Text highlights\n - `datetime` (string): Creation timestamp\n - `text` (string): Highlighted text content\n - `chapter` (integer): Chapter number\n - `pos0` (string): EPUB CFI start\n - `pos1` (string): EPUB CFI end\n - `page_start` (integer): Start page\n - `page_end` (integer): End page\n - `color` (string, optional): Highlight color\n - `notes` (array): Notes\n - `datetime` (string): Creation timestamp\n - `text` (string): Note content\n - `chapter` (integer): Chapter number\n - `pos0` (string, optional):\
\ Related position\n\n**Response:**\n- `progress_synced` (boolean): Progress sync status\n- `highlights_synced` (integer): Highlights synced\n- `notes_synced` (integer): Notes synced\n- `conflicts_resolved` (integer): Conflict count\n- `timestamp` (string): Sync timestamp\n\n**KOReader Annotation Model:**\n- **Separate arrays**: Highlights and notes stored separately\n- **Linked**: Notes can reference highlights\n- **Rich metadata**: Timestamps, positions, page numbers\n- **Flexible**: Supports complex annotations\n\n**Highlight Features:**\n- Multi-color highlighting (via plugins)\n- Precise text selection with EPUB CFI\n- Page range tracking\n- Chapter references\n- Timestamps for sorting\n- Full text preserved\n\n**Note Features:**\n- Free-form text notes\n- Can be standalone or attached\n- Chapter-based organization\n- Timestamped\n- Longer form than highlights\n- Support for markdown (some versions)\n\n**Color Support (via plugins):**\n- Yellow: Default highlight\n- Green: Important\
\ passages\n- Blue: Key concepts\n- Red: Critical information\n- Orange: Interesting quotes\n- Custom colors available\n\n**Sync Advantages:**\n- Separation allows granular control\n- Highlights sync without notes\n- Notes sync independently\n- Better conflict resolution\n- Efficient for large annotation sets\n\n**Use Cases:**\n- Academic research\n- Study groups\n- Content analysis\n- Personal knowledge management\n- Sharing insights"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth: inherit
headers:
- key: Content-Type
value: application/json
body:
type: json
json:
library_id: optional-library-uuid
books:
- uuid: book-uuid-here
title: Book Title
authors:
- Author Name
progress: 0.6
percentage: 0.6
last_read: '2026-01-30T20:00:00Z'
highlights:
- datetime: '2026-01-30T19:50:00Z'
text: Important passage
chapter: 4
pos0: 'epubcfi(/6/4/2:20)'
pos1: 'epubcfi(/6/4/2:30)'
page_start: 78
page_end: 79
notes:
- datetime: '2026-01-30T19:52:00Z'
text: My note about this chapter
chapter: 4
docs: |-
## Sync Progress with Highlights and Notes
Synchronizes reading progress with separate highlights and notes arrays.
**Method:** POST
**Endpoint:** /api/sync/koreader/progress
**Authentication:** Bearer token
**Request Body:**
- `library_id` (string, optional): Library UUID
- `books` (array): Books with progress and annotations
- `uuid`, `title`, `authors`, `progress`, `percentage`, `last_read`
- `highlights` (array): Text highlights
- `datetime` (string): Creation timestamp
- `text` (string): Highlighted text content
- `chapter` (integer): Chapter number
- `pos0` (string): EPUB CFI start
- `pos1` (string): EPUB CFI end
- `page_start` (integer): Start page
- `page_end` (integer): End page
- `color` (string, optional): Highlight color
- `notes` (array): Notes
- `datetime` (string): Creation timestamp
- `text` (string): Note content
- `chapter` (integer): Chapter number
- `pos0` (string, optional): Related position
**Response:**
- `progress_synced` (boolean): Progress sync status
- `highlights_synced` (integer): Highlights synced
- `notes_synced` (integer): Notes synced
- `conflicts_resolved` (integer): Conflict count
- `timestamp` (string): Sync timestamp
**KOReader Annotation Model:**
- **Separate arrays**: Highlights and notes stored separately
- **Linked**: Notes can reference highlights
- **Rich metadata**: Timestamps, positions, page numbers
- **Flexible**: Supports complex annotations
**Highlight Features:**
- Multi-color highlighting (via plugins)
- Precise text selection with EPUB CFI
- Page range tracking
- Chapter references
- Timestamps for sorting
- Full text preserved
**Note Features:**
- Free-form text notes
- Can be standalone or attached
- Chapter-based organization
- Timestamped
- Longer form than highlights
- Support for markdown (some versions)
**Color Support (via plugins):**
- Yellow: Default highlight
- Green: Important passages
- Blue: Key concepts
- Red: Critical information
- Orange: Interesting quotes
- Custom colors available
**Sync Advantages:**
- Separation allows granular control
- Highlights sync without notes
- Notes sync independently
- Better conflict resolution
- Efficient for large annotation sets
**Use Cases:**
- Academic research
- Study groups
- Content analysis
- Personal knowledge management
- Sharing insights
data: "{\n \"books\": [\n {\n \"authors\": [\n \"Author Name\"\n ],\n \"highlights\": [\n {\n \"chapter\": 4,\n \"datetime\": \"2026-01-30T19:50:00Z\",\n \"page_end\": 79,\n \"page_start\": 78,\n \"pos0\": \"epubcfi(/6/4/2:20)\",\n \"pos1\": \"epubcfi(/6/4/2:30)\",\n \"text\": \"Important passage\"\n }\n ],\n \"last_read\": \"2026-01-30T20:00:00Z\",\n \"notes\": [\n {\n \"chapter\": 4,\n \"datetime\": \"2026-01-30T19:52:00Z\",\n \"text\": \"My note about this chapter\"\n }\n ],\n \"percentage\": 0.6,\n \"progress\": 0.6,\n \"title\": \"Book Title\",\n \"uuid\": \"{{media_item_id}}\"\n }\n ],\n \"library_id\": \"optional-library-uuid\"\n}"
headers:
- key: Content-Type
value: application/json
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: Sync Progress - With Highlights and Notes
seq: 4
type: http
@@ -1,53 +1,16 @@
info:
name: KOReader Sync Progress
type: http
seq: 1
docs: "## KOReader Sync Progress\n\n Synchronizes reading progress from a KOReader device to the Bookhoard server.\n\n **Method:** POST\n\n **Endpoint:** /api/sync/koreader/progress\n\n **Authentication:** Bearer token (device token)\n\n **Request Body:**\n - `library_id` (string, optional): Library UUID\n - `books` (array): Array of book progress objects\n - `uuid` (string): Book UUID\n - `title` (string): Book title\n - `authors` (array): List of authors\n - `progress` (number): Progress value\n - `percentage` (number): Percentage complete (0-1)\n - `last_read` (string): ISO 8601 timestamp\n - `chapter` (number): Current chapter\n - `epubcfi` (string): EPUB Canonical Fragment Identifier\n - `page` (number): Current page\n - `total_pages` (number): Total pages\n - `sync_mode` (string): Sync mode (immediate, deferred)\n - `device_info` (object): Device information\n - `koreader_version` (string): KOReader version\n - `device_model` (string): Device\
\ model identifier\n\n **Response:**\n - `sync_status` (string): Sync status (accepted, processing)\n - `books_synced` (number): Number of books synced\n\n **Status Codes:**\n - 202: Accepted - sync queued\n - 401: Unauthorized\n - 500: Internal server error"
http:
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth: inherit
body:
type: json
jsonBody: "{\n \"library_id\": null,\n \"books\": [\n {\n \"\
uuid\": \"{{book_uuid"
headers:
- key: Authorization
value: Bearer {{device_token
docs: |-
## KOReader Sync Progress
Synchronizes reading progress from a KOReader device to the Bookhoard server.
**Method:** POST
**Endpoint:** /api/sync/koreader/progress
**Authentication:** Bearer token (device token)
**Request Body:**
- `library_id` (string, optional): Library UUID
- `books` (array): Array of book progress objects
- `uuid` (string): Book UUID
- `title` (string): Book title
- `authors` (array): List of authors
- `progress` (number): Progress value
- `percentage` (number): Percentage complete (0-1)
- `last_read` (string): ISO 8601 timestamp
- `chapter` (number): Current chapter
- `epubcfi` (string): EPUB Canonical Fragment Identifier
- `page` (number): Current page
- `total_pages` (number): Total pages
- `sync_mode` (string): Sync mode (immediate, deferred)
- `device_info` (object): Device information
- `koreader_version` (string): KOReader version
- `device_model` (string): Device model identifier
**Response:**
- `sync_status` (string): Sync status (accepted, processing)
- `books_synced` (number): Number of books synced
**Status Codes:**
- 202: Accepted - sync queued
- 401: Unauthorized
- 500: Internal server error
data: "{\n \"sync_mode\": \"immediate\",\n \"books\": [\n {\n \"uuid\": \"{{media_item_id}}\",\n \"percentage\": 0.45,\n \"chapter\": 3\n }\n ]\n}"
headers: []
method: POST
url: '{{base_url}}/api/sync/koreader/progress'
auth:
type: bearer
token: '{{koreader_device_token}}'
info:
name: KOReader Sync Progress
seq: 1
type: http
@@ -8,16 +8,8 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"media_item_ids\": [\n \"{{bookUUID1"
data: "{\n \"media_item_ids\": [\n \"{{bookUUID1"
headers:
- key: Authorization
value: Bearer {{userToken
docs: |-
## Add Books to Kobo Shelf
Add one or more books to a Kobo device shelf. Manages which books should be synced to a specific Kobo device. Supports multiple shelves for organization.
**Method:** POST
**Endpoint:** /api/devices/{deviceID
docs: "## Add Books to Kobo Shelf\n\n Add one or more books to a Kobo device shelf. Manages which books should be synced to a specific Kobo device. Supports multiple shelves for organization.\n\n **Method:** POST\n\n **Endpoint:** /api/devices/{deviceID"
@@ -2,51 +2,15 @@ info:
name: Check Pending Registration
type: http
seq: 4
http:
method: POST
url: '{{base_url}}/api/devices/register/status'
auth: none
headers:
- key: Content-Type
value: application/json
- key: Content-Type
value: application/json
body:
type: json
json:
registration_id: registration-uuid-here
docs: |-
## Check Registration Status
Checks the current status of a device registration request.
**Method:** POST
**Endpoint:** /api/devices/register/status
**Authentication:** None
**Request Body:**
- `registration_id` (string): UUID received from registration request
**Response:**
- `registration_id` (string): The registration UUID
- `status` (string): Current status
- `pending`: Awaiting user approval
- `approved`: Registration approved, device ready
- `rejected`: Registration rejected by user
- `expired`: Registration expired (not approved in time)
- `device_name` (string): Name of the device
- `device_type` (string): Type of device
- `created_at` (string): Registration timestamp
- `updated_at` (string): Last status update timestamp
**Status Codes:**
- 200: Status retrieved successfully
- 404: Registration ID not found
- 400: Invalid registration ID format
**Polling Recommendations:**
- Poll every 5-10 seconds while status is `pending`
- Stop polling when status changes to `approved`, `rejected`, or `expired`
- Use exponential backoff for mobile devices to save battery
data: "{\n \"registration_id\": \"registration-uuid-here\"\n}"
docs: "## Check Registration Status\n\nChecks the current status of a device registration request.\n\n**Method:** POST\n\n**Endpoint:** /api/devices/register/status\n\n**Authentication:** None\n\n**Request Body:**\n- `registration_id` (string): UUID received from registration request\n\n**Response:**\n- `registration_id` (string): The registration UUID\n- `status` (string): Current status\n - `pending`: Awaiting user approval\n - `approved`: Registration approved, device ready\n - `rejected`: Registration rejected by user\n - `expired`: Registration expired (not approved in time)\n- `device_name` (string): Name of the device\n- `device_type` (string): Type of device\n- `created_at` (string): Registration timestamp\n- `updated_at` (string): Last status update timestamp\n\n**Status Codes:**\n- 200: Status retrieved successfully\n- 404: Registration ID not found\n- 400: Invalid registration ID format\n\n**Polling Recommendations:**\n- Poll every 5-10 seconds while status is `pending`\n\
- Stop polling when status changes to `approved`, `rejected`, or `expired`\n- Use exponential backoff for mobile devices to save battery"
@@ -8,30 +8,5 @@ http:
auth: none
body:
type: json
jsonBody: "{\n \"registration_id\": \"{{registrationId}}"
docs: |-
## Check Registration Status
Checks the current status of a device registration request.
**Method:** POST
**Endpoint:** /api/devices/register/status
**Authentication:** None
**Request Body:**
- `registration_id` (string, required): Registration request ID
**Response:**
- `status` (string): Registration status (pending, approved, rejected, expired)
- `device_name` (string): Device name
- `device_type` (string): Device type
- `created_at` (string): Request timestamp
- `expires_at` (string): Expiration timestamp
**Status Codes:**
- 200: Success
- 404: Registration ID not found
- 410: Registration expired
data: "{\n \"registration_id\": \"{{registrationId}}"
docs: "## Check Registration Status\n\n Checks the current status of a device registration request.\n\n **Method:** POST\n\n **Endpoint:** /api/devices/register/status\n\n **Authentication:** None\n\n **Request Body:**\n - `registration_id` (string, required): Registration request ID\n\n **Response:**\n - `status` (string): Registration status (pending, approved, rejected, expired)\n - `device_name` (string): Device name\n - `device_type` (string): Device type\n - `created_at` (string): Request timestamp\n - `expires_at` (string): Expiration timestamp\n\n **Status Codes:**\n - 200: Success\n - 404: Registration ID not found\n - 410: Registration expired"
@@ -8,48 +8,12 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"file_path\": \"/mnt/sd/books/my-book.kepub.epub\",\n \"\
media_item_id\": \"{{media_item_id"
data: "{\n \"file_path\": \"/mnt/sd/books/my-book.kepub.epub\",\n \"media_item_id\": \"{{media_item_id"
headers:
- key: Content-Type
value: application/json
runtime:
scripts:
- type: tests
code: "test_create_device_file_alias_success(status, headers, body) {\n if\
\ (status !== 201 && status !== 200) {\n throw new Error(\"Expected status\
\ 201 or 200, got \" + status);"
docs: |-
## Create Device File Alias
Creates a new file alias for a device. File aliases map device-specific file paths to media items.
**Method:** POST
**Endpoint:** /api/devices/:id/file-aliases
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `id` (string, required): Device UUID
**Request Body:**
- `file_path` (string, required): Device-specific file path
- `media_item_id` (string, required): Media item UUID to link to
**Response:** Created file alias object
- `id` (string): Alias UUID
- `device_id` (string): Device UUID
- `file_path` (string): Device-specific file path
- `media_item_id` (string): Associated media item UUID
- `created_at` (string): Creation timestamp
- `updated_at` (string): Last update timestamp
**Status Codes:**
- 201: Created
- 200: Success
- 400: Invalid request body
- 401: Unauthorized
- 404: Device not found
- 500: Internal server error
code: "test_create_device_file_alias_success(status, headers, body) {\n if (status !== 201 && status !== 200) {\n throw new Error(\"Expected status 201 or 200, got \" + status);"
docs: "## Create Device File Alias\n\n Creates a new file alias for a device. File aliases map device-specific file paths to media items.\n\n **Method:** POST\n\n **Endpoint:** /api/devices/:id/file-aliases\n\n **Authentication:** Required (Bearer token)\n\n **Path Parameters:**\n - `id` (string, required): Device UUID\n\n **Request Body:**\n - `file_path` (string, required): Device-specific file path\n - `media_item_id` (string, required): Media item UUID to link to\n\n **Response:** Created file alias object\n - `id` (string): Alias UUID\n - `device_id` (string): Device UUID\n - `file_path` (string): Device-specific file path\n - `media_item_id` (string): Associated media item UUID\n - `created_at` (string): Creation timestamp\n - `updated_at` (string): Last update timestamp\n\n **Status Codes:**\n - 201: Created\n - 200: Success\n - 400: Invalid request body\n - 401: Unauthorized\n - 404: Device not found\n - 500: Internal server error"
+31 -11
View File
@@ -2,57 +2,77 @@ info:
name: Disable Device Sync
type: http
seq: 8
http:
method: PUT
url: '{{base_url}}/api/devices/{{device_id}}'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: Content-Type
value: application/json
body:
type: json
json:
device_name: My Kobo Clara
sync_enabled: false
auto_sync: false
sync_frequency_minutes: 30
data: "{\n \"device_name\": \"My Kobo Clara\",\n \"sync_enabled\": false,\n \"auto_sync\": false,\n \"sync_frequency_minutes\": 30\n}"
docs: '## Disable Device Sync
docs: |-
## Disable Device Sync
Disables synchronization for a specific device.
**Method:** PUT
**Endpoint:** /api/devices/:device_id
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `device_id` (string): UUID of the device
**Request Body:**
- `device_name` (string, optional): Device name
- `sync_enabled` (boolean): Must be `false`
- `auto_sync` (boolean): Should be `false`
- `sync_frequency_minutes` (integer, optional): Any value (sync disabled)
**Response:**
- `id` (string): Device UUID
- `device_name` (string): Device name
- `sync_enabled` (boolean): `false`
- `auto_sync` (boolean): `false`
- `message` (string): Confirmation message
**Status Codes:**
- 200: Sync disabled successfully
- 401: Unauthorized
- 403: Forbidden
- 404: Device not found
**Use Cases:**
- Temporarily disable sync for troubleshooting
- Stop sync for a lost or stolen device
- Disable sync before selling or giving away device
- Prevent data usage on limited connections
- Prevent data usage on limited connections'
@@ -8,32 +8,5 @@ http:
auth: none
body:
type: json
jsonBody: "{\n \"device_name\": \"My Kindle Paperwhite\",\n \"device_type\"\
: \"koreader\",\n \"device_identifier\": \"kindle-pw5-hardware-id-12345\""
docs: |-
## Initiate Device Registration
Initiates a new device registration request that requires admin approval.
**Method:** POST
**Endpoint:** /api/devices/register
**Authentication:** None (open endpoint)
**Request Body:**
- `device_name` (string, required): Human-readable device name (max 100 chars)
- `device_type` (string, required): Device type (kobo, koreader, kindle, etc.)
- `device_identifier` (string, required): Unique hardware ID (max 200 chars)
**Response:**
- `registration_id` (string): Unique registration request ID
- `status` (string): Initial status (pending)
- `expires_at` (string): Expiration timestamp (usually 24 hours)
- `message` (string): Informational message
**Status Codes:**
- 201: Registration initiated
- 400: Invalid request
- 409: Device already registered
data: "{\n \"device_name\": \"My Kindle Paperwhite\",\n \"device_type\": \"koreader\",\n \"device_identifier\": \"kindle-pw5-hardware-id-12345\""
docs: "## Initiate Device Registration\n\n Initiates a new device registration request that requires admin approval.\n\n **Method:** POST\n\n **Endpoint:** /api/devices/register\n\n **Authentication:** None (open endpoint)\n\n **Request Body:**\n - `device_name` (string, required): Human-readable device name (max 100 chars)\n - `device_type` (string, required): Device type (kobo, koreader, kindle, etc.)\n - `device_identifier` (string, required): Unique hardware ID (max 200 chars)\n\n **Response:**\n - `registration_id` (string): Unique registration request ID\n - `status` (string): Initial status (pending)\n - `expires_at` (string): Expiration timestamp (usually 24 hours)\n - `message` (string): Informational message\n\n **Status Codes:**\n - 201: Registration initiated\n - 400: Invalid request\n - 409: Device already registered"
+24 -10
View File
@@ -2,48 +2,62 @@ info:
name: Register Device
type: http
seq: 1
http:
method: POST
url: '{{base_url}}/api/devices/register'
auth: none
headers:
- key: Content-Type
value: application/json
- key: Content-Type
value: application/json
body:
type: json
json:
device_name: My Kobo Clara
device_type: kobo
device_identifier: N1234567890123
data: "{\n \"device_name\": \"My Kobo Clara\",\n \"device_type\": \"kobo\",\n \"device_identifier\": \"N1234567890123\"\n}"
docs: '## Register Device
docs: |-
## Register Device
Initiates device registration by sending device information to the server.
**Method:** POST
**Endpoint:** /api/devices/register
**Authentication:** None (public endpoint)
**Request Body:**
- `device_name` (string): Human-readable name for the device
- `device_type` (string): Type of device - `kobo`, `koreader`, or `web`
- `device_identifier` (string): Unique device identifier (serial number or other ID)
**Response:**
- `registration_id` (string): UUID for tracking registration status
- `status` (string): Registration status - `pending`, `approved`, or `rejected`
- `message` (string): Status message
**Status Codes:**
- 201: Registration initiated successfully
- 400: Invalid request data
- 409: Device already registered
**Notes:**
- Device registration requires user approval before activation
- Registration ID should be stored for status checking
- Device identifier must be unique per device
- Device identifier must be unique per device'
@@ -2,47 +2,60 @@ info:
name: Register KOReader Device
type: http
seq: 2
http:
method: POST
url: '{{base_url}}/api/devices/register'
auth: none
headers:
- key: Content-Type
value: application/json
- key: Content-Type
value: application/json
body:
type: json
json:
device_name: My Kindle Paperwhite
device_type: koreader
device_identifier: G090GP123456789
data: "{\n \"device_name\": \"My Kindle Paperwhite\",\n \"device_type\": \"koreader\",\n \"device_identifier\": \"G090GP123456789\"\n}"
docs: '## Register KOReader Device
docs: |-
## Register KOReader Device
Registers a KOReader device (typically Kindle devices running KOReader software).
**Method:** POST
**Endpoint:** /api/devices/register
**Authentication:** None (public endpoint)
**Request Body:**
- `device_name` (string): Human-readable name for the device
- `device_type` (string): Must be `koreader`
- `device_identifier` (string): Kindle serial number or unique device ID
**Response:**
- `registration_id` (string): UUID for tracking registration status
- `status` (string): Registration status - `pending`
- `message` (string): Confirmation message
**Status Codes:**
- 201: Registration initiated
- 400: Invalid device type or data
**KOReader Notes:**
- KOReader runs on Kindle, Kobo, PocketBook, and Android devices
- Device identifier is typically the device serial number
- Supports bookmarks, progress, highlights, and annotations sync
- Supports bookmarks, progress, highlights, and annotations sync'
+24 -10
View File
@@ -2,48 +2,62 @@ info:
name: Register Web Device
type: http
seq: 3
http:
method: POST
url: '{{base_url}}/api/devices/register'
auth: none
headers:
- key: Content-Type
value: application/json
- key: Content-Type
value: application/json
body:
type: json
json:
device_name: Chrome Browser
device_type: web
device_identifier: web-client-abc123
data: "{\n \"device_name\": \"Chrome Browser\",\n \"device_type\": \"web\",\n \"device_identifier\": \"web-client-abc123\"\n}"
docs: '## Register Web Device
docs: |-
## Register Web Device
Registers a web browser client for reading progress sync.
**Method:** POST
**Endpoint:** /api/devices/register
**Authentication:** None (public endpoint)
**Request Body:**
- `device_name` (string): Browser identification (e.g., "Chrome Browser")
- `device_type` (string): Must be `web`
- `device_identifier` (string): Unique browser/client identifier
**Response:**
- `registration_id` (string): UUID for tracking
- `status` (string): `pending` or `auto-approved`
- `message` (string): Status message
**Status Codes:**
- 201: Registration successful
- 400: Invalid request
**Web Device Notes:**
- Web devices use localStorage for device identification
- May be auto-approved without admin intervention
- Used for browser-based reading progress tracking
- Supports manual progress updates, highlights, and notes
- Supports manual progress updates, highlights, and notes'
@@ -2,60 +2,83 @@ info:
name: Update Device Settings
type: http
seq: 7
http:
method: PUT
url: '{{base_url}}/api/devices/{{device_id}}'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: Content-Type
value: application/json
body:
type: json
json:
device_name: Updated Device Name
sync_enabled: true
auto_sync: true
sync_frequency_minutes: 10
data: "{\n \"device_name\": \"Updated Device Name\",\n \"sync_enabled\": true,\n \"auto_sync\": true,\n \"sync_frequency_minutes\": 10\n}"
docs: '## Update Device Settings
docs: |-
## Update Device Settings
Updates device configuration and sync settings.
**Method:** PUT
**Endpoint:** /api/devices/:device_id
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `device_id` (string): UUID of the device
**Request Body:**
- `device_name` (string, optional): New device name
- `sync_enabled` (boolean, optional): Enable/disable sync
- `auto_sync` (boolean, optional): Enable automatic sync
- `sync_frequency_minutes` (integer, optional): Sync interval (5-1440 minutes)
**Response:**
- `id` (string): Device UUID
- `device_name` (string): Updated device name
- `sync_enabled` (boolean): Updated sync status
- `auto_sync` (boolean): Updated auto-sync setting
- `sync_frequency_minutes` (integer): Updated sync interval
- `updated_at` (string): Update timestamp
**Status Codes:**
- 200: Settings updated successfully
- 400: Invalid sync frequency or data
- 401: Unauthorized
- 403: Forbidden (device belongs to different user)
- 404: Device not found
**Sync Frequency Guidelines:**
- Mobile devices: 10-30 minutes
- E-readers on WiFi: 15-60 minutes
- Web clients: 5-15 minutes
- Minimum: 5 minutes
- Maximum: 1440 minutes (24 hours)
- Maximum: 1440 minutes (24 hours)'
+2 -32
View File
@@ -8,35 +8,5 @@ http:
auth: inherit
body:
type: json
jsonBody: "{\n \"device_name\": \"My Updated Kindle\",\n \"sync_enabled\"\
: true,\n \"auto_sync\": true,\n \"sync_frequency_minutes\": 10"
docs: |-
## Update Device
Updates device settings and synchronization preferences.
**Method:** PUT
**Endpoint:** /api/devices/{device_id}
**Authentication:** Required (Bearer token, admin or device owner)
**Path Parameters:**
- `device_id` (string): Device ID
**Request Body:**
- `device_name` (string, optional): New device name (max 100 chars)
- `sync_enabled` (boolean, optional): Enable/disable synchronization
- `auto_sync` (boolean, optional): Enable automatic synchronization
- `sync_frequency_minutes` (integer, optional): Sync frequency in minutes (5-1440)
**Response:**
- Updated device object
**Status Codes:**
- 200: Success
- 400: Invalid request
- 401: Unauthorized
- 403: Forbidden (not device owner)
- 404: Device not found
data: "{\n \"device_name\": \"My Updated Kindle\",\n \"sync_enabled\": true,\n \"auto_sync\": true,\n \"sync_frequency_minutes\": 10"
docs: "## Update Device\n\n Updates device settings and synchronization preferences.\n\n **Method:** PUT\n\n **Endpoint:** /api/devices/{device_id}\n\n **Authentication:** Required (Bearer token, admin or device owner)\n\n **Path Parameters:**\n - `device_id` (string): Device ID\n\n **Request Body:**\n - `device_name` (string, optional): New device name (max 100 chars)\n - `sync_enabled` (boolean, optional): Enable/disable synchronization\n - `auto_sync` (boolean, optional): Enable automatic synchronization\n - `sync_frequency_minutes` (integer, optional): Sync frequency in minutes (5-1440)\n\n **Response:**\n - Updated device object\n\n **Status Codes:**\n - 200: Success\n - 400: Invalid request\n - 401: Unauthorized\n - 403: Forbidden (not device owner)\n - 404: Device not found"
@@ -2,65 +2,92 @@ info:
name: Update Sync Frequency
type: http
seq: 9
http:
method: PUT
url: '{{base_url}}/api/devices/{{device_id}}'
auth: inherit
headers:
- key: Content-Type
value: application/json
- key: Content-Type
value: application/json
body:
type: json
json:
device_name: My Kobo Clara
sync_enabled: true
auto_sync: true
sync_frequency_minutes: 15
data: "{\n \"device_name\": \"My Kobo Clara\",\n \"sync_enabled\": true,\n \"auto_sync\": true,\n \"sync_frequency_minutes\": 15\n}"
docs: '## Update Sync Frequency
docs: |-
## Update Sync Frequency
Changes how often the device automatically syncs with the server.
**Method:** PUT
**Endpoint:** /api/devices/:device_id
**Authentication:** Required (Bearer token)
**Path Parameters:**
- `device_id` (string): UUID of the device
**Request Body:**
- `device_name` (string, optional): Device name
- `sync_enabled` (boolean): Must be `true` for auto-sync
- `auto_sync` (boolean): Must be `true`
- `sync_frequency_minutes` (integer): New sync interval (5-1440)
**Response:**
- `id` (string): Device UUID
- `sync_enabled` (boolean): `true`
- `auto_sync` (boolean): `true`
- `sync_frequency_minutes` (integer): Updated interval
- `next_sync_at` (string): Estimated next sync time
- `updated_at` (string): Update timestamp
**Status Codes:**
- 200: Frequency updated successfully
- 400: Invalid frequency value
- 401: Unauthorized
- 403: Forbidden
- 404: Device not found
**Recommended Frequencies:**
- **Active reading:** 5-10 minutes
- **Normal usage:** 15-30 minutes
- **Occasional reading:** 30-60 minutes
- **Battery saving:** 60+ minutes
- **WiFi only (3G):** 30-60 minutes
**Notes:**
- More frequent sync = more battery usage
- Sync only occurs when device is online
- Manual sync can be triggered anytime regardless of frequency
- Manual sync can be triggered anytime regardless of frequency'
+5 -1
View File
@@ -23,7 +23,7 @@ variables:
- name: library_folder
value: /app/uploads
- name: opds_base_url
value: ""
value: http://localhost:8765/opds
- secret: true
name: token
- secret: true
@@ -40,3 +40,7 @@ variables:
name: comic_library_id
- secret: true
name: manga_library_id
- secret: true
name: book_sha256
- secret: true
name: book_uuid
-52
View File
@@ -1,52 +0,0 @@
meta:
name: Browse library folders
type: http
seq: 1
config:
test:
filter: "response.status == 200"
assertions:
- type: stddev
target: response_time
threshold: 1000
- type: jsonpath
expression: response.directories
condition: exists
- type: jsonpath
expression: response.current_path
condition: exists
- type: jsonpath
expression: response.parent_path
condition: exists
req:
method: GET
url: "{{base_url}}/api/libraries/browse?path=/tmp"
headers:
Authorization: "Bearer {{ADMIN_TOKEN}}"
docs: |-
## Browse Library Folders
Navigates through the filesystem to browse folders for library configuration.
**Method:** GET
**Endpoint:** /api/libraries/browse
**Authentication:** Required (Bearer token, Admin only)
**Query Parameters:**
- `path` (string, required): Filesystem path to browse
**Response:**
- `current_path` (string): Current browsing path
- `parent_path` (string): Parent directory path
- `directories` (array): List of subdirectories
- `files` (array): List of files (optional)
**Status Codes:**
- 200: Success
- 400: Invalid path
- 401: Unauthorized
- 403: Forbidden (admin access required)
- 404: Path not found
Loaded 100 of 479 files, more files were not shown because too many files have changed in this diff. Show more