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
pip is only needed to install the Kobo cloudscraper requirements. Leaving it
in the final image also leaves pip/_vendor/vendor.txt, which Trivy reads as
installed msgpack 1.1.2 and setuptools 70.3.0 and fails the CRITICAL,HIGH
gate. Neither is an application dependency, and setuptools is not present in
the image as code at all.
Verify by building both variants for linux/amd64 and scanning with Trivy
0.70.0 at the workflow's settings: the current layer exits 1 with exactly
those two findings, the stripped layer exits 0. cloudscraper still imports
and creates a scraper, and removing py3-pip orphans no runtime tooling.
The setup:kobo-cloudscraper script still shells out to pip, but it needs tsx
and only ever runs in local development, never in the image.
On a phone the settings panel had no reachable exit. `hide-close` dropped the built-in close
button and nothing replaced it, the grab handle was decorative with no swipe-to-dismiss behind
it, and the compact trigger only ever set the panel open. That left tapping the overlay as the
sole way out, and `max-h-[85vh]` closed off even that: `vh` resolves against the toolbar-retracted
viewport, so with browser chrome on screen the sheet covered nearly the whole visible area. Only
fullscreen, where the two viewports agree, left a gap big enough to hit.
Move the sheet chrome into a shared ReaderSettingsSheet instead of patching both call sites. The
epub and cbz sheets were identical copies, which is why the bug existed twice. Leave the desktop
popover path alone; it closes correctly already.
Closes#1016
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.
A provider identifier parsed out of an OPF was passed on verbatim, so a Calibre
`opf:scheme="AMAZON"` holding a product URL reached a varchar(20) column as a 36-character
value. It could not be stored, and the next manual save of that book was rejected for a field
the user never typed into. Drop an identifier that cannot fit its column rather than truncating
it: an overflowing value is not a long identifier, it is a different kind of value, and its
first 20 characters are not an ASIN either. Enforce the same bound where every extractor
converges, so no parser can fail a metadata write with a Postgres 22001.
Keep the server's description of a rejected save instead of discarding the response body. Only
that text names the field at fault, so the editor showed a bare "HTTP 400" and left the reason
in the network tab. It stays untranslated, so it is preferred over the catalog message rather
than replacing it, and the failure is carried as a structured value so the component still
translates everything else.
Tie the DTO limits to the column widths they were copied from, and guard the pair with a test,
since nothing connected them when they drifted apart.
Stand Node's built-in web storage down in the client test run. Node 26 defines localStorage on
globalThis, jsdom shares that object, and its getter reads as undefined without
--localstorage-file, which failed 229 tests across 28 unrelated suites on CI.
Closes#1015
Author, narrator, genre and tag names written before whitespace normalization existed can hold
a non-breaking space, tab, newline or doubled space. HTML collapses all of those, so the name
renders correctly in the UI while the ILIKE search predicate never matches it; unaccent() does
not touch whitespace either. Re-entering an identical name by hand fixed a single book because
the write path canonicalizes the row it reuses, but nothing ever ran for untouched books.
Collapse stored names to the form normalizeMetadataTextSql() produces, merging rows that collide
once collapsed and remapping every junction and content-filter reference before a duplicate is
dropped. Case variants are left alone, since search is already case-insensitive. Close the two
write paths that still produced un-collapsed names, and normalize the typed search term so a
pasted non-breaking space still matches.
Also carries staged client appearance work: the surface brightness picker and related controls.
Closes#1014
Sorting by "Last Read" ordered on reading_progress.updated_at, but the KOReader sync path
deliberately freezes that column so it stays a reliable "last local write" marker when resolving
conflicts against device progress. Readers who sync from KOReader therefore kept the timestamp of
their first ever sync of each book forever, so the smart scope sort, the jump-bucket rails and the
dashboard Continue Reading list all looked unordered.
Add reading_progress.last_read_at, which advances on every write unless a path opts out, and move
every "last read" reader onto it. Leave updated_at to the conflict-resolution and progress-resume
paths that genuinely mean "last local write". Kobo bookmark-location stamping opts out because
recording which Location a bookmark maps to is bookkeeping rather than reading, while the Kobo
progress write and the migration importer pass their source timestamps through so imported history
is not stamped as read today.
Move the ebook-versus-audiobook recency tiebreaks onto the new column as well. They compared a
frozen timestamp against a live one, which selected the wrong percentage for the same reason.
Swap the (user_id, updated_at) index for (user_id, last_read_at), since the ordering consumer that
justified it moved. Backfill last_read_at from updated_at in the migration, as the generated
DEFAULT now() would otherwise make every existing book look as though it had just been read.
Fixes#1013
When library items are scanned or moved on case-insensitive filesystems, paths with non-standard author or folder capitalization point to the same physical disk entries. Treating previous path spellings as distinct caused scanner, upload, dock, and rename services to fail path-reuse lookups and create duplicate book records.
Introduce path identity helpers to check whether paths resolve to the same underlying filesystem entry, treating matching entries as path updates rather than creating duplicate books.
Closes#1012
Use the patched Go standard library to address CVE-2026-39821 and CVE-2026-46600 in the bundled binaries. Keep the reported v4-dev version unchanged so existing rendition hashes remain stable.
Filter provider contributors by author role and treat empty values as absent for non-destructive merge strategies while preserving explicit overwrite clears.
Fixes#1001
KOReader page-stat uploads can grow an existing derived session as more page events arrive, but
only newly inserted sessions reached achievement evaluation. A session that crossed a threshold by
being updated showed progress past that threshold while the badge stayed locked.
Carry the updated sessions out of the repository instead of a bare count and emit for them too.
The oversized-upload branch emitted a backfill event that nothing listened for, so subscribe
achievement evaluation to it as well; those runs are serialized per user and a burst collapses into
one trailing re-run, since each backfill re-evaluates the whole catalogue. Awards from that branch
stay silent: an upload that large is a device catching up, and notifying per badge would burst a
toast for every historical unlock.
Fixes#991
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
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