3 Commits

Author SHA1 Message Date
neon 39ac2f1efd feat: request an ebook and audiobook as a pair
Link two ordinary requests as companions, filed into one book, with a Both search,
grouped list rows, a tabbed release picker and a book-page request for the missing
format. Retire comic requests, keeping old rows readable. File Book Dock covers by the
field rules with a shape check, and re-pick a moved book's primary by its new library.
2026-10-08 16:12:30 +00:00
neon f4cfc3ca5d feat(books): rework cross-library moves with preview and progress
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
2026-08-02 17:24:18 +00:00
Oliver 41fd6f1881 feat(book): move books between libraries (#818)
* feat(books): add moving books between libraries

Allow moving books to another library without re-uploading, so a staging
library (e.g. book-dock imports) can feed curated genre libraries. Re-parent
the existing book row instead of delete+recreate so reading progress,
annotations, collections, and Kobo sync state survive the move. Relocate
files by rebasing library-relative paths onto the target folder with
rollback on failure and a copy+delete fallback for cross-filesystem moves,
then schedule the target library's rename pattern. Exposed as a bulk
endpoint plus UI actions in the selection bar and book detail page.

Closes #257

* ci: allow container image dispatch from any branch

Enable building a GHCR image from a feature branch via workflow_dispatch
to test changes in a container before they land on main. The main tag is
still only updated by the CI-gated workflow_run path.

Ref #257

* fix(books): update book row before files when moving between libraries

book_files_book_folder_consistency_fk references books(id, library_folder_id)
with ON UPDATE CASCADE, so updating a file's library_folder_id while the book
still points at the source folder violates the constraint and the move fails
with a 500. Update the books row first so the new folder id cascades to the
files, then rewrite the file paths. Verified against a real Postgres instance
in addition to the reordered unit test expectations.

Ref #257

* test(books): add book move e2e suite

Unit tests mock the database and cannot catch constraint-order bugs like
the book_files_book_folder_consistency_fk violation fixed in b1c430df.
Cover the move endpoint against a real Postgres and filesystem: successful
cross-library move with row re-parenting, file relocation, and preserved
user state, plus disk-collision skip, foreign-folder rejection, and
format-restriction skip. Reuses the metadata-write e2e harness.

Ref #257

* fix(books): emit transfer event so library views refresh after a move

Moving books via the API updated the database and files but never told
connected clients, so the source and target library views kept stale book
lists and counts until a manual reload. Emit the existing book:transferred
websocket event (grouped per source library) after successful moves; the
library and book detail views already subscribe to it and refresh their
lists, counts, and buckets.

Ref #257

* fix(books): refresh views deterministically after moving books

The initiating library view only removed rows locally and relied on the
book:transferred websocket round-trip for counts, so deployments where
websockets are unavailable kept stale headers and sidebar badges until a
reload. Refresh the view's list and buckets directly after a successful
move, refresh the sidebar library counts on transfer events like scans
and uploads already do, and surface an error toast when a move is
confirmed with an empty selection instead of silently doing nothing.
Promote the move action from the overflow menu to a dedicated,
count-gated button in the selection action bar.

Ref #257

* fix(books): show move action for demo-restricted accounts like delete

The move button was additionally gated on the demo-restriction flag, so
accounts carrying the explicit demo_restricted permission saw the delete
button but no way to move books. Gate it on library_delete_books alone,
mirroring the delete action next to it; demo instances withhold that
permission anyway.

Ref #257

* style(server): align union type formatting with upstream main

The criss-cross merges of two upstream states left seven files with the
older prettier union-type layout, which fails lint against the current
formatter output. Restore the files byte-identical to upstream main.

* i18n(client): translate the move-to-library UI

The move action, dialog, and detail menu entries carried hardcoded
English strings from before the vue-i18n migration. Move them to
translation keys following the existing catalog structure and add
entries to all locale catalogs so the parity validator passes; Crowdin
can refine the non-English translations later.

Ref #257

* security(deps): update brace-expansion and engine.io past new advisories

Trivy fails the container build on CVE-2026-13149 (brace-expansion DoS)
and CVE-2026-59725 (engine.io DoS). Both fixes are semver-compatible
patch releases, so refreshing the lockfile resolves them without
manifest changes.

* security(deps): patch fastify static and find-my-way CVEs

Trivy blocks image builds on CVE-2026-15074 (route guard bypass in
fastify/static 9.x) and CVE-2026-47219 (HTTP2 DDoS in find-my-way
9.6.0). Bump the direct fastify/static dependency to 10.1.2 and add a
find-my-way override because nestjs/platform-fastify pins it to an
exact vulnerable version. The static plugin usage (register with root
and prefix plus sendFile fallback) is unchanged in v10; the peer range
warning from NestJS is expected until it allows v10.

* i18n(client): add BookBulkMove audit action label to all catalogs

Upstream's audit filtering feature derives its action options from the
AuditAction enum and requires a label for every member. Add the label
for the move feature's BookBulkMove action to all nine locale
catalogs.

* ci: restore main-only guard for container image dispatch

Revert the fork-local change that allowed workflow_dispatch container
builds from any branch. The guard matches upstream again so the branch
only carries the move feature. Fork builds continue from the
fork-build branch which keeps the relaxed dispatch.

This reverts commit b13d276a4becbba7be80723ff6708df23132b3fb.

* style(client): restore upstream formatting in untouched files

Merge conflict resolution left four files with stale prettier
formatting that differs from upstream while carrying no functional
change. Restore them byte-identical to upstream/main so the move
feature diff stays clean.

* fix(book-move): harden error paths raised in PR review

Address review feedback on bookorbit#818:

- Guard the applyMove call so a database error fails only that book
  instead of aborting the batch, which discarded collected results and
  skipped transfer events for books that had already moved
- Replace the bare catch around the source access check with one that
  maps ForbiddenException to the access reason and logs unexpected
  errors with a distinct failure reason
- Report an incomplete rollback in the outcome reason so callers know
  a book may need manual verification
- Ignore backdrop and cancel clicks in the move dialog while a move is
  running
- Catch thrown move requests in handleBulkMove, surface an error
  toast, and keep the dialog open on transport errors so the user can
  retry
- Wire onBooksMoved in SmartScopeView so scope totals and jump buckets
  refresh deterministically after a move
- Extend the book-move e2e suite changedPaths with the library and
  scanner modules and the metadata-write harness it depends on

* i18n(client): fill move keys with English source for Crowdin

Crowdin owns the target catalogs, so hand-written translations get
overwritten or conflict on the next export. Replace the translated
move dialog, menu, tooltip, and audit label values in all fifteen
target catalogs with the English source strings, keeping the
locale-specific ICU plural categories the parity validator requires.
Real translations happen in Crowdin.

* fix(book-move): harden access, filesystem, and audit behavior from review

Address the maintainer review on bookorbit#818, server side:

- Require editor-level access on the target library via a new
  LibraryService.verifyUserAccessLevel backed by a repository lookup,
  mirroring the LibraryAccessGuard ranking; viewer access could
  previously write files into a library through this endpoint
- Skip books whose files are missing before touching the database
- Assert resolved target paths stay inside the target folder so a bad
  relPath cannot escape the library via mkdir and rename
- Bound empty-directory cleanup at the library folder root and walk
  nested directories upward so flat libraries keep their configured
  folder and Author/Series trees do not orphan
- Bump books.updatedAt in applyMove so Kobo sync sees moved books, and
  cancel the source library's pending unavailable-books notification
  like the scanner's transfer path does
- Suppress file-watcher events for all touched paths through
  SelfWriteRegistry while files move, closing the race against
  missing-marking and duplicate detection
- Replace rename with link+unlink (COPYFILE_EXCL on cross-device
  fallback) so concurrent moves resolving the same target fail with
  EEXIST instead of silently clobbering each other
- Record the actual moved/skipped/failed outcome in the audit entry
  instead of the requested count

* fix(book-move): use stable outcome codes and an accessible move dialog

Address the client-facing maintainer review items on bookorbit#818:

- Replace the free-form English outcome reasons with a stable code
  union in MoveBookOutcome; the prose stays in server log lines and
  the client maps codes to translated copy, so the UI no longer
  renders raw server strings
- Localize the single-book move toasts on the detail page through the
  new outcome keys (English source in all catalogs, translation via
  Crowdin)
- Rebuild MoveBooksDialog on reka-ui DialogRoot/Content/Title like
  ConfirmDialog, gaining role=dialog, focus trap and restore, Escape
  handling, and aria wiring; closing stays blocked while a move runs
- Filter libraries without folders out of the target picker since
  confirming one is a guaranteed 400

* test(book-move): assert database and disk state in no-op scenarios

The rejected and skipped e2e cases only checked the response before;
a partial move could have passed. Assert the 400 error message, the
unchanged libraryId and libraryFolderId, the source file remaining,
the destination staying absent, and that a colliding target file
keeps its original content.

* feat(book-move): stream bulk moves with per-book progress

The move endpoint processed an unbounded selection serially inside one
request, which cannot finish within proxy timeouts on target-scale
libraries and gave no progress. Convert it to the same SSE pattern as
bulk metadata refresh:

- The endpoint streams one event per book plus a final done summary
  and stops processing when the client disconnects
- Target validation runs before headers are written so user errors
  stay regular JSON error responses
- Audit is emitted manually with the actual outcome because the
  interceptor never sees a response body on streaming handlers
- The bulk composable consumes the stream, reports progress through
  the shared in-flight indicator, and removes moved books from the
  view as their events arrive; the detail page reads the single
  outcome from the stream
- The e2e suite parses the event stream and additionally asserts the
  done summary and content type

* fix(book-move): explain skipped books in multi-library selections

Collection and smart-scope selections can span libraries, so the
books' own library legitimately appears as a move target and picking
it skips them with already_in_target. Surface that in the dialog with
a hint that is only shown when the view has no single current library
to filter out. Exact target filtering would need a library id on every
book card, which collapsed series cards cannot provide unambiguously.

---------

Co-authored-by: ohoeltke <hoeltke@oho-dt.de>
2026-08-02 10:41:57 -06:00