* feat(scanner): set book added_at from earliest on-disk file mtime
A book's "date added" now reflects when its content files first landed on
disk (the earliest mtime across its content-role files) instead of the
moment BookOrbit imported it. Cover/metadata/supplement sidecars are
excluded. When no content file has a usable mtime, addedAt is left unset so
the DB defaultNow() fallback (import time) still applies. Only set at
initial book creation; re-scan/restore paths do not touch it.
* feat(library): add per-library added_at source with recompute
Add an addedAtSource setting to libraries so "date added" can follow the
book's files on disk rather than the moment the scanner happened to see
them. The setting takes imported (unchanged default), file_modified, or
file_created, guarded by a check constraint on the column.
Expose a recompute action so the chosen source can be applied to books
that are already imported. file_modified runs as a single set-based
update over the stored mtimes; file_created has to stat the files, since
birthtime is not persisted, so it batches those reads.
Leave every existing library on imported so behavior is unchanged unless
a user opts in.
* feat(library): add added_at source control to the library editor
Surface the new addedAtSource setting in the library editor as a radio
group, alongside a button that triggers the recompute for the library
being edited.
Keep the recompute out of the save path so changing the setting stays
cheap: the setting alone only affects books imported from then on, and
applying it to existing books is an explicit choice.
* i18n(library): translate added_at source strings (de/nl/pt/sl)
* fix(library): correct create payload, recompute floor and failure logging
The library creator posts its whole form, so omitting addedAtSource from
CreateLibraryDto made the global whitelist reject every create request
with a 400. Declare the field, validate it against the same source list
as the update DTO, and persist it so the initial scan uses the requested
source rather than silently falling back to imported.
Apply the scanner's usable-time floor to the mtime recompute query too.
It previously excluded only NULL, so a filesystem reporting epoch 0 would
rewrite added_at to 1970 instead of leaving the import time in place. The
floor now lives in library.constants rather than being spelled out twice.
Wrap the recompute in try/catch so a database or stat failure emits the
[fail] log the logging conventions require instead of propagating with
only a [start] line recorded.
* fix(library): gate recompute on the saved source and label the radios
The recompute button stayed enabled while the selected source differed
from the stored one. Because the request carries no source, clicking it
recomputed against the old stored value while the UI showed the new
selection. A hint already said to save first; disable the control as well
so the mismatch is not actionable.
Give the source list radiogroup semantics with aria-checked so screen
readers can tell which option is active, since selection was conveyed by
styling alone.
* test(library): assert the create DTO under the real validation options
The previous comment claimed the case exercised whitelist and
forbidNonWhitelisted, but the shared helper calls validate without them,
so it only proved @IsIn rejects an unknown value.
Drop the misleading comment and add a case that validates with the same
options main.ts gives ValidationPipe, so an undeclared addedAtSource
fails the way it would in a real request.
* docs(library): remove redundant date-added documentation
---------
Co-authored-by: Bob Bobber <user@example.com>
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
Require an enabled OIDC-linked superuser before disabling password flows. Track session authentication methods and fail closed across the API, web client, WebSockets, and administrative mutations.
Closes#1002
* 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
Expose promotion and demotion to existing superusers while keeping shared and self targets blocked. Serialize role lifecycle changes, preserve one active superuser, and invalidate stale credentials.
Closes#1208
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.
Field rules resolve each field from whichever provider ranks first for it, and nothing ever
compared candidates to one another. A provider that matched the wrong book still won every field
it led on, so a single record could carry a title from one book, a cover from a second and a
subtitle from a third. The stock defaults make that the ordinary case rather than an unlucky one,
since cover leads with Amazon while title and subtitle lead with Goodreads.
Relevance scoring is what let those wrong matches in. A candidate survived on any score above
zero, and token overlap counted stopwords, so "The Girl on the Train" scored against a query for
"The Hobbit" on the strength of a shared "the". Substring matching ignored word boundaries as
well, which is how "Ubik" matched "Rubik Cube Solutions". Drop stopwords from the query side,
require a majority of the words that remain, match whole words only, and give the surviving floor
a name.
Then hold every candidate to an anchor before field rules ever see it. The anchor is the edition
confirmed by the book's own ISBN when there is one, otherwise the candidate the query supports
most strongly, with the title rule breaking ties because that is where the reader declares which
provider decides identity. Agreement admits subtitle extensions, reordered words and translated
editions, and rejects on a contradicting author, which is the only signal that separates a book
from a companion volume that extends its title. Reject solely on positive evidence: a candidate
with no title, or with no author to compare against, keeps the benefit of the doubt, and a lone
provider is never second-guessed. Rejected candidates no longer contribute their provider ids
either, which would otherwise pin the wrong book for every later refresh.
Normalizing titles to [a-z0-9] emptied every title written in Japanese, Chinese, Russian or
Greek. That scored two providers reporting an identical title as a disagreement, which under the
new anchor would drop all but one of them in those libraries, and it had already denied the
relevance gate any way to score them at all. Decompose instead, strip nonspacing marks so Soseki
matches Sōseki, and keep letters of any script.
Two sets of fixtures described different books per provider while testing something else
entirely, field-rule precedence and metadata locking, and now describe one book so that they
still test what they were written for.
Closes#1023
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
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
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.
Add index on books table for random dashboard scroller queries, batch dashboard scroller requests, instrument pg pool logging, and cover dashboard scroller batching in e2e tests.
Replaces the implementation from #818 with a job-backed flow. Moves are
planned up front and surfaced as a preview the user confirms, then run
through a planner/executor split against a book_move_jobs table, so
progress is trackable and partial failures roll back.
The dialog-based flow from #818 is superseded: its module, service, and
repository are removed here in favour of the job-based equivalents. Its
e2e coverage is replaced by a book-move suite that exercises preview
classification, the collision policies, file relocation, preserved user
state, and the books-before-book_files update ordering that the
composite folder foreign key constrains.
Also adds dashboard shelf layout configuration.
Ref #257
Add server support for writing metadata, embedded covers, and FictionBook XML structures directly into FB2 format files. Introduce migration 0058 to persist FB2 file write preferences per library, and update library creator UI and locale catalogs.
Kobo devices send GET requests to /v1/analytics/gettests during sync checks. Previously only POST was handled, causing 404 errors during subsequent syncs.
Closes#697
* build(deps): synchronize i18n lockfile
Record the locale dependencies so frozen installs can resolve the client workspace.
* feat(stats): expose privacy-aware reading insights
- Give administrators permission-gated access to bounded account activity.
- Let readers opt into auditable shared reading insights.
Ref #501
Introduce a LIBRARY_BROWSE_ROOT environment variable (defaulting to the
current directory) to restrict directories users can browse and select when
setting up library paths. Add a PathPolicyService on the backend to enforce
path containment checks securely, and update the frontend FolderPickerModal
to prevent navigating above the configured browse root.
Closes#567
Import Booklore and Grimmory reading sessions, personal ratings, and progress state through the migration pipeline.
Preserve book-level progress as primary-file progress when source file progress rows are absent.
Closes#482Closes#483
Select all acquisition content files from the database for each book
instead of only the primary file. Return multiple acquisition links
in the OPDS catalog feed for books available in multiple formats,
and update unit and E2E tests to verify catalog output and downloads.
Closes#224
* feat(library): add bulk rename based on file naming settings
Provides a way to rename all books in a library according to the user's
configured file naming template without requiring manual metadata edits.
Includes a streaming endpoint to report progress back to the frontend.
Closes#121
* test(library): add e2e tests for bulk file rename
Introduce a comprehensive E2E test suite for the bulk rename functionality to verify behavior across different library modes (book per file, book per folder) and handle edge cases like naming collisions or special characters.
Additionally fix a TypeScript compilation error in BulkRenameView.vue by replacing `replaceAll` with a regex-based `replace` for broader target compatibility.
---------
Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
* fix(server): deduplicate migration user-state upsert batches
Prevent migration failures when source data contains duplicate user-state rows for the same target keys. Keep the latest row per upsert key and prefer richer reading progress signals on timestamp ties, then cover the behavior with focused unit and e2e tests.
Closes#71
* ci: raise commitlint body line limit to 500 chars
Allow longer commit body lines so convention checks do not fail on detailed but still concise commit explanations in this repository.
- Remove author insights and duplicate suggestions panels, composables, DTOs, and all backend support
- Add genre and tag duplicate detection, merge, and dismiss with server-side paginated scan results
- Consolidate per-entity-type tools into a single entity-manager module backed by the Strategy pattern
- Support browse, merge, rename, delete, split, and dismiss across all entity types (author, genre, tag, narrator, publisher, language, series)
- Replace old tools module with unified entity-manager controller, service, and repository; add 85+ unit tests