* feat(reader): add PDF highlights and annotations
Persist and render PDF highlights through the same annotation system as
EPUB. The annotation_positions table already supported a 'pdf' format, so
this wires the web create path, response, and reader UI to it.
Backend:
- create-annotation DTO accepts a pdf position (page + segment rects) as an
alternative to a cfi; exactly one is required
- repository createPdf() persists a format='pdf' position (geometry in pos0,
pageno in extras); selectWithCfi exposes the pdf geometry and status
- response DTO parses the pdf position and derives positionStatus
Frontend (pdf-v4 reader):
- usePdfAnnotations REST store and usePdfHighlights orchestration render
persisted highlights via the EmbedPDF annotation plugin (autoCommit off,
DB stays source of truth)
- selection popup (colour/style/note/copy/delete), note dialog, and a
Highlights sidebar tab with search/colour/notes filters, navigate, delete
- NoteDialog relocated to reader/shared for reuse by both readers
Shared types gain AnnotationRect/AnnotationPdfPosition and an optional pdf
field on AnnotationItem.
* fix(reader): preserve input on failed PDF annotation save; convert mixed PDF+CFI for KOReader
- usePdfHighlights: createFromSelection/restyleExisting now return success;
applyHighlight and saveNote only clear the selection, dismiss the popup, and
close the note dialog on success. On failure the mark, popup, and typed note
are preserved so the user can retry without losing input.
- koreader annotation exchange: the PDF push-down guard now skips only PDF-only
annotations (no EPUB anchor). A mixed PDF+CFI annotation still converts its CFI
to xpointer instead of being dropped.
- Tests for both paths.
* fix(reader): label PDF selection buttons and dedupe multi-page highlight retries
- PdfSelectionPopup: add aria-labels to the copy, highlight, note, and delete
icon-only buttons so screen readers can identify them.
- usePdfHighlights: createFromSelection keeps only the pages that failed, so a
retry after a partial multi-page failure never duplicates already-saved pages.
- Test for the multi-page partial-failure retry path.
* fix(reader): harden PDF annotation store, validation, and create logging
- usePdfAnnotations: catch rejected api() calls (session expiry / network) so
load() sets loadError and mutations return null/false instead of rejecting.
- create-annotation DTO: require positive rect width/height so degenerate PDF
geometry is rejected; tests for zero/negative dimensions.
- annotation.service: add [annotation.create] start/end/fail lifecycle logs with
IDs, durationMs, and sanitized error for the multi-step create.
* test(reader): cover PDF annotations in the hub and add missing docstrings
Adds a real-DB e2e proving a PDF highlight persists as a pdf position and surfaces in the library-wide Annotations hub with the exact fields the page deep link needs (jumpFileFormat=pdf, cfi=null, pageno). Adds a hub-service unit test for the pdf row mapping. Documents the PDF render helpers, the annotation deep-link builder, and the createPdf repository method.
* fix(annotations): make PDF highlights reliable across reloads
Reconcile persisted highlights after EmbedPDF finishes loading its annotation state. Keep file-scoped pagination bounded and prevent PDF-only positions from starving device sync.
Ref #1195
* fix(annotations): keep pdf annotations stable during reload
Use authenticated native pagination fetches and discard responses that overlap successful mutations. Align shared dialog styling and annotation tests with project conventions.
Ref #1195
---------
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
Let users request books, route approvals, search configured sources, and import fulfilled releases through the Book Dock. Include scoped permissions, automation, download-client integrations, plugin support, requester visibility, and one consolidated schema migration.
Closes#248
Ref #1115
Add an isPublic flag to collections allowing users to share collections across the instance. Public collections are readable by all authenticated users while write, edit, reorder, and delete operations remain restricted to the owner.
Add fileSize as a filterable rule field resolved against the book's primary file size in bytes. In the filter builder UI, allow operators to input sizes in megabytes with automatic serialization to bytes for queries.
Redesign the bulk rename UI with change-type grouping, inline character diffs, and tree previews. Add two-mode selection for large libraries, virtualize the queue, and move search to the server. Optimize execution by pausing the library watcher instead of tearing it down, and improve SSE progress reporting and run error handling.
Share one path-resolution routine between the preview and the rename so the preview cannot offer a rename the executor refuses: multi-track audiobooks whose parts collapse onto one filename, and renumbering that would overwrite the book's own files.
The series, author and similar endpoints never selected a read status, so
every cover in the detail shelf rendered bare. Attach the requesting user's
status in one bulk lookup per lane, and draw the badge in BookCarousel using
the same icons and placement as the library grid, plus an sr-only label.
BookRepository has no user in scope, so it now returns UnscopedBookRecommendation
and the type system forces the status on where the user exists.
Also adds the untracked scanner overlap repro for issue 1094.
Closes#1163
Token values are sanitized before modifiers run, so {authors:sort} rewrote
"J.K. Rowling" into a folder named "Rowling, J.K." that Windows and SMB reject,
serving a mangled 8.3 alias instead. Normalize each segment after assembly rather
than reusing sanitizePathSegment, whose reserved-name guard re-fires on a second
pass and would turn "NUL.txt_" into "NUL.txt__".
Closes#1160
Both views are toggled from the header and read from the same ladder: one
segment per volume the series should have, coloured read, reading, in library
or missing. Gaps were previously only on the series detail page, so the list
could never say a volume was absent; a Gaps filter now decides that in SQL,
mirroring the existing gap finder branch for branch so the two surfaces can
never disagree.
The card's fanned covers are laid out from cached percentages against an
aspect-ratio box, so nothing is measured at runtime and hover spreads the fan
through one custom property instead of per-cover reactive state.
See docs/COMMIT_GUIDELINES.md for the API additions to SeriesSummary.
The two layouts are the viewMode the app already persists, so search, filters
and sort carry across them. Only 19% of a library's authors have a portrait,
so the gallery leans on monograms and the index treats the name as the content;
a sort name now prints only when it says something the display name does not.
Jump buckets, a cover fallback and two filters come from the server so the A-Z
rail and the chips cover the whole result set rather than the loaded pages.
Reconciles authors.has_photo against the image store on boot: the list resolves
portraits from disk while the filter queried the column, so "no portrait" was
over-reporting by 64.
Both pages now share the stream, entry and detail components, so a highlight
reads the same wherever you meet it. Entries split three ways - date rail,
quote at a real measure, source margin - and the library hub gains an overview
endpoint so its side rail does not recompute six aggregates per scroll.
Notes and anchor-review are server filters now; filtering the loaded window
made the counts and the pager disagree with each other.
Replace the paginated card list with a three-column ledger: a marks rail, a
grouped stream that loads on scroll, an index that regroups by chapter, colour
or day, and a chapter position band. Add chapterBreakdown and activity to the
annotation stats so those panels describe the whole book without loading every
row, and move the metadata score colour onto a shared oklch ramp.
Seed the random sort field from a per-view randomSeed on BookQuery rather
than a day counter, so a reload or a scope change draws a new order while
every page of one listing stays coherent. Callers that omit the seed keep
the daily fallback. Adds a shuffle button to the library, smart scope, and
collection toolbars when the active sort is random.
Closes#1153
Vitals, the session ledger and attempts/records fill the pane height, with
the progress band across the row beneath, matching the details tab.
- attempt boundaries and reading stalls are rows inside the ledger, in date
order only, so sorting by length or change still works
- the band toggles between the progress curve and a trace of the stretch of
the book each session covered
- one empty state for a book with no log, in place of four empty panels
- drops the twelve-month heatmap, which was 357 empty cells on a real book
Stats gain longestSessionSeconds, longestSessionAt and backtrackCount, and
sessions gain attemptId; the client cannot derive either from one page.
Multiple audiobook editions of one title resolved to the same target path, so
enabling file rename merged or skipped every narrator but the first. Add
{narrators}, plus a :max3 modifier that yields nothing above three values, and
judge an optional group by what its placeholders render so the fallback literal
stays in the user's own pattern. The five duplicate token builders collapse into
one shared helper, which also gives audio uploads the metadata tokens they never
had. Build the shared types package before server lint, since type-aware rules
degrade to unknown on a clean checkout without it.
Refs #1108
Choose Off, Problems or All per category instead of a single on/off switch, and
stop announcing scheduled scans that changed nothing. Repeat occurrences now
collapse into one row with a count, so a bulk run no longer emits one
notification per book. Category and severity move into a shared registry that
the client reads instead of keeping its own copies.
Closes#1141
Remove database records for books whose files are gone, clear cover
references with no file on disk, and delete cover folders left behind by
deleted books. Each category is reviewed in a paginated list and
re-verified against disk before anything is removed.
Closes#312
Finishing an issue offered no way onward, so the next one had to be opened from the library.
Resolve the next readable book server-side with one keyset query, offer it on the last page, and
let an opt-in setting cross over on a further page turn. The reader route now remounts on a param
change, without which it would keep rendering the book it was mounted with.
Also cast the compared value in compareSeriesIndexSql: as a bare parameter Postgres could not type
it inside the null test and rejected the statement.
Closes#998
Expose each session's originating file so the reader can restore a validated historical progress position without losing exact highlight navigation.
Closes#1045
Preserve curated genres while appending unique values returned by metadata
providers, and expose merge versus replace behavior in the web interface.
Closes#1125
Treat series positions as validated string labels so values such as 5.10 retain their exact representation across storage, metadata providers, file formats, and clients.
Closes#1119
Rebuilds the users roster, metadata field rules, file naming and metadata
score panels, and replaces the libraries list with an expandable ledger.
Libraries now batch their counts and last scan into one request instead of a
per-library fan-out, and surface scan history from scan_jobs on demand.
Let each user choose which accessible libraries feed dashboard shelves and reading widgets while preserving access checks and Smart Scope filters.
Closes#1113
Read variable-font axes and named instances so readers can select real weight and style variants instead of synthesized faces. Preserve the axis default, accept the full CSS weight range, and keep saved choices stable while font catalogs load.
Require the new settings fields for complete EPUB defaults, keep per-book patches partial, and widen the persisted font-weight constraints to match CSS.
Closes#533
Return a stable error code for private issuer rejections and translate it in settings so administrators can apply the required production override.
Closes#1065
Every comicvine search probed up to eight candidate volumes one request at a time, each holding a
one second rate limiter slot against an endpoint that answers in three to four seconds, so the
fifteen second provider budget ran out before the right volume was reached and the search reported
an empty result. Rank volumes on the name and issue count already carried by the volume search
response, then probe them in concurrent waves, so the runs that can hold the issue are tried first
and within budget.
Bind the work to a soft deadline that aborts requests still in flight. Reaching it now yields the
candidates already found, rather than letting a late detail lookup cross the hard timeout and take
everything with it. A probe or detail lookup that fails or is throttled costs its own result only,
never its siblings or the candidate it was enriching.
Take the series name from a query the user actually typed instead of the stored one, or searching a
comic for a different series silently returned the series it was already filed under. Strip the
zero padding comic filenames add to issue numbers in the provider rather than only in the search
controller, so the background refresh path benefits too.
Report a provider that timed out, was throttled, or failed as its own stream event, since all three
were indistinguishable from a genuinely empty result. Candidates keep the default event and an
unchanged payload, so a client has to read the event name to tell the two apart.
Fixes#1039
Clearing a book's progress deleted the shared row and left the KOReader device row behind, so
the next pull served the deleted position straight back. Clearing that row too is not enough
either: an empty response reads as "this server knows nothing about your book", which is the
one reply that leaves a device certain its own sidecar is the only position there is, and it
pushes that back on the next sync.
Record the reset as a durable fact and answer the pull with a real start position attributed to
BookOrbit. Route the position by format, because KOReader's own kosync plugin feeds it straight
to GotoPage or GotoXPointer with no percentage fallback and an empty value leaves the reader
where it was. Stamp each delivery as current, because both clients compare that against their
last page turn and default backward syncs to disabled, so the original reset time would be
dropped on any pull deferred past a page turn without ever prompting.
Serving a reset proves nothing, since either client can classify it as a backward sync and drop
it silently, so the marker survives delivery and a device is held until its own push reports the
start. Retire it once a device that took the reset reads on: the pull carries no device
identity, so a marker kept alive past its purpose is answered to every device and would send
that one back to the start on every sync. The bound this leaves is deliberate. A second device
that never took the reset is protected only until the first reads on, and closing that needs
device_id on the pull, which is a plugin change.
Judge convergence on the reported position rather than the percentage, so page 1 of a hundred
page comic is not mistaken for a position no short book could ever reach. Wind Kobo bookmarks
back through the same paths, since a Kobo device otherwise resumes from its own bookmark and
reports it back the same way.
Closes#1032
The ring read only reading_progress, the stored reading position, which nothing writes
except the web reader, Kobo sync and KOReader sync. Manual sessions record endProgress on
the session row and touch nothing else, so a book whose whole history was entered by hand
sat at 0% no matter how far the sessions ran, and marking it read did not help either.
Surface the log's own view of progress instead of writing a reading position from a manual
session: that path also drives auto read-status updates, achievement events and Hardcover
and StoryGraph sync, and would store a position with no CFI for the reader to restore.
Take the most recent session that recorded an endProgress rather than the highest, so a
reread still reads correctly once its first session lands. Read it outside the list date
and format filters, otherwise narrowing the log would drag the ring down with it. The
client folds the value into the max it already computes across ebook and audiobook
positions, which also lets the ring update when a session is added or deleted.
Closes#1030
Audnexus was the only author provider, and it returns just an ASIN, name,
description and image. Goodreads carries a far richer author record, so add it
as a second provider and let each field choose where it comes from.
The Goodreads API was retired in 2020, so the provider scrapes: a name resolves
to an author id through the WAF-free /book/auto_complete endpoint, falling back
to the author search page for names that only differ by a middle initial, and
the author page yields the biography, photo, birth and death dates, website,
genres and influences. Two rendering quirks are worth knowing about, since both
silently return the wrong value: free-text fields appear twice, truncated and
whole, so the parser takes whichever copy carries the most content; and the
birthplace sits in a bare text node while only the date is wrapped in a
dataItem, so authors with a place but no date correctly store nothing.
Preferences are now per field, mirroring the book-side FieldPreference model,
with an ordered provider list and a merge strategy each. They are global,
because an author row is shared across every library and cannot hold a
different biography per library. Providers declare which fields they can return
and both the resolver and the settings UI filter on that, so Audnexus can never
be assigned to a field it has no way to fill. The single global write mode is
migrated into each field's merge strategy on first read, keeping an existing
install's overwrite behaviour instead of silently adopting the default.
The executor now queries every listed provider and resolves each field
independently, first non-empty value winning, and records which provider
supplied the record so a re-run can pin the match instead of searching by name
again. overwriteIfProvided skips empty values, which is what lets a matched
author with an empty Audnexus biography fall through to Goodreads.
Extend the migration module beyond Booklore and Grimmory so an existing Audiobookshelf or
Calibre-Web Automated library can be moved into BookOrbit without rebuilding user state by
hand. Audiobookshelf supports both a live API connection and an offline backup file, while
Calibre-Web Automated reads a stopped snapshot of the app and metadata databases.
Match source books by ASIN in addition to ISBN, and batch the ISBN, ASIN, file hash, file
path, and title/author lookups so planning a large library issues a bounded number of
queries rather than one per book. Route the batched author comparison through the shared
accent-insensitive predicate, which now accepts a SQL fragment so a per-row pattern column
is matched the same way a bound literal is.
Extract the shared source connection form into MigrationSourceFields so the modal and the
settings page stay in sync as source types are added, and allow a mapping to skip a source
user instead of forcing every account to be imported.
Closes#986
Looking up a plural showed only "plural of candelabra", and looking up "candelabra" added a Latin
declension line beneath it. Both come from the Wiktionary path, which English lookups fall through
to whenever api.dictionaryapi.dev fails; it 502s often enough that readers hit it regularly.
Read only the requested language section. Wiktionary keys a page by language and returns every
language that spells the word the same way, so iterating Object.values() merged Latin into English.
This was not a rare edge case: of 14 common words sampled, 13 carried foreign senses, and "fare"
rendered 129 definitions of which 18 were English.
Follow an inflection gloss to the word that carries the meaning, bounded to depth 2, three lemma
fetches and a visited set for cycles. Only a pointer in the leading sense of its block is followed,
which is what separates "candelabras" from the "simple past of bake" buried at sense 10 of "book".
A lemma that fails to resolve is dropped rather than failing the lookup. Detection requires every
word before "of" to be grammatical, so "Informal form of address used by juniors" stays a
definition rather than sending the reader to "address".
Drop script and style elements before reading textContent. Wiktionary ships an inline <style> with
some senses and its CSS was rendering as part of the definition, visibly so on "fare".
Merge blocks that share a part of speech and cap each at five senses, so a resolved chain stays
readable and the popover stops keying a v-for on a duplicated part of speech.
Pointer following is deliberately English-only. fr.wiktionary writes "pluriel de chat", and
guessing at sixteen languages of grammar vocabulary is not worth it; non-English lookups still gain
the language filter. The Free Dictionary path is also unchanged for "candelabras", where its own
gloss "A single candelabrum." is circular but not a recognizable pointer.
Closes#1020
Move chokidar to 5, pinia to 4, and jsdom to 30 with a matching
@types/jsdom, and align packages/types on TypeScript 6 with the other
workspaces.
chokidar 5 is ESM-only while the server compiles to CommonJS. Node's
require(ESM) support covers this, verified against a live watcher and a
nest watch build rather than the mocked unit tests.
Remove @fastify/formbody and @tanstack/vue-table, neither of which had a
single caller, along with the stale @tanstack/vue-table entry in the Vite
optimizeDeps list.
Hold TypeScript at 6. typescript-eslint refuses to install alongside 7 and
vue-tsc cannot drive the native checker until the stable compiler API
lands in 7.1.
Ignore pnpm-lock.yaml in prettier so lint-staged stops rewriting it out of
pnpm's native style, which was inflating every lockfile diff to roughly
19k lines.
Removing a KOReader device was the only way to take a decommissioned reader off the list, and it
permanently deleted that device's page-by-page reading events, per-device progress, sweep record,
naming pattern and sole-source unmatched books. Derived reading sessions survived, but the raw
events did not, and the plugin keeps its upload watermark on the device, so nothing ever
re-uploaded them.
Add retirement as a marker table rather than a device registry. Device identity is implicit,
spread across five device-keyed tables and surfaced by a DISTINCT ON over device progress, so
there is no row to carry a flag and nothing to backfill. Retiring records only (user, device);
every byte the device synced stays where it is.
Clear the marker whenever a retired device syncs again, on single-book progress, bulk sweeps,
page-stat uploads and sweep completion, so reading never accrues against a device the user cannot
see. Keep deleting synced data as a separate, explicit action on the retired row, and clear the
marker it would otherwise leave behind.
Move the confirm copy to new message keys instead of reusing the ones whose meaning changed, so
the sparse target catalogs cannot ship a stale translation of a destructive dialog.
Closes#996
The auto-scan cron expression was validated, persisted and shown in the UI, but nothing ever
consumed it, so a library configured with a schedule never scanned and no scan job was ever
recorded with the schedule trigger.
Register a cron job per configured library on boot and keep it in step as libraries are created,
updated and deleted. An occurrence that lands while a scan is already running is skipped rather
than retried, and a failing occurrence leaves the job registered so later ones still fire.
Schedules run in the server timezone, matching the cleanup, audit and statistics jobs, so a
deployment that sets TZ gets the wall-clock time its users expect.
Move cron validation into a single shared grammar used by both the API and the schedule editor.
The two sides previously disagreed in both directions: the UI accepted step-zero and reversed
ranges that the API rejected, and rejected day names the API accepted. The grammar is a strict
subset of what the scheduler and the human-readable preview can each parse, so an expression the
editor marks valid can always be scheduled.
Closes#985
Require manage_book_dock for the settings UI and the scoped API so regular Book Dock users cannot
view or modify administrative configuration.
Reject every Book Dock key on the generic PATCH /app-settings/:key route as well, including
book_dock_paused. That route accepted the same keys with any string value, so an operator with
manage_app_settings could store an out-of-range threshold or an unknown metadata mode and bypass
the validation the scoped endpoints apply.
Closes#1003
Replace the nine-column table with one list of uniform rows that reflows through a container
query, so a single component serves both desktop and phone. Surface the match score, applied
state and write path that the old row buried in tertiary text.
Back it with a needsReview filter and attention sort over existing indexed columns, threaded
through every bulk selection path so select-all cannot target the wrong rows. Keep confidence
advisory: it gates automatic finalization only.
BookDockSummary gains three additive counts; ../bookorbit-ios mirrors these types by hand.
Browse was the only labelled zone that could not be collapsed, leaving no way to reclaim
those rows for someone who never browses by author or series. Reuse the section header,
persistence and rail behaviour the entity sections already have rather than introducing a
second mechanism, and extract the shared collapse transition so both call sites stay in
step.
Browse has no entity list to bound, so make the persisted section cap optional and keep it
off that section entirely. Zones now carry their own section id, so adding another
collapsible zone needs no change in the template.
Cover search always opened on DuckDuckGo, so anyone who prefers another source had to switch it by
hand for every book. Store the choice per user and apply it when the drawer opens. Cache the value
client-side so the drawer never waits on a request to become usable, and save it to the account
rather than the device so it reaches the iOS client too. AudiobookCovers is not offered as a
default because it only returns results for audiobooks.
Closes#983
Collections opened in title order, so a curated shelf came back rearranged. Add a collectionOrder
sort field, make it the default for collection views, and offer it from a sort control that grid
and list previously did not have.
Back the ordering with a new collection_books.position sequence rather than added_at: added_at
defaults to now(), the transaction timestamp, so every book added in one request shared it and the
order fell back to books.id. Kobo collection paging had the same tie and now orders by position.
Also stop useBookWindow blanking its slots and total on every query change. Clearing them collapsed
the virtual list to zero height and back, which read as a flash on any sort, search, or filter
change.
Closes#978
Resolve {library} to the name of the library a book belongs to, so a
single KOReader device pattern can keep each library in its own subtree
on the device instead of merging every library into one tree.
The token is populated in every server-side pattern surface: KOReader
manifest and book detail device paths, upload, automatic and bulk
rename, book move (target library), download filenames, and BookDock
finalize.
Traditional Chinese cannot reuse the `%two_letters_code%` filename because it would collide with
Simplified under `zh.json`, so `crowdin.yml` maps `zh-TW` to a `zh-Hant` catalog and locale
matching resolves by script subtag before falling back to the base language. Every other new
locale keeps its bare two-letter ID.
Twenty-five entries outgrew the old menus. The mobile submenu inherited `overflow-hidden` with no
max-height, so the last nine languages were unreachable on a phone, and the list was ordered by
ISO code while displaying endonyms, which reads as unsorted. Both header menus now render one
LanguagePicker that searches the native name, the English name and the locale code, groups the
browser's own languages as suggestions, and orders the rest with a collator on the string it
actually shows. Mobile opens a sheet instead of a nested flyout, since a flyout is the wrong
shape for touch regardless of height. Settings gains the Appearance language section its copy
has anticipated since the keys were added.
Tighten the Crowdin progress chart, which the longer language list had stretched to 810px tall.
Kobo sync replaces the store API for the device but only ever answered for
BookOrbit's own books, so anything owned through Kobo fell through the gaps.
Library sync never queried Kobo, so Kobo Plus and purchased titles had no route
to the device at all. Add an opt-in per-user store sync that pulls the device's
upstream entitlements with the device's own Kobo credentials, which still reach
us because initialization leaves device_auth and device_refresh pointing at
Kobo, and appends them to BookOrbit's own. Both cursors ride in one composite
sync token, so no server-side state is needed and tokens minted before this
change keep working. The store is only consulted once BookOrbit has drained its
own pages, and paging stops if Kobo's cursor fails to advance.
Everything on the store path stays inside one guarantee: reading the setting, or
a store that is slow, down, unauthorized or answering with a body we cannot use,
costs the store page and never the BookOrbit half of the sync.
Initialization also rewrites the image templates to point at BookOrbit, so the
device asked us for store covers too. Those were forwarded to storeapi.kobo.com,
which does not serve images, and 404d. Redirect unresolved cover ids to Kobo's
image CDN instead. This is not gated on store sync, since browsing the store
shows those covers whether or not the setting is on.
Deliberately left alone: annotation pulls still 404 for content that is not a
BookOrbit book. The device reconciles annotations by omission, so answering with
an empty set would wipe its local highlights.
Closes#789
A shelf rendered a single horizontal row, which wastes space on wide screens and shows very
few books on narrow ones. Each shelf now carries a row count of 1 to 3, set per shelf in the
dashboard settings drawer.
Books fill bands left to right rather than filling each column top to bottom. The cheaper
grid-auto-flow approach would put the second most recent book directly under the first, which
reads wrong on ordered shelves like Continue Reading and Up Next in Series.
Below the sm breakpoint the row count is capped at two, since only two covers fit across and a
third row costs a screen height to gain two books. A shelf fetches limit * rows books, clamped
to the limit the server already accepts, so no server behaviour changes. DASHBOARD_SCROLLER_MAX_LIMIT
moves into the shared types package so both sides read one value instead of drifting apart.
The row count is part of the shelf key because a shelf snapshots its fetch limit during setup;
without it, switching to three rows would re-flow the books fetched for one.
Also widen the settings drawer, which was pinned at 384px because SheetContent's own sm:max-w-sm
outranked the plain max-w override in tailwind-merge, put Shelves ahead of Widgets, and move the
customize control out of the floating layer into the greeting row where it is not missed.
Closes#928
Book Dock was a single permission that the standard preset handed to every user, so an ordinary
account could pause processing, trigger a rescan, and finalize straight into the library. Keep
book_dock_access for personal upload and review, and move global control behind a new
manage_book_dock permission in the administration group.
Finalization now also requires library_upload, since it writes into a library. Per-file reads and
mutations are scoped to the uploader unless the caller can manage the dock, which closes the gap
that let any Book Dock user read, edit, or discard another user's entry by id. The summary
websocket now announces a change instead of broadcasting global counts, so each client refetches
its own scoped totals.
Closes#961
The lockout was checked before the password was verified, so a correct password on a locked
account returned the same generic invalid-credentials error as a wrong one. Users had no way to
tell the two apart, and the natural response was to keep retrying a password that was already
right.
Completing a password reset did not clear the lock either, so the one recovery path the UI
advertises appeared to fail for up to fifteen minutes after a successful reset.
Verify the password first and report the lockout, with the remaining wait, only when the supplied
password was correct. Someone guessing wrong passwords still gets the generic failure, so account
existence is not leaked. Clear the lock whenever a password is reset or changed, and give admins
an explicit unlock action for the case where waiting is not acceptable.