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
Report success when Kobo rejects a delete for a tag it no longer has, so the device stops retrying
one it can never complete. Skip the full-library reconcile while a device is still draining pages,
and page fifty books at a time so a large re-announce costs a few round trips instead of hundreds.
Ref #1005
Expose the existing credential update path so users can rotate the single KOReader login without deleting synced data. Clarify the current settings location and device reconnection requirement.
Closes#990
The KOReader settings page displayed the origin-only base URL without the /api/v1/koreader path suffix, causing manual URL copy and built-in progress sync setup to fail due to incomplete endpoint paths.
Updates the UI to display the full KOReader sync URL with the API endpoint path included, consolidates redundant copy fields, and updates plugin URL normalization to handle full stock sync URLs seamlessly.
Closes#981
The locale validator demanded every CLDR plural category, so each new ICU plural key took the daily
Crowdin sync red until a `many` branch was hand-added for Spanish and French. That branch can never
render: `many` selects at a million and over in Spanish, French, Italian and Portuguese, and only
for fractional counts in Czech and Slovak, while every catalog plural counts whole items well below
a million and intl-messageformat falls back to `other` regardless. Require only the categories an
integer count 0..1000 can select, which is the same set every integer below a million selects, and
keep `other` always so the categories everyday counts reach stay mandatory.
Drop a target message that still fails validation from the sparse catalog instead of failing the
whole export, so one broken string can no longer block the other 23 languages for days. Report the
rejections in the run log and the pull request body, and keep the retention guard fatal when a
rejected message would replace a translation that is already in Git.
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.
The search params used one flag for two questions: which edition a provider should return, and
whether audiobook-only providers may run. Every caller that wanted the second forced the flag true,
so iTunes asked for entity=audiobook whenever Audible, AudNexus or Libro.fm was in the provider set.
Split the two, and derive the media type from the book's primary file so a record holding both an
ebook and an audiobook no longer searches as an audiobook in one drawer and an ebook in the other.
Closes#982
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.
Non-credential secrets rendered as input[type="password"], so every
settings tab click unmounted a populated password form during a
pushState navigation, matching Chrome's heuristic for a submitted login
form and popping the "Update password?" bubble.
Render them as masked text inputs instead, and guard the boundary with a
static scan that restricts input[type="password"] to BookOrbit login
forms.
Moon+ Reader and CrossPoint never fetch the OpenSearch description document, so they
detected no search capability and hid the search affordance entirely. Both look only for a
rel="search" link whose href already carries {searchTerms}, and they match the link type by
equality rather than by substring.
Emit that templated link alongside the existing OpenSearch pointer, keeping the pointer
first so KOReader's has_opensearch guard still wins and its behaviour is unchanged. The
type stays bare application/atom+xml and the href stays relative like every other href in
the feed, so nothing has to reconstruct an external base URL behind a reverse proxy.
Calibre-Web ships the same two-link pairing.
Add the pair to the libraries, collections, smart-scopes, authors and series feeds too.
Those carried only a self link, so search disappeared once a client descended into a
sub-folder even when it did support OpenSearch.
Closes#860
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
Selecting inside a collapsed library was broken in every layout: a grid series card navigated
into the series instead of selecting, and a list or table series row selected only the
representative book rather than the series it stands for.
Suppress collapse for as long as select mode is active, so the list reloads uncollapsed and
every row is an individual, selectable book. The saved collapse preference is untouched and
comes back on exit. The toolbar toggle stays focusable while locked and explains itself
through its tooltip, since entering select mode visibly re-lays out the view.
Closes#963
The skeleton kept animate-pulse after the image landed, and opacity-0 could not hide it because a
CSS animation overrides the specified value. Every rendered cover ran an infinite opacity animation
behind an opaque image for the life of the page. Idle library view in Firefox 153: 109% CPU before,
0.0% after.
Closes#626
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
KOReader reports a paged document's position as a page number and a reflowable one's as an
xpointer, but applySharedProgress treated every incoming position as an xpointer and
upsertReadingProgress hardcoded pageNumber to null on every write. A PDF synced from a device
therefore stored a correct percentage with no page, and the web reader reopened it at page 1.
The same write also destroyed a page the web reader had saved earlier, including during an
unattended bulk sweep, so this lost positions that were never synced from a device at all.
Carry the book file's format through hash resolution and pick the column from it. CBZ/CBR were
affected identically at the data level but masked in the UI, because the comic reader estimates
a page from the percentage when none is stored.
Closes#954
The admin preset selected every permission group, including the restrictions group whose only
member is demo_restricted. That permission is enforced with ForbidPermission, so holding it
removes capability instead of granting it: users created from the preset silently lost
notifications, account settings, bulk edits, bulk downloads and font management.
Move the groups into a testable module and mark the restrictions group as inverted so presets
skip it, while leaving the checkbox available for deliberate selection.
Fixes#953
Wire the existing allow_registration setting to an admin toggle in the
Users tab and a public /register page, so the documented self-registration
flow is reachable from the UI instead of the API alone. Expose the flag on
the public setup-status endpoint the router guard already calls, so the
sign-in page can offer sign-up without an extra request.
Harden the newly public path: return a conflict instead of a 500 when
concurrent signups race the unique index, sanitize the logged username,
and reject control, zero-width, and text-direction characters in the
username and display name so a new account cannot impersonate another in
the user list or audit log.
Closes#952
The membership row used a fixed grid whose non-name columns take about 326px, so the
minmax(0,1fr) name column shrank to zero in any container narrower than that. On phones,
and on tablets where the series field only gets half the form width, the input disappeared
behind the index and total boxes.
Lay the row out with flex wrapping instead: the name keeps a minimum width and the index,
total and row actions move to a second line when they no longer fit. Wide layouts render
exactly as before, and the reflow follows the container rather than a viewport breakpoint,
which is what the tablet case needs.
Closes#949
Chapters are parsed in XML mode, which resolves only the five predefined XML entities and numeric
character references. EPUB2 XHTML may use any named HTML entity through its DTD, so ` ` and its
kin were indexed as literal text while kepubify wrote the decoded character. The two text indexes
then never matched, which disabled structural resolution for every annotation in the chapter and
failed each one with kepub_text_mismatch, even though the Kobo position was exact. Resolve the
references on the raw markup before parsing, where an escaped ` ` is still distinguishable
from a real reference, and read each one back through an attribute value so an unknown reference is
never partially matched against a legacy entity.
Rebuild canonical positions from the stored kobo_span position on position retry as well. Retry only
ran the cfi backfill, which derives cfi from an xpointer and therefore had no candidate when the
xpointer failed too, leaving an affected annotation pending forever with no way to recover from the
UI. Route the rebuild through a registry that the Kobo module registers into, since it already
imports the annotation module and the dependency cannot run the other way.
Fixes#944
docker-compose.dev.yml and e2e-runner.yml have run pgvector/pgvector:pg18
since v2.2.0, but the development and testing guides still described the
container as PostgreSQL 16.
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.
* feat(metadata): add hardcover edition selection and refresh pinning
Mirrors the StoryGraph edition picker for Hardcover, but backed by real
GraphQL edition data instead of scraping. An edition can now be chosen
either from the Hardcover settings page's new "Linked books" section or
from the metadata downloader/refresh flow, and once chosen it pins
future automatic metadata refreshes to that exact edition instead of
silently re-deriving one by ISBN every time.
book_metadata.hardcover_edition_id is shared across all users, while
hardcover_book_state.hardcover_edition_id is scoped per (user, book).
Cross-writing between them is one-directional and non-destructive per
flow: the settings page always updates the acting user's own sync
state and only back-fills the shared field if it is empty; the
metadata downloader always updates the shared field and only updates
the acting user's own sync state if they already have one. The
propagation uses the existing achievement event bus rather than a
direct module import, since HardcoverModule already depends on
BookModule.
* fix(metadata): address hardcover edition selection review findings
Responds to CodeRabbit review on PR #936:
- Surface Hardcover edition-lookup failures as errors instead of an
empty list, so a provider outage cannot be mistaken for "no
editions". Updates the client to throw and the UI to show a
retryable error state.
- Validate that a manually-picked edition id actually belongs to the
book's matched Hardcover book before persisting it, rejecting
mismatches with a 400 instead of trusting an unvalidated client
input.
- Require an exact safe-integer string before parsing a propagated
hardcoverEditionId, so a value like "200abc" cannot be silently
truncated into a valid-looking edition id.
- Add durationMs to the edition-propagation failure log per the
project's logging conventions.
- Honor a pinned edition in the ISBN search() path too, not only in
lookupById(), so a refresh that falls back to search still returns
the previously chosen edition when it's present.
* test(metadata): strengthen hardcover edition-pin fallback assertions
The fallback tests for search() and lookupById() only used a
single-edition fixture, so they would have passed even if the
edition-pin filtering never ran. Use two-edition fixtures and assert
on which edition is actually selected instead.
* i18n(hardcover): use i18n keys for Linked books strings
HardcoverLinkedBooks.vue hardcoded its UI text instead of using
useI18n like its sibling components, breaking the ability to
translate the Hardcover settings page and non-English catalogs.
* fix(hardcover): harden edition selection and bound the linked-book list
Validate a picked edition against the book it belongs to rather than against a
capped, unordered list. Editions past the cap stay selectable, and two calls
can no longer disagree about which window they saw.
- Order the edition query by id and fetch one row past the cap, so truncation
is detectable instead of silent. Verified against the live Hardcover API:
both "The Name of the Wind" and "Pride and Prejudice" exceed the 50 cap.
- Report truncation for the linked-book list and the edition list, and say so
in the UI rather than presenting a partial list as complete.
- Bound and order the linked-book query. It backed a settings list with the
unbounded sync query; sync callers keep their previous unordered behaviour.
- Throw for "not connected" and "not matched" instead of returning an empty
list, which the UI could not tell apart from a genuinely empty catalogue.
- Drop release_year from the edition query. Hardcover never populates it, so
it was dead weight in the payload.
- Add a tuned --success token. The status pill hardcoded text-green-600, which
is untuned in dark mode; the light value is set for 4.5:1 on card and page.
- Expose row expansion with aria-expanded and aria-controls, announce load
failures through a live region, and keep the edition list open after a
switch so the refreshed marker lands where the user is looking.
---------
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
A tab that slept past the 15 minute access-token lifetime woke up holding a token every consumer
then handed to the server independently. socket.io reconnected all seven namespaces with it, each
gateway rejected the handshake and closed the socket, and because a server-initiated close reports
`io server disconnect` the client never reconnects from it, so live updates stayed dead until the
page was reloaded. The dashboard widgets fetch once on mount with no retry, so whatever failed in
that window sat on "Failed to load" for the life of the page.
Make a valid token the thing consumers ask for rather than something they hope is still good.
Decode `exp` on the client and renew inside a 30 second skew before the token is used, so websocket
handshakes, uploads and `api()` requests no longer discover the expiry one 401 at a time. Route
every authenticated namespace through a shared factory that resolves the token per handshake and,
on an auth rejection, refreshes and reconnects with bounded exponential backoff.
Let the server say which rejections are worth retrying. Gateways now answer an authentication
failure with an `unauthorized` event before closing, and stay silent on a permission denial, which
a fresh token would not fix. Classifying that needed the gateways to stop throwing bare `Error` for
both cases, so the handshake paths now throw `UnauthorizedException` or `ForbiddenException` as the
project's conventions already require, and jsonwebtoken's own `TokenExpiredError` counts as an
authentication failure since it is the single most common way a slept-through handshake fails.
Collapse the twelve identical widget composables onto one loader that reloads a failed widget once
a rejected request is followed by a successful refresh.
Closes#942
Embedding metadata rewrites the book file, so its hash moves even though the book itself is
unchanged. Kobo reconcile reads a moved hash as new content and re-delivers the book as a
NewEntitlement, which makes the device rebuild its local entry and orphan every annotation
attached to the old one.
Carry the post-write hash into the device snapshots that recorded the pre-write hash, so
reconcile stays on the metadata-only path. The device keeps the bytes it already has and never
receives the rewritten file; a content change from any other source still triggers a download.
Ref #925
Normalize heterogeneous language metadata at the Kobo boundary and include validated ISBN values so device details remain accurate. Version metadata hashes to refresh previously synchronized books.
Fixes#920
Extract dominant color from book covers to apply ambient background tinting in BookDetailView, add cover tinting toggle to display settings, and test cover tint extraction.
validate-styles.mjs compared path.relative() output against POSIX-style
ALLOWED keys. On Windows path.relative() yields backslashes, so the lookup
never matched: the allowlisted views/NotFoundView.vue was reported as a
violation and its allowlist entry was simultaneously reported as unused.
That made verify:fast fail on a clean checkout on Windows, which in turn
made the husky pre-push hook fail.
Fixes#824
Co-authored-by: andysom25 <7364828+andysom25@users.noreply.github.com>
* fix(kobo): stop offering plugin updates to devices that crash applying them
Plugin 1.3.x calls Device:unpackArchive, which KOReader removed in
koreader/koreader@751b4978 (July 2026). The call is unguarded and the whole apply path runs
without a pcall inside a ConfirmBox callback, so the missing method raises on UIManager's event
loop and takes KOReader down. Plugin 1.4.0 fixed the extraction, but the broken updater is the
code that would have to install that fix, so it can never self-deliver: every device still on
1.3.x crashes on every update attempt, forever.
Gate the offer server side instead. GET /koreader/plugin/version now resolves the calling user
and withholds pluginVersion when any of their devices last reported a plugin older than 1.4.0.
Those clients already treat "unknown" as "the server could not tell me" and show a message
rather than the update prompt, so the crashing path becomes unreachable without touching the
plugin on the device.
Two limits are inherent to a server-only mitigation and are not bugs. The endpoint carries no
device id, only the user, so one stale device parks self-update for all of that user's devices
until it is updated by hand or removed. A device that has never completed a sweep has no
recorded version, so it is not gated. Neither is closable until the plugin reports its own
version on the version check.
Surface requiresManualUpdate per device in the KOReader settings so the affected devices say
what to do, since the 1.3.x dialog string is baked into the installed plugin and cannot be
changed from here.
Closes#905
* fix(kobo): keep blank plugin versions inside the self-update gate
A device reporting an empty pluginVersion produced a falsy blocker, so the truthiness check
opened the gate and handed out the real version. The DTO allows an empty string, so this was
reachable. Also move the settings spec fixtures above the manual-update cutoff, since a
sub-1.4.0 version with requiresManualUpdate false describes a state that cannot occur.
Disable horizontal page swipe gestures in scrolled flow and use screen coordinates for touch movement checks to prevent touch scrolling from triggering accidental page jumps.
Closes#910