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>
This commit is contained in:
Oliver
2026-08-02 18:41:57 +02:00
committed by GitHub
parent d86b32ed85
commit 41fd6f1881
49 changed files with 3047 additions and 7 deletions
+6
View File
@@ -21,6 +21,7 @@ import { useCollections } from '@/features/collection/composables/useCollections
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary'
import type { Library } from '@bookorbit/types'
import CreateSmartScopeDialog from '@/features/smart-scope/components/CreateSmartScopeDialog.vue'
@@ -156,6 +157,11 @@ const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
void refreshBrowseCounts()
})
// Book moves change per-library counts; refresh the sidebar badges like scans and uploads do.
useBookEvents().onBookTransferred(() => {
void refreshLibraries()
})
onUnmounted(() => stopLibraryUploadListener())
</script>
+19 -1
View File
@@ -5,6 +5,7 @@ import {
BookOpen,
Download,
FileSpreadsheet,
FolderInput,
FolderMinus,
FolderPlus,
ImageDown,
@@ -84,6 +85,7 @@ const emit = defineEmits<{
'set-rating': [rating: number | null]
'set-field': [field: BulkEditableField, value: BulkEditableValue]
'lock-metadata': [locked: boolean]
move: []
delete: []
exit: []
}>()
@@ -105,6 +107,8 @@ const hasCustomContent = computed(() => Boolean(slots.content))
const canBulkActions = computed(() => !isDemoRestrictedAccount.value)
const canDownload = computed(() => hasPermission('library_download') && canBulkActions.value)
const canEditMetadata = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value)
// Gated like delete below: only on the permission, not on the demo restriction.
const canMove = computed(() => hasPermission('library_delete_books'))
const canShowMoreMenu = computed(() => canDownload.value || canEditMetadata.value)
const canShare = computed(() => hasPermission('email_send') || canDownload.value)
const numericFieldSelected = computed(() => bulkField.value === 'publishedYear')
@@ -449,7 +453,21 @@ watch(
<TooltipContent side="top">{{ t('components.selectionActionBar.moreActions') }}</TooltipContent>
</Tooltip>
<div v-if="hasPermission('library_delete_books')" :class="DIVIDER" />
<div v-if="canMove || hasPermission('library_delete_books')" :class="DIVIDER" />
<Tooltip v-if="canMove">
<TooltipTrigger as-child>
<button
data-testid="action-bulk-move"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('move')"
>
<FolderInput :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">{{ t('components.selectionActionBar.moveToLibrary') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="hasPermission('library_delete_books')">
<TooltipTrigger as-child>
@@ -162,6 +162,42 @@ describe('SelectionActionBar demo restriction', () => {
expect(wrapper.emitted('lock-metadata')?.map((args) => args[0])).toEqual([true, false])
})
it('emits move from a direct action button for users with delete permission', async () => {
const wrapper = mountBar()
const moveButton = wrapper.find('button[data-testid="action-bulk-move"]')
expect(moveButton.exists()).toBe(true)
await moveButton.trigger('click')
expect(wrapper.emitted('move')).toHaveLength(1)
})
it('disables the move button while nothing is selected', () => {
const wrapper = mount(SelectionActionBar, {
props: { visible: true, count: 0, inCollection: false, inFlight: null },
global: globalStubs,
})
// Direct bar buttons are count-gated; the old dropdown entry was not.
expect(wrapper.find('button[data-testid="action-bulk-move"]').attributes('disabled')).toBeDefined()
})
it('hides the move action without delete permission', () => {
permissionState.allowed = new Set(['library_edit_metadata'])
const wrapper = mountBar()
expect(wrapper.find('[data-testid="action-bulk-move"]').exists()).toBe(false)
})
it('keeps the move action for demo-restricted accounts with delete permission, mirroring delete', () => {
permissionState.allowed = new Set(['library_delete_books'])
permissionState.demoRestricted = true
const wrapper = mountBar()
expect(wrapper.find('[data-testid="action-delete"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="action-bulk-move"]').exists()).toBe(true)
})
it('emits set-field from the field editor flow', async () => {
const wrapper = mountBar()
await wrapper.find('[data-testid="action-bulk-set-field"]').trigger('click')
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { FolderInput, Loader2 } from '@lucide/vue'
import { DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { Button } from '@/components/ui/button'
import { useLibraries } from '@/features/library/composables/useLibraries'
const props = defineProps<{
open: boolean
count: number
currentLibraryId?: number | null
moving?: boolean
}>()
const emit = defineEmits<{ confirm: [libraryId: number, folderId: number | undefined]; cancel: [] }>()
const { t } = useI18n()
const { libraries, fetchLibraries } = useLibraries()
const selectedLibraryId = ref<number | null>(null)
const selectedFolderId = ref<number | null>(null)
// Libraries without folders cannot receive files; confirming one is a
// guaranteed 400 out of the target folder resolution.
const targetLibraries = computed(() => libraries.value.filter((library) => library.id !== props.currentLibraryId && library.folders.length > 0))
const selectedLibrary = computed(() => targetLibraries.value.find((library) => library.id === selectedLibraryId.value) ?? null)
const needsFolderChoice = computed(() => (selectedLibrary.value?.folders.length ?? 0) > 1)
const canConfirm = computed(() => {
if (props.moving) return false
if (!selectedLibrary.value) return false
return !needsFolderChoice.value || selectedFolderId.value !== null
})
watch(
() => props.open,
(open) => {
if (open) {
void fetchLibraries()
selectedLibraryId.value = null
selectedFolderId.value = null
}
},
{ immediate: true },
)
watch(selectedLibraryId, () => {
selectedFolderId.value = null
})
function handleOpenChange(open: boolean) {
if (!open && !props.moving) emit('cancel')
}
function onLibraryChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
selectedLibraryId.value = value ? Number(value) : null
}
function onFolderChange(event: Event) {
const value = (event.target as HTMLSelectElement).value
selectedFolderId.value = value ? Number(value) : null
}
function onCancel() {
if (props.moving) return
emit('cancel')
}
function onConfirm() {
if (!canConfirm.value || selectedLibraryId.value === null) return
emit('confirm', selectedLibraryId.value, needsFolderChoice.value ? (selectedFolderId.value ?? undefined) : undefined)
}
</script>
<template>
<DialogRoot :open="open" @update:open="handleOpenChange">
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-50 bg-foreground/50" />
<DialogContent
aria-modal="true"
class="fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-card p-6 shadow-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div class="flex items-start gap-4">
<div class="shrink-0 h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<FolderInput class="text-primary" :size="18" aria-hidden="true" />
</div>
<div class="min-w-0">
<DialogTitle class="text-base font-semibold text-foreground">{{ t('book.moveDialog.title', { count }) }}</DialogTitle>
<DialogDescription class="text-sm text-muted-foreground mt-1">
{{ t('book.moveDialog.description') }}
</DialogDescription>
<!-- Collection and smart-scope selections can span libraries, so the
books' own library may appear as a target; picking it skips them. -->
<p v-if="currentLibraryId == null" data-testid="move-multi-library-hint" class="text-xs text-muted-foreground mt-2">
{{ t('book.moveDialog.multiLibraryHint') }}
</p>
</div>
</div>
<div class="space-y-3 mt-5">
<label class="block">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">{{ t('book.moveDialog.targetLibrary') }}</span>
<select data-testid="move-library-select" :value="selectedLibraryId ?? ''" class="select-field w-full mt-1" @change="onLibraryChange">
<option value="" disabled>{{ t('book.moveDialog.selectLibrary') }}</option>
<option v-for="library in targetLibraries" :key="library.id" :value="library.id">{{ library.name }}</option>
</select>
</label>
<label v-if="needsFolderChoice" class="block">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wide">{{ t('book.moveDialog.targetFolder') }}</span>
<select data-testid="move-folder-select" :value="selectedFolderId ?? ''" class="select-field w-full mt-1" @change="onFolderChange">
<option value="" disabled>{{ t('book.moveDialog.selectFolder') }}</option>
<option v-for="folder in selectedLibrary?.folders ?? []" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
</label>
</div>
<div class="mt-6 flex justify-end gap-2">
<Button data-testid="move-cancel" variant="outline" :disabled="moving" @click="onCancel">
{{ t('common.cancel') }}
</Button>
<Button data-testid="move-confirm" :disabled="!canConfirm" @click="onConfirm">
<Loader2 v-if="moving" class="animate-spin" :size="16" aria-hidden="true" />
{{ t('book.moveDialog.move') }}
</Button>
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
@@ -70,6 +70,7 @@ const globalStubs = {
DialogClose: { template: '<button><slot /></button>' },
AddToCollectionSheet: true,
DeleteBookDialog: true,
MoveBooksDialog: true,
ResetReadingStateDialog: true,
},
}
@@ -0,0 +1,177 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { ref } from 'vue'
import type { Library } from '@bookorbit/types'
const librariesRef = ref<Library[]>([])
const fetchLibraries = vi.fn<() => Promise<void>>().mockResolvedValue(undefined)
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({
libraries: librariesRef,
loading: ref(false),
loaded: ref(true),
fetchLibraries,
}),
}))
import MoveBooksDialog from '../MoveBooksDialog.vue'
const DialogRootStub = { name: 'DialogRoot', props: ['open'], emits: ['update:open'], template: '<div><slot /></div>' }
const globalStubs = {
stubs: {
DialogRoot: DialogRootStub,
DialogPortal: { template: '<div><slot /></div>' },
DialogOverlay: { template: '<div />' },
DialogContent: { template: '<div><slot /></div>' },
DialogTitle: { template: '<div><slot /></div>' },
DialogDescription: { template: '<div><slot /></div>' },
},
}
function makeLibrary(overrides: Partial<Library> = {}): Library {
return {
id: 1,
name: 'SF&F',
folders: [{ id: 11, path: '/books/sff', createdAt: '2026-01-01T00:00:00.000Z' }],
...overrides,
} as Library
}
function mountDialog(props: { open?: boolean; count?: number; currentLibraryId?: number | null; moving?: boolean } = {}) {
return mount(MoveBooksDialog, {
props: {
open: true,
count: 2,
currentLibraryId: null,
...props,
},
global: globalStubs,
})
}
describe('MoveBooksDialog', () => {
beforeEach(() => {
fetchLibraries.mockClear()
librariesRef.value = [
makeLibrary(),
makeLibrary({ id: 2, name: 'Thrillers', folders: [{ id: 21, path: '/books/thrillers', createdAt: '2026-01-01T00:00:00.000Z' }] }),
makeLibrary({
id: 3,
name: 'Multi',
folders: [
{ id: 31, path: '/books/multi-a', createdAt: '2026-01-01T00:00:00.000Z' },
{ id: 32, path: '/books/multi-b', createdAt: '2026-01-01T00:00:00.000Z' },
],
}),
]
})
it('renders translated title, labels, and buttons through the i18n keys', () => {
const wrapper = mountDialog({ count: 2 })
expect(wrapper.text()).toContain('Move 2 books to library')
expect(wrapper.text()).toContain('Target library')
expect(wrapper.find('[data-testid="move-library-select"] option').text()).toBe('Select a library…')
expect(wrapper.find('[data-testid="move-cancel"]').text()).toBe('Cancel')
expect(wrapper.find('[data-testid="move-confirm"]').text()).toBe('Move')
})
it('uses the singular title form for a single book', () => {
const wrapper = mountDialog({ count: 1 })
expect(wrapper.text()).toContain('Move 1 book to library')
})
it('lists all libraries except the current one', () => {
const wrapper = mountDialog({ currentLibraryId: 1 })
const options = wrapper.find('[data-testid="move-library-select"]').findAll('option')
const labels = options.map((option) => option.text())
expect(labels).toContain('Thrillers')
expect(labels).toContain('Multi')
expect(labels).not.toContain('SF&F')
})
it('hides libraries without folders because they cannot receive files', () => {
librariesRef.value = [...librariesRef.value, makeLibrary({ id: 4, name: 'Folderless', folders: [] })]
const wrapper = mountDialog()
const labels = wrapper
.find('[data-testid="move-library-select"]')
.findAll('option')
.map((option) => option.text())
expect(labels).not.toContain('Folderless')
})
it('confirms with the selected library without a folder id for single-folder libraries', async () => {
const wrapper = mountDialog()
await wrapper.find('[data-testid="move-library-select"]').setValue('2')
await wrapper.find('[data-testid="move-confirm"]').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([[2, undefined]])
})
it('requires a folder choice for multi-folder libraries and passes it on confirm', async () => {
const wrapper = mountDialog()
await wrapper.find('[data-testid="move-library-select"]').setValue('3')
expect(wrapper.find('[data-testid="move-folder-select"]').exists()).toBe(true)
await wrapper.find('[data-testid="move-folder-select"]').setValue('32')
await wrapper.find('[data-testid="move-confirm"]').trigger('click')
expect(wrapper.emitted('confirm')).toEqual([[3, 32]])
})
it('disables confirm until a library is selected', () => {
const wrapper = mountDialog()
expect(wrapper.find('[data-testid="move-confirm"]').attributes('disabled')).toBeDefined()
})
it('emits cancel from the cancel button', async () => {
const wrapper = mountDialog()
await wrapper.find('[data-testid="move-cancel"]').trigger('click')
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('emits cancel when the dialog requests to close', () => {
const wrapper = mountDialog()
wrapper.findComponent(DialogRootStub).vm.$emit('update:open', false)
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('ignores close requests and cancel clicks while a move is running', async () => {
const wrapper = mountDialog({ moving: true })
wrapper.findComponent(DialogRootStub).vm.$emit('update:open', false)
await wrapper.find('[data-testid="move-cancel"]').trigger('click')
expect(wrapper.emitted('cancel')).toBeUndefined()
})
it('shows the multi-library skip hint when the view has no single current library', () => {
const wrapper = mountDialog({ currentLibraryId: null })
expect(wrapper.find('[data-testid="move-multi-library-hint"]').text()).toBe('Books that are already in the selected library are skipped.')
})
it('hides the skip hint when the current library is known and filtered out', () => {
const wrapper = mountDialog({ currentLibraryId: 1 })
expect(wrapper.find('[data-testid="move-multi-library-hint"]').exists()).toBe(false)
})
it('fetches libraries when opened', () => {
mountDialog()
expect(fetchLibraries).toHaveBeenCalled()
})
})
@@ -9,6 +9,7 @@ import {
Check,
ChevronDown,
Eye,
FolderInput,
Library,
Headphones,
Lock,
@@ -30,7 +31,15 @@ import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import { FORMAT_TO_GROUP, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadStatus, UserBookStatus } from '@bookorbit/types'
import type {
BookDetail,
BookKoboState,
CustomMetadataBookValue,
MoveBookOutcome,
MoveBooksProgressEvent,
ReadStatus,
UserBookStatus,
} from '@bookorbit/types'
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
import BookDownloadButton from '@/features/book/components/BookDownloadButton.vue'
import DiscoverRow from '@/features/book/components/detail/DiscoverRow.vue'
@@ -45,6 +54,8 @@ import { useMetadataLocks } from '@/features/book/composables/useMetadataLocks'
import { usePersonalNote, PERSONAL_NOTE_MAX_LENGTH } from '@/features/book/composables/usePersonalNote'
import { useResetReadingState } from '@/features/book/composables/useResetReadingState'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import MoveBooksDialog from '@/features/book/components/MoveBooksDialog.vue'
import { toast } from 'vue-sonner'
import ResetReadingStateDialog from '@/features/book/components/ResetReadingStateDialog.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import AddToCollectionSheet from '@/features/collection/components/AddToCollectionSheet.vue'
@@ -915,6 +926,74 @@ function handleDeleteFromMenu() {
promptDelete(props.book.id)
}
const moveBookOpen = ref(false)
const movingBook = ref(false)
function handleMoveFromMenu() {
moreMenuOpen.value = false
mobileMoreMenuOpen.value = false
moveBookOpen.value = true
}
// The move endpoint streams one SSE event per book; for a single book the
// first outcome event is the result.
async function readSingleMoveOutcome(res: Response): Promise<MoveBookOutcome | null> {
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let outcome: MoveBookOutcome | null = null
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6)) as MoveBooksProgressEvent
if (!('done' in data && data.done) && outcome === null) outcome = data as MoveBookOutcome
} catch {
// Ignore malformed stream lines.
}
}
}
return outcome
}
async function confirmMoveBook(libraryId: number, folderId?: number) {
if (movingBook.value) return
movingBook.value = true
try {
const res = await api('/api/v1/books/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
bookIds: [props.book.id],
targetLibraryId: libraryId,
...(folderId !== undefined ? { targetFolderId: folderId } : {}),
}),
})
if (!res.ok) {
toast.error(t('book.detail.details.moveFailed'))
return
}
const outcome = await readSingleMoveOutcome(res)
if (outcome?.status !== 'moved') {
toast.error(
outcome?.reason
? t('book.detail.details.moveNotMovedWithReason', { reason: t(`book.moveOutcomes.${outcome.reason}`) })
: t('book.detail.details.moveNotMoved'),
)
return
}
toast.success(t('book.detail.details.moveSuccess'))
const detailRes = await api(`/api/v1/books/${props.book.id}`)
if (detailRes.ok) {
emit('saved', (await detailRes.json()) as BookDetail)
}
} finally {
movingBook.value = false
moveBookOpen.value = false
}
}
function handleSendFromMenu() {
moreMenuOpen.value = false
mobileMoreMenuOpen.value = false
@@ -1410,6 +1489,14 @@ watch(
<RotateCcw class="size-3.5" />
Reset reading state
</button>
<button
v-if="hasPermission('library_delete_books')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
@click="handleMoveFromMenu"
>
<FolderInput class="size-3.5" />
{{ t('book.detail.details.moveToLibrary') }}
</button>
<button
v-if="hasPermission('library_delete_books')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
@@ -1573,6 +1660,14 @@ watch(
<RotateCcw class="size-3.5" />
Reset reading state
</button>
<button
v-if="hasPermission('library_delete_books')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-foreground hover:bg-muted transition-colors"
@click="handleMoveFromMenu"
>
<FolderInput class="size-3.5" />
{{ t('book.detail.details.moveToLibrary') }}
</button>
<button
v-if="hasPermission('library_delete_books')"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded text-sm text-destructive hover:bg-destructive/10 transition-colors"
@@ -2239,6 +2334,15 @@ watch(
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
<MoveBooksDialog
:open="moveBookOpen"
:count="1"
:current-library-id="book.libraryId"
:moving="movingBook"
@confirm="confirmMoveBook"
@cancel="moveBookOpen = false"
/>
<ResetReadingStateDialog
:open="resetReadingStateDialogOpen"
:resetting="resettingReadingState"
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<{ ok: boolean }>>(),
toastSuccess: vi.fn<(message: string) => void>(),
toastError: vi.fn<(message: string) => void>(),
toastWarning: vi.fn<(message: string) => void>(),
bumpVersion: vi.fn<(bookId: number) => void>(),
markRefreshing: vi.fn<(bookIds: number[]) => void>(),
clearRefreshing: vi.fn<(bookIds: number[]) => void>(),
@@ -37,7 +38,7 @@ vi.mock('vue-sonner', () => ({
toast: {
success: mocks.toastSuccess,
error: mocks.toastError,
warning: vi.fn<(message: string) => void>(),
warning: mocks.toastWarning,
},
}))
@@ -415,6 +416,142 @@ describe('useBookBulkActions', () => {
expect(mocks.toastSuccess).toHaveBeenCalledWith('Deleted 500 books')
})
it('moves selected books and removes moved ids from the current view', async () => {
mocks.api.mockResolvedValue(
makeSseStream([
'data: {"bookId":1,"status":"moved"}',
'data: {"bookId":2,"status":"skipped","reason":"already_in_target"}',
'data: {"done":true,"total":2,"moved":1,"skipped":1,"failed":0,"cancelled":false}',
]) as never,
)
const selectedIds = ref(new Set([1, 2]))
const onDeleted = vi.fn<(ids: number[]) => void>()
const { handleBulkMove } = useBookBulkActions(selectedIds, onDeleted)
await handleBulkMove(3, 9)
expect(mocks.api).toHaveBeenCalledWith(
'/api/v1/books/move',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ bookIds: [1, 2], targetLibraryId: 3, targetFolderId: 9 }),
}),
)
expect(onDeleted).toHaveBeenCalledWith([1])
expect(mocks.toastWarning).toHaveBeenCalledWith('Moved 1 of 2 books (1 skipped)')
})
it('reports a plain success toast when every book moves', async () => {
mocks.api.mockResolvedValue(
makeSseStream([
'data: {"bookId":1,"status":"moved"}',
'data: {"bookId":2,"status":"moved"}',
'data: {"done":true,"total":2,"moved":2,"skipped":0,"failed":0,"cancelled":false}',
]) as never,
)
const selectedIds = ref(new Set([1, 2]))
const onDeleted = vi.fn<(ids: number[]) => void>()
const { handleBulkMove } = useBookBulkActions(selectedIds, onDeleted)
await handleBulkMove(3)
expect(mocks.api).toHaveBeenCalledWith(
'/api/v1/books/move',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ bookIds: [1, 2], targetLibraryId: 3 }),
}),
)
expect(onDeleted).toHaveBeenNthCalledWith(1, [1])
expect(onDeleted).toHaveBeenNthCalledWith(2, [2])
expect(mocks.toastSuccess).toHaveBeenCalledWith('Moved 2 books')
})
it('sends move requests using query selection payloads', async () => {
mocks.api.mockResolvedValue(
makeSseStream([
'data: {"bookId":8,"status":"moved"}',
'data: {"done":true,"total":500,"moved":1,"skipped":0,"failed":0,"cancelled":false}',
]) as never,
)
const selectedIds = ref(new Set<number>())
const onDeleted = vi.fn<(ids: number[]) => void>()
const querySelection = ref<QuerySelectionState | null>({
libraryId: 5,
filter: { type: 'group', join: 'AND', rules: [] },
q: 'space opera',
total: 500,
})
const { handleBulkMove } = useBookBulkActions(selectedIds, onDeleted, undefined, undefined, querySelection)
await handleBulkMove(3, 9)
expect(mocks.api).toHaveBeenCalledWith(
'/api/v1/books/move',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({
query: { libraryId: 5, filter: { type: 'group', join: 'AND', rules: [] }, q: 'space opera' },
targetLibraryId: 3,
targetFolderId: 9,
}),
}),
)
expect(onDeleted).toHaveBeenCalledWith([8])
})
it('surfaces an error toast instead of silently doing nothing when the selection is empty', async () => {
const selectedIds = ref(new Set<number>())
const onDeleted = vi.fn<(ids: number[]) => void>()
const { handleBulkMove } = useBookBulkActions(selectedIds, onDeleted)
await handleBulkMove(3, 9)
expect(mocks.api).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('No books selected to move')
})
it('shows an error toast and keeps the view when the move request fails', async () => {
mocks.api.mockResolvedValue({ ok: false } as never)
const selectedIds = ref(new Set([1]))
const onDeleted = vi.fn<(ids: number[]) => void>()
const { handleBulkMove } = useBookBulkActions(selectedIds, onDeleted)
await handleBulkMove(3, 9)
expect(onDeleted).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('Failed to move books')
})
it('reports a failed move instead of throwing when the request itself throws', async () => {
mocks.api.mockRejectedValue(new Error('network down'))
const selectedIds = ref(new Set([1]))
const onDeleted = vi.fn<(ids: number[]) => void>()
const { handleBulkMove } = useBookBulkActions(selectedIds, onDeleted)
await expect(handleBulkMove(3, 9)).resolves.toBe(false)
expect(onDeleted).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('Failed to move books')
})
it('shows an error toast when no book could be moved', async () => {
mocks.api.mockResolvedValue(
makeSseStream([
'data: {"bookId":1,"status":"failed","reason":"book_not_found"}',
'data: {"done":true,"total":1,"moved":0,"skipped":0,"failed":1,"cancelled":false}',
]) as never,
)
const selectedIds = ref(new Set([1]))
const onDeleted = vi.fn<(ids: number[]) => void>()
const { handleBulkMove } = useBookBulkActions(selectedIds, onDeleted)
await handleBulkMove(3, 9)
expect(onDeleted).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('No books were moved (1 failed)')
})
it('bumps versions for all selected IDs after SSE stream even when no events are received', async () => {
mocks.api.mockResolvedValue(makeSseStream([]))
mocks.bumpVersion.mockReset()
@@ -5,6 +5,8 @@ import type { BookCard } from '@bookorbit/types'
const mockSelectionMode = { value: false }
const mockSelectedIds = { value: new Set<number>() }
const handleBulkMoveMock = vi.hoisted(() => vi.fn<(libraryId: number, folderId?: number) => Promise<boolean>>().mockResolvedValue(true))
const mocks = vi.hoisted(() => ({
enterSelectionMode: vi.fn<() => void>(),
exitSelectionMode: vi.fn<() => void>(),
@@ -23,6 +25,7 @@ const mocks = vi.hoisted(() => ({
handleBulkSetMetadataLock: vi.fn<() => void>(),
handleDeleteSelected: vi.fn<() => void>(),
handleDownloadFiles: vi.fn<() => void>(),
handleBulkMove: handleBulkMoveMock,
inFlight: { value: null },
})),
useDeleteBookCallback: null as ((id: number) => void) | null,
@@ -120,6 +123,44 @@ describe('useBookTableShell', () => {
mocks.setBookContext.mockClear()
})
it('runs bulk move behind a busy flag and closes the move dialog afterwards', async () => {
const books = ref([makeBook()])
const shell = useBookTableShell({ books })
shell.moveBooksOpen.value = true
const pending = shell.confirmMoveBooks(3, 9)
expect(shell.movingBooks.value).toBe(true)
await pending
expect(handleBulkMoveMock).toHaveBeenCalledWith(3, 9)
expect(shell.movingBooks.value).toBe(false)
expect(shell.moveBooksOpen.value).toBe(false)
})
it('keeps the move dialog open and skips the refresh callback when the request fails', async () => {
const books = ref([makeBook()])
const onBooksMoved = vi.fn<() => void>()
const shell = useBookTableShell({ books, onBooksMoved })
handleBulkMoveMock.mockResolvedValueOnce(false)
shell.moveBooksOpen.value = true
await shell.confirmMoveBooks(3, 9)
expect(shell.movingBooks.value).toBe(false)
expect(shell.moveBooksOpen.value).toBe(true)
expect(onBooksMoved).not.toHaveBeenCalled()
})
it('notifies the view after a bulk move so it can refresh counts without relying on websockets', async () => {
const books = ref([makeBook()])
const onBooksMoved = vi.fn<() => void>()
const shell = useBookTableShell({ books, onBooksMoved })
await shell.confirmMoveBooks(3, 9)
expect(onBooksMoved).toHaveBeenCalledTimes(1)
})
it('wires useBookBulkActions without an onBulkRefreshCompleted callback', () => {
const books = ref([makeBook()])
useBookTableShell({ books })
@@ -7,6 +7,9 @@ import {
type BookCard,
type BookDetail,
type BookMetadataLockField,
type MoveBookOutcome,
type MoveBooksProgressEvent,
type MoveBooksSummary,
type ReadStatus,
type UserBookStatus,
} from '@bookorbit/types'
@@ -393,6 +396,83 @@ export function useBookBulkActions(
toast.success(`${locked ? 'Locked' : 'Unlocked'} metadata for ${count} book${count === 1 ? '' : 's'}`)
}
async function handleBulkMove(targetLibraryId: number, targetFolderId?: number): Promise<boolean> {
if (!hasSelection()) {
toast.error('No books selected to move')
return false
}
const total = querySelection?.value ? querySelection.value.total : selectedIds.value.size
inFlight.value = { label: 'Moving books', processed: 0, total, failed: 0 }
let moved = 0
let skipped = 0
let failed = 0
let summary: MoveBooksSummary | null = null
try {
const res = await api('/api/v1/books/move', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...getSelectionPayload(), targetLibraryId, ...(targetFolderId !== undefined ? { targetFolderId } : {}) }),
})
if (!res.ok) {
toast.error('Failed to move books')
return false
}
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
for (const line of decoder.decode(value, { stream: true }).split('\n')) {
if (!line.startsWith('data: ')) continue
try {
const data = JSON.parse(line.slice(6)) as MoveBooksProgressEvent
if ('done' in data && data.done) {
summary = data
continue
}
const outcome = data as MoveBookOutcome
if (outcome.status === 'moved') {
moved++
onDeleted([outcome.bookId])
} else if (outcome.status === 'skipped') {
skipped++
} else {
failed++
}
const prev: InFlightOp = inFlight.value ?? { label: 'Moving books', processed: 0, total, failed: 0 }
inFlight.value = {
label: 'Moving books',
processed: prev.processed + 1,
total,
failed: (prev.failed ?? 0) + (outcome.status === 'failed' ? 1 : 0),
}
} catch {
// Ignore malformed stream lines; the done event carries the authoritative counts.
}
}
}
} catch {
toast.error('Failed to move books')
return false
} finally {
inFlight.value = null
}
const counts = summary ?? { total, moved, skipped, failed, cancelled: false }
const parts = [counts.skipped > 0 ? `${counts.skipped} skipped` : null, counts.failed > 0 ? `${counts.failed} failed` : null]
.filter(Boolean)
.join(', ')
if (counts.moved === counts.total) {
toast.success(`Moved ${counts.moved} book${counts.moved === 1 ? '' : 's'}`)
return true
}
if (counts.moved === 0) {
toast.error(`No books were moved${parts ? ` (${parts})` : ''}`)
return true
}
toast.warning(`Moved ${counts.moved} of ${counts.total} books${parts ? ` (${parts})` : ''}`)
return true
}
async function handleDeleteSelected() {
if (!hasSelection()) return
const ids = querySelection?.value ? [] : [...selectedIds.value]
@@ -419,6 +499,7 @@ export function useBookBulkActions(
handleBulkSetRating,
handleBulkSetField,
handleBulkSetMetadataLock,
handleBulkMove,
handleDeleteSelected,
getSelectionPayload,
}
@@ -15,11 +15,14 @@ interface BookTableShellOptions {
loading?: Ref<boolean>
exitSelectionMode?: () => void
querySelection?: Ref<QuerySelectionState | null>
// Called after a bulk move so the view can refresh counts/buckets itself,
// independent of the book:transferred websocket round-trip.
onBooksMoved?: () => void | Promise<void>
}
type BookActionType = 'quick-view' | 'add-to-collection' | 'delete'
export function useBookTableShell({ books, querySelection }: BookTableShellOptions) {
export function useBookTableShell({ books, querySelection, onBooksMoved }: BookTableShellOptions) {
const router = useRouter()
const { setBookContext } = useBookNavigation()
const tableControls = useTableViewControls()
@@ -50,9 +53,31 @@ export function useBookTableShell({ books, querySelection }: BookTableShellOptio
const addToCollectionOpen = ref(false)
const bulkEditOpen = ref(false)
const sendBookOpen = ref(false)
const moveBooksOpen = ref(false)
const movingBooks = ref(false)
const quickViewBookId = ref<number | null>(null)
const quickViewOpen = ref(false)
async function confirmMoveBooks(libraryId: number, folderId?: number): Promise<void> {
if (movingBooks.value) return
movingBooks.value = true
let completed = false
try {
completed = await bulk.handleBulkMove(libraryId, folderId)
if (completed && onBooksMoved) {
try {
await onBooksMoved()
} catch {
// The move itself succeeded; a failed view refresh must not surface as a move error.
}
}
} finally {
movingBooks.value = false
// Keep the dialog open on transport errors so the user can retry.
if (completed) moveBooksOpen.value = false
}
}
function handleBookAction(book: BookCard, action: BookActionType): void {
if (action === 'quick-view') {
quickViewBookId.value = book.id
@@ -101,6 +126,9 @@ export function useBookTableShell({ books, querySelection }: BookTableShellOptio
addToCollectionOpen,
bulkEditOpen,
sendBookOpen,
moveBooksOpen,
movingBooks,
confirmMoveBooks,
quickViewBookId,
quickViewOpen,
handleBookAction,
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Odstranit knihu",
"BookBulkMetadataRefresh": "Obnovit metadata knihy",
"BookBulkDelete": "Odstranit knihy",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Přepravní obaly knih",
"BookBulkSetStatus": "Nastavit stav knihy",
"BookBulkSetRating": "Nastavit hodnocení knihy",
@@ -3364,6 +3365,31 @@
"title": "Smazat knihu?",
"description": "Tímto trvale smažete knihu a všechna její metadata, soubory, postup čtení a záložky. Tuto akci nelze vrátit zpět."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} few {Move # books to library} many {Move # books to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Upravte metadata - # kniha} one {Upravte metadata - # kniha} few {Upravte metadata - # knih} many {Upravte metadata - # knih} other {Upravte metadata - # knih}}",
"instructions": "Přepnout pole na editaci a pak nastavit hodnoty. Změníte pouze přepnutá pole.",
@@ -3628,6 +3654,11 @@
"primary": "Primární",
"peek": "Prohlížení",
"sendViaEmail": "Odeslat e-mailem",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Osobní recenze",
"toggleAria": "Přepnout osobní recenzi",
@@ -4514,6 +4545,7 @@
"lockAll": "Zamknout vše",
"unlockAll": "Odemknout vše",
"exportMetadata": "Exportovat metadata",
"moveToLibrary": "Move to library",
"deleteSelected": "Odstranit vybrané",
"exitSelection": "Ukončit výběr",
"downloadFilesZipLabel": "Stáhnout soubory jako ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Slet bog",
"BookBulkMetadataRefresh": "Opdater bogmetadata",
"BookBulkDelete": "Slet bøger",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Bogomslag til genudtræk",
"BookBulkSetStatus": "Sæt bogstatus",
"BookBulkSetRating": "Sæt bog rating",
@@ -3364,6 +3365,31 @@
"title": "Slet bog?",
"description": "Dette vil permanent slette bogen og alle dens metadata, filer, læse fremskridt og bogmærker. Dette kan ikke fortrydes."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Rediger metadata - # bog} one {Rediger metadata - # bog} other {Rediger metadata - # bøger}}",
"instructions": "Skift felter til redigering, og indstil værdier. Kun valgte felter vil blive ændret.",
@@ -3628,6 +3654,11 @@
"primary": "Primær",
"peek": "Peek",
"sendViaEmail": "Send via e-mail",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Personlig Anmeldelse",
"toggleAria": "Skift personlig anmeldelse",
@@ -4514,6 +4545,7 @@
"lockAll": "Lås alle",
"unlockAll": "Lås alle op",
"exportMetadata": "Eksporter metadata",
"moveToLibrary": "Move to library",
"deleteSelected": "Slet valgte",
"exitSelection": "Afslut markering",
"downloadFilesZipLabel": "Download filer som ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Delete book",
"BookBulkMetadataRefresh": "Refresh book metadata",
"BookBulkDelete": "Delete books",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Re-extract book covers",
"BookBulkSetStatus": "Set book status",
"BookBulkSetRating": "Set book rating",
@@ -3364,6 +3365,31 @@
"title": "Buch löschen?",
"description": "Dadurch werden das Buch sowie alle zugehörigen Metadaten, Dateien, Lesefortschritte und Lesezeichen dauerhaft gelöscht. Dies kann nicht rückgängig gemacht werden."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Metadaten bearbeiten - # Buch} one {Metadaten bearbeiten - # Buch} other {Metadaten bearbeiten - # Bücher}}",
"instructions": "Aktivieren Sie Felder zur Bearbeitung und legen Sie dann Werte fest. Nur aktivierte Felder werden geändert.",
@@ -3628,6 +3654,11 @@
"primary": "Primär",
"peek": "Vorschau",
"sendViaEmail": "Per E-Mail senden",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Persönliche Rezension",
"toggleAria": "Persönliche Rezension ein- oder ausblenden",
@@ -4514,6 +4545,7 @@
"lockAll": "Alle sperren",
"unlockAll": "Alle entsperren",
"exportMetadata": "Metadaten exportieren",
"moveToLibrary": "Move to library",
"deleteSelected": "Auswahl löschen",
"exitSelection": "Auswahl beenden",
"downloadFilesZipLabel": "Dateien als ZIP herunterladen:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Delete book",
"BookBulkMetadataRefresh": "Refresh book metadata",
"BookBulkDelete": "Delete books",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Re-extract book covers",
"BookBulkSetStatus": "Set book status",
"BookBulkSetRating": "Set book rating",
@@ -3364,6 +3365,31 @@
"title": "Delete book?",
"description": "This will permanently delete the book and all its metadata, files, reading progress, and bookmarks. This cannot be undone."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Edit metadata - # book} one {Edit metadata - # book} other {Edit metadata - # books}}",
"instructions": "Toggle fields to edit, then configure values. Only toggled fields will be changed.",
@@ -3628,6 +3654,11 @@
"primary": "Primary",
"peek": "Peek",
"sendViaEmail": "Send via Email",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Personal Review",
"toggleAria": "Toggle personal review",
@@ -4514,6 +4545,7 @@
"lockAll": "Lock all",
"unlockAll": "Unlock all",
"exportMetadata": "Export metadata",
"moveToLibrary": "Move to library",
"deleteSelected": "Delete selected",
"exitSelection": "Exit selection",
"downloadFilesZipLabel": "Download files as ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Eliminar libro",
"BookBulkMetadataRefresh": "Actualizar metadatos del libro",
"BookBulkDelete": "Eliminar libros",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Volver a extraer portadas de libros",
"BookBulkSetStatus": "Establecer estado del libro",
"BookBulkSetRating": "Establecer calificación de libro",
@@ -3364,6 +3365,31 @@
"title": "¿Eliminar libro?",
"description": "Esto eliminará permanentemente el libro y todos sus metadatos, archivos, progreso de lectura y marcadores. Esto no se puede deshacer."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} many {Move # books to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Editar metadatos - # libro} one {Editar metadatos - # libro} other {Editar metadatos - # libros} many {Editar metadatos - # libros}}",
"instructions": "Cambie los campos para editar y luego configure los valores. Sólo se cambiarán los campos alternados.",
@@ -3628,6 +3654,11 @@
"primary": "Principal",
"peek": "vistazo",
"sendViaEmail": "Enviar por correo electrónico",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Opinión personal",
"toggleAria": "Alternar revisión personal",
@@ -4514,6 +4545,7 @@
"lockAll": "bloquear todo",
"unlockAll": "Desbloquear todo",
"exportMetadata": "Exportar metadatos",
"moveToLibrary": "Move to library",
"deleteSelected": "Eliminar seleccionado",
"exitSelection": "Salir de la selección",
"downloadFilesZipLabel": "Descargar archivos como ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Poista kirja",
"BookBulkMetadataRefresh": "Päivitä kirjan metatiedot",
"BookBulkDelete": "Poista kirjat",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Uusintauutteen kannet",
"BookBulkSetStatus": "Aseta kirjan tila",
"BookBulkSetRating": "Aseta kirjan arvostelu",
@@ -3364,6 +3365,31 @@
"title": "Poistetaanko kirja?",
"description": "Tämä poistaa pysyvästi kirjan ja kaikki sen metatiedot, tiedostot, lukemisen edistymisen ja kirjanmerkit. Tätä ei voi peruuttaa."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Muokkaa metatietoja - # kirja} one {Muokkaa metatietoja - # kirja} other {Muokkaa metatietoja - # kirjoja}}",
"instructions": "Vaihda muokattavia kenttiä ja aseta arvot. Vain toggled kentät vaihdetaan.",
@@ -3628,6 +3654,11 @@
"primary": "Ensisijainen",
"peek": "Tirkistys",
"sendViaEmail": "Lähetä sähköpostitse",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Henkilökohtainen Arvostelu",
"toggleAria": "Ota henkilökohtainen arvostelu käyttöön",
@@ -4514,6 +4545,7 @@
"lockAll": "Lukitse kaikki",
"unlockAll": "Avaa kaikki",
"exportMetadata": "Vie metatiedot",
"moveToLibrary": "Move to library",
"deleteSelected": "Poista valitut",
"exitSelection": "Poistu valinnasta",
"downloadFilesZipLabel": "Lataa tiedostot ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Supprimer le livre",
"BookBulkMetadataRefresh": "Actualiser les métadonnées du livre",
"BookBulkDelete": "Supprimer des livres",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Réextraire les couvertures de livres",
"BookBulkSetStatus": "Définir le statut du livre",
"BookBulkSetRating": "Définir la note du livre",
@@ -3364,6 +3365,31 @@
"title": "Supprimer le livre ?",
"description": "Cela supprimera définitivement le livre et toutes ses métadonnées, fichiers, progression de la lecture et signets. Cela ne peut pas être annulé."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} many {Move # books to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Modifier les métadonnées - # livre} one {Modifier les métadonnées - # livre} other {Modifier les métadonnées - # livres} many {Modifier les métadonnées - # livres}}",
"instructions": "Basculez les champs à modifier, puis configurez les valeurs. Seuls les champs basculés seront modifiés.",
@@ -3628,6 +3654,11 @@
"primary": "Principal",
"peek": "Coup d'oeil",
"sendViaEmail": "Envoyer par e-mail",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Examen personnel",
"toggleAria": "Activer l'avis personnel",
@@ -4514,6 +4545,7 @@
"lockAll": "Verrouiller tout",
"unlockAll": "Débloquez tout",
"exportMetadata": "Exporter les métadonnées",
"moveToLibrary": "Move to library",
"deleteSelected": "Supprimer la sélection",
"exitSelection": "Quitter la sélection",
"downloadFilesZipLabel": "Téléchargez des fichiers au format ZIP :",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Elimina libro",
"BookBulkMetadataRefresh": "Aggiorna i metadati del libro",
"BookBulkDelete": "Elimina libri",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Riestrarre le copertine dei libri",
"BookBulkSetStatus": "Imposta lo stato del libro",
"BookBulkSetRating": "Imposta la valutazione del libro",
@@ -3364,6 +3365,31 @@
"title": "Eliminare il libro?",
"description": "Ciò eliminerà definitivamente il libro e tutti i suoi metadati, file, progressi di lettura e segnalibri. Questa operazione non può essere annullata."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} many {Move # books to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Modifica metadati - # libro} one {Modifica metadati - # libro} other {Modifica metadati - # libri} many {Modifica metadati - # libri}}",
"instructions": "Attiva/disattiva i campi da modificare, quindi configura i valori. Verranno modificati solo i campi attivati/disattivati.",
@@ -3628,6 +3654,11 @@
"primary": "Principale",
"peek": "Sbircia",
"sendViaEmail": "Invia tramite e-mail",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Revisione personale",
"toggleAria": "Attiva/disattiva la revisione personale",
@@ -4514,6 +4545,7 @@
"lockAll": "Blocca tutto",
"unlockAll": "Sblocca tutto",
"exportMetadata": "Esporta metadati",
"moveToLibrary": "Move to library",
"deleteSelected": "Elimina selezionato",
"exitSelection": "Esci dalla selezione",
"downloadFilesZipLabel": "Scarica file come ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Delete book",
"BookBulkMetadataRefresh": "Refresh book metadata",
"BookBulkDelete": "Delete books",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Re-extract book covers",
"BookBulkSetStatus": "Set book status",
"BookBulkSetRating": "Set book rating",
@@ -3364,6 +3365,31 @@
"title": "Boek verwijderen?",
"description": "Hiermee worden het boek en alle bijbehorende metadata, bestanden, leesvoortgang en bladwijzers permanent verwijderd. Dit kan niet ongedaan worden gemaakt."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Metadata bewerken - # boek} one {Metadata bewerken - # boek} other {Metadata bewerken - # boeken}}",
"instructions": "Schakel velden in om te bewerken en stel vervolgens waarden in. Alleen ingeschakelde velden worden gewijzigd.",
@@ -3628,6 +3654,11 @@
"primary": "Primair",
"peek": "Voorvertoning",
"sendViaEmail": "Verzenden via e-mail",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Persoonlijke recensie",
"toggleAria": "Persoonlijke recensie in- of uitklappen",
@@ -4514,6 +4545,7 @@
"lockAll": "Alles vergrendelen",
"unlockAll": "Alles ontgrendelen",
"exportMetadata": "Metadata exporteren",
"moveToLibrary": "Move to library",
"deleteSelected": "Selectie verwijderen",
"exitSelection": "Selectie afsluiten",
"downloadFilesZipLabel": "Bestanden downloaden als ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Usuń książkę",
"BookBulkMetadataRefresh": "Odśwież metadane książki",
"BookBulkDelete": "Usuń książki",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Wyjmij ponownie okładki książek",
"BookBulkSetStatus": "Ustaw stan książki",
"BookBulkSetRating": "Ustaw ocenę książki",
@@ -3364,6 +3365,31 @@
"title": "Usunąć książkę?",
"description": "Spowoduje to trwałe usunięcie książki wraz ze wszystkimi jej metadanymi, plikami, postępem czytania i zakładkami. Tego nie można cofnąć."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} few {Move # books to library} many {Move # books to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Edytuj metadane - # książka} one {Edytuj metadane - # książka} other {Edytuj metadane - # książki} few {Edytuj metadane - # książki} many {Edytuj metadane - # książek}}",
"instructions": "Przełącz pola do edycji, a następnie skonfiguruj wartości. Zmienione zostaną tylko pola przełączane.",
@@ -3628,6 +3654,11 @@
"primary": "Główny",
"peek": "Zerknij",
"sendViaEmail": "Wyślij e-mailem",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Recenzja osobista",
"toggleAria": "Przełącz recenzję osobistą",
@@ -4514,6 +4545,7 @@
"lockAll": "Zablokuj wszystko",
"unlockAll": "Odblokuj wszystko",
"exportMetadata": "Eksportuj metadane",
"moveToLibrary": "Move to library",
"deleteSelected": "Usuń wybrane",
"exitSelection": "Wyjdź z wyboru",
"downloadFilesZipLabel": "Pobierz pliki w formacie ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Excluir livro",
"BookBulkMetadataRefresh": "Atualizar metadados",
"BookBulkDelete": "Livros excluídos",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Reextrair capas de livros",
"BookBulkSetStatus": "Defina o status do livro",
"BookBulkSetRating": "Definir avaliação do livro",
@@ -3364,6 +3365,31 @@
"title": "Excluir livro?",
"description": "Isso excluirá permanentemente o livro e todos os seus metadados, arquivos, progresso de leitura e marcadores. Isso não pode ser desfeito."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} many {Move # books to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Editar metadados - # livro} one {Editar metadados - # livro} many {Editar metadados - # livros} other {Editar metadados - # livros}}",
"instructions": "Alterne os campos para editar e, em seguida, configure os valores. Apenas os campos ativados serão alterados.",
@@ -3628,6 +3654,11 @@
"primary": "Primário",
"peek": "Espiar",
"sendViaEmail": "Enviar por E-mail",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Revisão Pessoal",
"toggleAria": "Alternar revisão pessoal",
@@ -4514,6 +4545,7 @@
"lockAll": "Bloquear tudo",
"unlockAll": "Desbloquear tudo",
"exportMetadata": "Exportar metadados",
"moveToLibrary": "Move to library",
"deleteSelected": "Excluir selecionados",
"exitSelection": "Sair da seleção",
"downloadFilesZipLabel": "Baixar arquivos como ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Удалить книгу",
"BookBulkMetadataRefresh": "Обновить метаданные книги",
"BookBulkDelete": "Удалить книги",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Извлечь обложки книг",
"BookBulkSetStatus": "Установить статус книги",
"BookBulkSetRating": "Установить рейтинг книги",
@@ -3364,6 +3365,31 @@
"title": "Удалить книгу?",
"description": "Это навсегда удалит книгу и все ее метаданные, файлы, прогресс чтения и закладки. Это не может быть отменено."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} few {Move # books to library} many {Move # books to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Редактировать метаданные - # книга} one {Редактировать метаданные - # книга} few {Редактировать метаданные - # книги} many {Редактировать метаданные - # книги} other {Редактировать метаданные - # книги}}",
"instructions": "Включите поля для редактирования, затем настройте значения. Только переключаемые поля будут изменены.",
@@ -3628,6 +3654,11 @@
"primary": "Первичный",
"peek": "Пик",
"sendViaEmail": "Отправить по электронной почте",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Личный отзыв",
"toggleAria": "Переключить персональный обзор",
@@ -4514,6 +4545,7 @@
"lockAll": "Заблокировать все",
"unlockAll": "Разблокировать все",
"exportMetadata": "Экспорт метаданных",
"moveToLibrary": "Move to library",
"deleteSelected": "Удалить выбранные",
"exitSelection": "Выйти из выбора",
"downloadFilesZipLabel": "Скачать файлы как ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Izbriši knjigo",
"BookBulkMetadataRefresh": "Osveži metapodatke knjige",
"BookBulkDelete": "Izbriši knjige",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Ponovno izlušči naslovnice knjig",
"BookBulkSetStatus": "Nastavi stanje knjige",
"BookBulkSetRating": "Nastavi oceno knjige",
@@ -3364,6 +3365,31 @@
"title": "Izbrišem knjigo?",
"description": "S tem boste trajno izbrisali knjigo ter vse njene metapodatke, datoteke, napredek branja in zaznamke. Tega dejanja ni mogoče razveljaviti."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} few {Move # books to library} one {Move # book to library} two {Move # books to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Uredi metapodatke - # knjiga} one {Uredi metapodatke - # knjiga} two {Uredi metapodatke - # knjig} few {Uredi metapodatke - # knjig} other {Uredi metapodatke - # knjig}}",
"instructions": "Preklopite polja za urejanje, nato nastavite vrednosti. Spremenjena bodo samo preklopljena polja.",
@@ -3628,6 +3654,11 @@
"primary": "Primarno",
"peek": "Hitri pogled",
"sendViaEmail": "Pošlji po e-pošti",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Osebna ocena",
"toggleAria": "Preklopi osebno oceno",
@@ -4514,6 +4545,7 @@
"lockAll": "Zakleni vse",
"unlockAll": "Odkleni vse",
"exportMetadata": "Izvozi metapodatke",
"moveToLibrary": "Move to library",
"deleteSelected": "Izbriši izbrano",
"exitSelection": "Zapri izbiranje",
"downloadFilesZipLabel": "Prenesi datoteke kot ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Ta bort bok",
"BookBulkMetadataRefresh": "Uppdatera bokens metadata",
"BookBulkDelete": "Ta bort böcker",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Återextrahera bokomslag",
"BookBulkSetStatus": "Ange bokens status",
"BookBulkSetRating": "Ange bokens betyg",
@@ -3364,6 +3365,31 @@
"title": "Ta bort bok?",
"description": "Detta kommer att permanent ta bort boken och alla dess metadata, filer, läsframsteg och bokmärken. Detta kan inte ångras."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Redigera metadata - # bok} one {Redigera metadata - # bok} other {Redigera metadata - # böcker}}",
"instructions": "Växla fält att redigera och konfigurera värden. Endast växlade fält kommer att ändras.",
@@ -3628,6 +3654,11 @@
"primary": "Primär",
"peek": "Tydlig",
"sendViaEmail": "Skicka via e-post",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Personlig granskning",
"toggleAria": "Växla personlig granskning",
@@ -4514,6 +4545,7 @@
"lockAll": "Lås alla",
"unlockAll": "Lås upp alla",
"exportMetadata": "Exportera metadata",
"moveToLibrary": "Move to library",
"deleteSelected": "Ta bort markerade",
"exitSelection": "Avsluta markering",
"downloadFilesZipLabel": "Ladda ner filer som ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "Видалити книгу",
"BookBulkMetadataRefresh": "Оновити метадані книги",
"BookBulkDelete": "Видалити книги",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "Повторно витягти обкладинки книги",
"BookBulkSetStatus": "Встановити статус книги",
"BookBulkSetRating": "Встановити рейтинг",
@@ -3364,6 +3365,31 @@
"title": "Видалити книгу?",
"description": "Це остаточно видалить книгу та всі метадані, файли читання, мерти та закладки. Цю дію неможливо скасувати."
},
"moveDialog": {
"title": "{count, plural, =0 {Move # book to library} few {Move # books to library} many {Move # books to library} one {Move # book to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {Редагувати метадані - # книга} one {Редагувати метадані - # книга} few {Редагувати метадані - # книг} many {Редагувати метадані - # книг} other {Редагувати метадані - # книг}}",
"instructions": "Перемикання полів для редагування, а потім налаштувати значення. Тільки перемикачі поля будуть змінені.",
@@ -3628,6 +3654,11 @@
"primary": "Первинний",
"peek": "Пік",
"sendViaEmail": "Надіслати через ел. пошту",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "Особистий Відгук",
"toggleAria": "Переключити персональний огляд",
@@ -4514,6 +4545,7 @@
"lockAll": "Заблокувати все",
"unlockAll": "Розблокувати всі",
"exportMetadata": "Експорт метаданих",
"moveToLibrary": "Move to library",
"deleteSelected": "Видалити вибране",
"exitSelection": "Вийти з вибору",
"downloadFilesZipLabel": "Завантажити файли як ZIP:",
+32
View File
@@ -2866,6 +2866,7 @@
"BookDelete": "删除书",
"BookBulkMetadataRefresh": "刷新书籍元数据",
"BookBulkDelete": "删除书",
"BookBulkMove": "Move books",
"BookBulkCoverReextract": "重新提取书封面",
"BookBulkSetStatus": "设置书状状态",
"BookBulkSetRating": "设置书评分数",
@@ -3364,6 +3365,31 @@
"title": "删除书?",
"description": "这将永久删除该书及其所有元数据、文件、阅读进度和书签。无法撤消。"
},
"moveDialog": {
"title": "{count, plural, =0 {Move # books to library} other {Move # books to library}}",
"description": "Files are moved into the target library's folder. Reading progress, annotations, and collections stay with the books.",
"multiLibraryHint": "Books that are already in the selected library are skipped.",
"targetLibrary": "Target library",
"targetFolder": "Target folder",
"selectLibrary": "Select a library…",
"selectFolder": "Select a folder…",
"move": "Move"
},
"moveOutcomes": {
"book_not_found": "Book not found",
"no_source_access": "No access to the source library",
"source_access_check_failed": "The source library access check failed",
"book_processing": "The book is still processing",
"book_missing": "The book's files are missing",
"already_in_target": "The book is already in the target library",
"format_not_allowed": "The book's format is not allowed in the target library",
"target_path_taken": "The target path is already used by another book",
"target_path_exists": "A file already exists at the target path",
"path_escapes_target": "The resolved file path leaves the target folder",
"database_update_failed": "The database update failed",
"file_move_failed": "Moving the files failed",
"file_move_failed_rollback_incomplete": "Moving the files failed and the rollback was incomplete, please verify the book"
},
"bulkEdit": {
"title": "{count, plural, =0 {编辑元数据-# 书} one {编辑元数据-# 书} other {编辑元数据-# 书}}",
"instructions": "切换要编辑的字段,然后配置值。仅切换的字段将被更改。",
@@ -3628,6 +3654,11 @@
"primary": "主要的",
"peek": "佩克",
"sendViaEmail": "通过电子邮件发送",
"moveToLibrary": "Move to Library",
"moveSuccess": "Book moved",
"moveFailed": "Failed to move book",
"moveNotMoved": "Book was not moved",
"moveNotMovedWithReason": "Book was not moved: {reason}",
"personalReview": {
"title": "个人评论",
"toggleAria": "切换个人评论",
@@ -4514,6 +4545,7 @@
"lockAll": "锁定所有",
"unlockAll": "解锁所有",
"exportMetadata": "导出元数据",
"moveToLibrary": "Move to library",
"deleteSelected": "删除选中的",
"exitSelection": "退出选择",
"downloadFilesZipLabel": "以ZIP下载文件:",
+14
View File
@@ -19,6 +19,7 @@ import MetadataExportDialog from '@/features/book/components/MetadataExportDialo
import EditCollectionDialog from '@/features/collection/components/EditCollectionDialog.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import MoveBooksDialog from '@/features/book/components/MoveBooksDialog.vue'
import JumpRail from '@/features/book/components/JumpRail.vue'
import { toast } from 'vue-sonner'
import { useCollections } from '@/features/collection/composables/useCollections'
@@ -208,6 +209,9 @@ const {
handleBulkSetField,
handleBulkSetMetadataLock,
handleDeleteSelected,
moveBooksOpen,
movingBooks,
confirmMoveBooks,
addToCollectionOpen,
bulkEditOpen,
sendBookOpen,
@@ -384,10 +388,20 @@ defineOptions({ name: 'CollectionView' })
@set-rating="handleBulkSetRating"
@set-field="handleBulkSetField"
@lock-metadata="handleBulkSetMetadataLock"
@move="moveBooksOpen = true"
@delete="handleDeleteSelected"
@exit="exitSelectionMode"
/>
<MoveBooksDialog
:open="moveBooksOpen"
:count="selectedCount"
:current-library-id="null"
:moving="movingBooks"
@confirm="confirmMoveBooks"
@cancel="moveBooksOpen = false"
/>
<MetadataExportDialog
:open="metadataExportOpen"
view-type="collection"
+18
View File
@@ -37,6 +37,7 @@ import MetadataExportDialog from '@/features/book/components/MetadataExportDialo
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import SaveAsSmartScopeDialog from '@/features/smart-scope/components/SaveAsSmartScopeDialog.vue'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import MoveBooksDialog from '@/features/book/components/MoveBooksDialog.vue'
import JumpRail from '@/features/book/components/JumpRail.vue'
import type { BookCard } from '@bookorbit/types'
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
@@ -410,6 +411,9 @@ const {
handleBulkSetMetadataLock,
handleDeleteSelected,
getSelectionPayload,
moveBooksOpen,
movingBooks,
confirmMoveBooks,
addToCollectionOpen,
bulkEditOpen,
sendBookOpen,
@@ -421,6 +425,10 @@ const {
} = useBookTableShell({
books,
querySelection,
onBooksMoved: () => {
resetBooks()
refreshBuckets()
},
})
const { onBookMissing, onBookRestored, onBookMoved, onBookTransferred } = useBookEvents()
@@ -1033,10 +1041,20 @@ defineOptions({ name: 'HomeView' })
@set-rating="handleBulkSetRating"
@set-field="handleBulkSetField"
@lock-metadata="handleBulkSetMetadataLock"
@move="moveBooksOpen = true"
@delete="handleDeleteSelected"
@exit="exitSelectionMode"
/>
<MoveBooksDialog
:open="moveBooksOpen"
:count="querySelection ? querySelection.total : selectedCount"
:current-library-id="libraryId ?? null"
:moving="movingBooks"
@confirm="confirmMoveBooks"
@cancel="moveBooksOpen = false"
/>
<MetadataExportDialog
:open="metadataExportOpen"
view-type="library"
+18
View File
@@ -36,6 +36,7 @@ import BulkEditMetadataDialog from '@/features/book/components/BulkEditMetadataD
import MetadataExportDialog from '@/features/book/components/MetadataExportDialog.vue'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue'
import MoveBooksDialog from '@/features/book/components/MoveBooksDialog.vue'
import JumpRail from '@/features/book/components/JumpRail.vue'
import { toast } from 'vue-sonner'
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
@@ -235,6 +236,9 @@ const {
handleBulkSetField,
handleBulkSetMetadataLock,
handleDeleteSelected,
moveBooksOpen,
movingBooks,
confirmMoveBooks,
addToCollectionOpen,
bulkEditOpen,
sendBookOpen,
@@ -245,6 +249,10 @@ const {
handleEditIndividually,
} = useBookTableShell({
books,
onBooksMoved: () => {
resetBooks()
refreshBuckets()
},
})
const metadataExportOpen = ref(false)
@@ -450,10 +458,20 @@ defineOptions({ name: 'SmartScopeView' })
@set-rating="handleBulkSetRating"
@set-field="handleBulkSetField"
@lock-metadata="handleBulkSetMetadataLock"
@move="moveBooksOpen = true"
@delete="handleDeleteSelected"
@exit="exitSelectionMode"
/>
<MoveBooksDialog
:open="moveBooksOpen"
:count="selectedCount"
:current-library-id="null"
:moving="movingBooks"
@confirm="confirmMoveBooks"
@cancel="moveBooksOpen = false"
/>
<MetadataExportDialog
:open="metadataExportOpen"
view-type="smartScope"
+1
View File
@@ -55,6 +55,7 @@ export enum AuditAction {
BookBulkUpdateTags = "book.bulk.update_tags",
BookBulkSetMetadataLock = "book.bulk.set_metadata_lock",
BookBulkEditMetadata = "book.bulk.edit_metadata",
BookBulkMove = "book.bulk.move",
BookReadingStateReset = "book.reading_state.reset",
BookWriteAndRename = "book.write_and_rename",
+36
View File
@@ -8,3 +8,39 @@ export type BookSelectionQuery = {
};
export type BookSelectionPayload = { bookIds: number[]; query?: never } | { query: BookSelectionQuery; bookIds?: never };
export const MOVE_BOOK_OUTCOME_REASONS = [
"book_not_found",
"no_source_access",
"source_access_check_failed",
"book_processing",
"book_missing",
"already_in_target",
"format_not_allowed",
"target_path_taken",
"target_path_exists",
"path_escapes_target",
"database_update_failed",
"file_move_failed",
"file_move_failed_rollback_incomplete",
] as const;
export type MoveBookOutcomeReason = (typeof MOVE_BOOK_OUTCOME_REASONS)[number];
export interface MoveBookOutcome {
bookId: number;
status: "moved" | "skipped" | "failed";
reason?: MoveBookOutcomeReason;
}
export interface MoveBooksSummary {
total: number;
moved: number;
skipped: number;
failed: number;
cancelled: boolean;
}
export type MoveBooksDoneEvent = { done: true } & MoveBooksSummary;
export type MoveBooksProgressEvent = MoveBookOutcome | MoveBooksDoneEvent;
+22
View File
@@ -250,6 +250,28 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS,
],
},
"book-move": {
id: "book-move",
name: "Book Move",
timeout: 45,
lane: "full",
description: "Moving books between libraries: DB re-parenting, file relocation, and constraint consistency",
vitestTarget: "test/book-move.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/book-move-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/modules/book-move/**",
"server/src/modules/book/**",
"server/src/modules/file-write/**",
"server/src/modules/library/**",
"server/src/modules/scanner/**",
"server/test/book-move.e2e-spec.ts",
"server/test/e2e/metadata-write/**",
...SHARED_TYPES_PATHS,
...SHARED_DB_AND_HELPER_PATHS,
],
},
"jump-buckets-invariant": {
id: "jump-buckets-invariant",
name: "Jump Buckets Invariant",
@@ -0,0 +1,20 @@
import 'reflect-metadata';
vi.mock('../file-write/file-write.module', () => ({ FileWriteModule: class FileWriteModule {} }));
vi.mock('../library/library.module', () => ({ LibraryModule: class LibraryModule {} }));
import { MODULE_METADATA } from '@nestjs/common/constants';
import { BookMoveModule } from './book-move.module';
import { BookMoveRepository } from './book-move.repository';
import { BookMoveService } from './book-move.service';
describe('BookMoveModule', () => {
it('registers expected provider graph and exports the move service', () => {
const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, BookMoveModule) as Array<unknown>;
const exports = Reflect.getMetadata(MODULE_METADATA.EXPORTS, BookMoveModule) as Array<unknown>;
expect(providers).toEqual(expect.arrayContaining([BookMoveService, BookMoveRepository]));
expect(exports).toEqual([BookMoveService]);
});
});
@@ -0,0 +1,15 @@
import { Module, forwardRef } from '@nestjs/common';
import { SelfWriteRegistryModule } from '../../common/self-write-registry.module';
import { FileWriteModule } from '../file-write/file-write.module';
import { LibraryModule } from '../library/library.module';
import { ScannerModule } from '../scanner/scanner.module';
import { BookMoveRepository } from './book-move.repository';
import { BookMoveService } from './book-move.service';
@Module({
imports: [forwardRef(() => LibraryModule), FileWriteModule, ScannerModule, SelfWriteRegistryModule],
providers: [BookMoveService, BookMoveRepository],
exports: [BookMoveService],
})
export class BookMoveModule {}
@@ -0,0 +1,137 @@
import { BookMoveRepository } from './book-move.repository';
describe('BookMoveRepository', () => {
function chain<T>(result: T) {
return {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue(result),
};
}
function orderedChain<T>(result: T) {
return {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue(result),
};
}
it('findBookForMove maps the book row and its files', async () => {
const bookRow = {
id: 5,
libraryId: 1,
libraryFolderId: 2,
libraryFolderPath: '/src-lib',
folderPath: '/src-lib/Frank Herbert/Dune',
status: 'present',
};
const fileRows = [
{ id: 10, absolutePath: '/src-lib/Frank Herbert/Dune/Dune.epub', relPath: 'Frank Herbert/Dune/Dune.epub', format: 'epub', role: 'primary' },
];
const db = {
select: vi
.fn()
.mockReturnValueOnce(chain([bookRow]))
.mockReturnValueOnce(orderedChain(fileRows)),
};
const repo = new BookMoveRepository(db as never);
await expect(repo.findBookForMove(5)).resolves.toEqual({ ...bookRow, files: fileRows });
});
it('findBookForMove returns null for unknown books', async () => {
const db = { select: vi.fn().mockReturnValueOnce(chain([])) };
const repo = new BookMoveRepository(db as never);
await expect(repo.findBookForMove(404)).resolves.toBeNull();
expect(db.select).toHaveBeenCalledTimes(1);
});
it('findLibrary returns the library row or null', async () => {
const db = {
select: vi
.fn()
.mockReturnValueOnce(chain([{ id: 3, allowedFormats: ['epub'] }]))
.mockReturnValueOnce(chain([])),
};
const repo = new BookMoveRepository(db as never);
await expect(repo.findLibrary(3)).resolves.toEqual({ id: 3, allowedFormats: ['epub'] });
await expect(repo.findLibrary(999)).resolves.toBeNull();
});
it('findFolder returns the folder row or null', async () => {
const db = {
select: vi
.fn()
.mockReturnValueOnce(chain([{ id: 9, libraryId: 3, path: '/dst-lib' }]))
.mockReturnValueOnce(chain([])),
};
const repo = new BookMoveRepository(db as never);
await expect(repo.findFolder(9)).resolves.toEqual({ id: 9, libraryId: 3, path: '/dst-lib' });
await expect(repo.findFolder(999)).resolves.toBeNull();
});
it('findFoldersByLibrary lists all folders of a library', async () => {
const folders = [
{ id: 9, libraryId: 3, path: '/dst-lib' },
{ id: 12, libraryId: 3, path: '/dst-lib-2' },
];
const db = { select: vi.fn().mockReturnValueOnce(orderedChain(folders)) };
const repo = new BookMoveRepository(db as never);
await expect(repo.findFoldersByLibrary(3)).resolves.toEqual(folders);
});
it('findExistingPaths maps owning book ids by absolute path and skips the query for empty input', async () => {
const db = {
select: vi.fn().mockReturnValueOnce(chain([{ absolutePath: '/dst-lib/Dune.epub', bookId: 77 }])),
};
const repo = new BookMoveRepository(db as never);
await expect(repo.findExistingPaths(['/dst-lib/Dune.epub', '/dst-lib/other.epub'])).resolves.toEqual(new Map([['/dst-lib/Dune.epub', 77]]));
await expect(repo.findExistingPaths([])).resolves.toEqual(new Map());
expect(db.select).toHaveBeenCalledTimes(1);
});
it('applyMove updates the book row before the files so the folder-consistency FK can cascade', async () => {
const setCalls: unknown[] = [];
const tx = {
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation((values: unknown) => {
setCalls.push(values);
return { where: vi.fn().mockResolvedValue(undefined) };
}),
})),
};
const db = {
transaction: vi.fn().mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx)),
};
const repo = new BookMoveRepository(db as never);
await repo.applyMove(5, { libraryId: 3, libraryFolderId: 9, folderPath: '/dst-lib/Frank Herbert/Dune' }, [
{ id: 10, absolutePath: '/dst-lib/Frank Herbert/Dune/Dune.epub', relPath: 'Frank Herbert/Dune/Dune.epub' },
{ id: 11, absolutePath: '/dst-lib/Frank Herbert/Dune/cover.jpg', relPath: 'Frank Herbert/Dune/cover.jpg' },
]);
expect(db.transaction).toHaveBeenCalledTimes(1);
// The books row must be updated first: 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 old folder violates the FK.
expect(setCalls).toEqual([
{ libraryId: 3, libraryFolderId: 9, folderPath: '/dst-lib/Frank Herbert/Dune', updatedAt: expect.any(Date) },
{ absolutePath: '/dst-lib/Frank Herbert/Dune/Dune.epub', relPath: 'Frank Herbert/Dune/Dune.epub', libraryFolderId: 9 },
{ absolutePath: '/dst-lib/Frank Herbert/Dune/cover.jpg', relPath: 'Frank Herbert/Dune/cover.jpg', libraryFolderId: 9 },
]);
});
});
@@ -0,0 +1,153 @@
import { Inject, Injectable } from '@nestjs/common';
import { asc, eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookFiles, books, libraries, libraryFolders } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
export interface BookMoveFile {
id: number;
absolutePath: string;
relPath: string | null;
format: string | null;
role: string;
}
export interface BookMoveBookData {
id: number;
libraryId: number;
libraryFolderId: number;
libraryFolderPath: string;
folderPath: string;
status: string;
files: BookMoveFile[];
}
export interface BookMoveLibrary {
id: number;
allowedFormats: string[];
}
export interface BookMoveFolder {
id: number;
libraryId: number;
path: string;
}
export interface BookMoveTarget {
libraryId: number;
libraryFolderId: number;
folderPath: string;
}
export interface BookMoveFileUpdate {
id: number;
absolutePath: string;
relPath: string | null;
}
@Injectable()
export class BookMoveRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findBookForMove(bookId: number): Promise<BookMoveBookData | null> {
const [row] = await this.db
.select({
id: books.id,
libraryId: books.libraryId,
libraryFolderId: books.libraryFolderId,
libraryFolderPath: libraryFolders.path,
folderPath: books.folderPath,
status: books.status,
})
.from(books)
.innerJoin(libraryFolders, eq(libraryFolders.id, books.libraryFolderId))
.where(eq(books.id, bookId));
if (!row) return null;
const files = await this.db
.select({
id: bookFiles.id,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
format: bookFiles.format,
role: bookFiles.role,
})
.from(bookFiles)
.where(eq(bookFiles.bookId, bookId))
.orderBy(asc(bookFiles.id));
return { ...row, files };
}
async findLibrary(libraryId: number): Promise<BookMoveLibrary | null> {
const [row] = await this.db
.select({ id: libraries.id, allowedFormats: libraries.allowedFormats })
.from(libraries)
.where(eq(libraries.id, libraryId));
return row ?? null;
}
async findFolder(folderId: number): Promise<BookMoveFolder | null> {
const [row] = await this.db
.select({ id: libraryFolders.id, libraryId: libraryFolders.libraryId, path: libraryFolders.path })
.from(libraryFolders)
.where(eq(libraryFolders.id, folderId));
return row ?? null;
}
async findFoldersByLibrary(libraryId: number): Promise<BookMoveFolder[]> {
return this.db
.select({ id: libraryFolders.id, libraryId: libraryFolders.libraryId, path: libraryFolders.path })
.from(libraryFolders)
.where(eq(libraryFolders.libraryId, libraryId))
.orderBy(asc(libraryFolders.id));
}
async findExistingPaths(absolutePaths: string[]): Promise<Map<string, number>> {
if (absolutePaths.length === 0) return new Map();
const result = new Map<string, number>();
const batchSize = 500;
for (let i = 0; i < absolutePaths.length; i += batchSize) {
const batch = absolutePaths.slice(i, i + batchSize);
const rows = await this.db
.select({ absolutePath: bookFiles.absolutePath, bookId: bookFiles.bookId })
.from(bookFiles)
.where(inArray(bookFiles.absolutePath, batch));
for (const row of rows) {
result.set(row.absolutePath, row.bookId);
}
}
return result;
}
async applyMove(bookId: number, target: BookMoveTarget, files: BookMoveFileUpdate[]): Promise<void> {
await this.db.transaction(async (tx) => {
// books must be updated first: book_files_book_folder_consistency_fk references
// books(id, library_folder_id) with ON UPDATE CASCADE, so the new folder id
// propagates to the files; updating a file first violates the FK.
// updatedAt drives Kobo sync's LastModified; without the bump a moved
// book never reaches paired devices in the target library's sync scope.
await tx
.update(books)
.set({ libraryId: target.libraryId, libraryFolderId: target.libraryFolderId, folderPath: target.folderPath, updatedAt: new Date() })
.where(eq(books.id, bookId));
for (const file of files) {
await tx
.update(bookFiles)
.set({ absolutePath: file.absolutePath, relPath: file.relPath, libraryFolderId: target.libraryFolderId })
.where(eq(bookFiles.id, file.id));
}
});
}
}
@@ -0,0 +1,575 @@
import { ForbiddenException, BadRequestException } from '@nestjs/common';
import type { MockedFunction } from 'vitest';
import { access, constants as fsConstants, copyFile, link, mkdir, readdir, rmdir, unlink } from 'fs/promises';
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
return {
...actual,
access: vi.fn(),
copyFile: vi.fn(),
mkdir: vi.fn(),
readdir: vi.fn(),
link: vi.fn(),
rmdir: vi.fn(),
unlink: vi.fn(),
};
});
import type { RequestUser } from '../../common/types/request-user';
import type { BookMoveBookData, BookMoveFolder, BookMoveLibrary } from './book-move.repository';
import { BookMoveService } from './book-move.service';
const mockAccess = access as MockedFunction<typeof access>;
const mockCopyFile = copyFile as MockedFunction<typeof copyFile>;
const mockMkdir = mkdir as MockedFunction<typeof mkdir>;
const mockReaddir = readdir as MockedFunction<typeof readdir>;
const mockLink = link as MockedFunction<typeof link>;
const mockRmdir = rmdir as MockedFunction<typeof rmdir>;
const mockUnlink = unlink as MockedFunction<typeof unlink>;
type BookOverrides = Partial<Omit<BookMoveBookData, 'files'>> & { files?: BookMoveBookData['files'] };
function makeBook(overrides: BookOverrides = {}): BookMoveBookData {
return {
id: 5,
libraryId: 1,
libraryFolderId: 2,
libraryFolderPath: '/src-lib',
folderPath: '/src-lib/Frank Herbert/Dune',
status: 'present',
files: overrides.files ?? [
{
id: 10,
absolutePath: '/src-lib/Frank Herbert/Dune/Dune.epub',
relPath: 'Frank Herbert/Dune/Dune.epub',
format: 'epub',
role: 'primary',
},
{
id: 11,
absolutePath: '/src-lib/Frank Herbert/Dune/cover.jpg',
relPath: 'Frank Herbert/Dune/cover.jpg',
format: null,
role: 'extra',
},
],
...overrides,
};
}
function makeUser(): RequestUser {
return {
id: 7,
username: 'reader',
name: 'Reader',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
contentFilters: null as unknown as RequestUser['contentFilters'],
} as RequestUser;
}
const TARGET_LIBRARY: BookMoveLibrary = { id: 3, allowedFormats: [] };
const TARGET_FOLDER: BookMoveFolder = { id: 9, libraryId: 3, path: '/dst-lib' };
function makeService() {
const repo = {
findBookForMove: vi.fn().mockResolvedValue(makeBook()),
findLibrary: vi.fn().mockResolvedValue(TARGET_LIBRARY),
findFolder: vi.fn().mockResolvedValue(TARGET_FOLDER),
findFoldersByLibrary: vi.fn().mockResolvedValue([TARGET_FOLDER]),
findExistingPaths: vi.fn().mockResolvedValue(new Map()),
applyMove: vi.fn().mockResolvedValue(undefined),
};
const libraryService = { verifyUserAccess: vi.fn().mockResolvedValue(undefined), verifyUserAccessLevel: vi.fn().mockResolvedValue(undefined) };
const lockService = { withLock: vi.fn((_key: string, fn: () => unknown) => fn()) };
const fileRenameService = { scheduleRename: vi.fn() };
const scanGateway = { emitBookTransferred: vi.fn() };
const scannerService = { cancelBooksUnavailableNotification: vi.fn() };
const selfWriteRegistry = { begin: vi.fn(), end: vi.fn() };
const service = new BookMoveService(
repo as never,
libraryService as never,
lockService as never,
fileRenameService as never,
scanGateway as never,
scannerService as never,
selfWriteRegistry as never,
);
return { service, repo, libraryService, lockService, fileRenameService, scanGateway, scannerService, selfWriteRegistry };
}
beforeEach(() => {
vi.clearAllMocks();
// Default disk state: no target paths exist, source dirs empty after move.
mockAccess.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
mockMkdir.mockResolvedValue(undefined);
mockLink.mockResolvedValue(undefined);
mockReaddir.mockResolvedValue([] as never);
mockRmdir.mockResolvedValue(undefined);
});
describe('BookMoveService', () => {
describe('moveBooks', () => {
it('re-parents the book in the DB and physically moves its files into the target folder', async () => {
const { service, repo } = makeService();
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'moved' }]);
expect(repo.applyMove).toHaveBeenCalledWith(5, { libraryId: 3, libraryFolderId: 9, folderPath: '/dst-lib/Frank Herbert/Dune' }, [
{ id: 10, absolutePath: '/dst-lib/Frank Herbert/Dune/Dune.epub', relPath: 'Frank Herbert/Dune/Dune.epub' },
{ id: 11, absolutePath: '/dst-lib/Frank Herbert/Dune/cover.jpg', relPath: 'Frank Herbert/Dune/cover.jpg' },
]);
expect(mockLink).toHaveBeenCalledWith('/src-lib/Frank Herbert/Dune/Dune.epub', '/dst-lib/Frank Herbert/Dune/Dune.epub');
expect(mockLink).toHaveBeenCalledWith('/src-lib/Frank Herbert/Dune/cover.jpg', '/dst-lib/Frank Herbert/Dune/cover.jpg');
});
it('schedules a pattern rename in the target library after a successful move', async () => {
const { service, fileRenameService } = makeService();
await service.moveBooks([5], 3, 9, makeUser());
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(5, 7);
});
it('requires editor access on the target library and viewer access on the source', async () => {
const { service, libraryService } = makeService();
await service.moveBooks([5], 3, 9, makeUser());
expect(libraryService.verifyUserAccessLevel).toHaveBeenCalledWith(7, 3, 'editor', false);
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(7, 1, false);
});
it('rejects the whole request when the user lacks editor access to the target library', async () => {
const { service, libraryService, repo } = makeService();
libraryService.verifyUserAccessLevel.mockRejectedValueOnce(new ForbiddenException('Insufficient library access level'));
await expect(service.moveBooks([5], 3, 9, makeUser())).rejects.toBeInstanceOf(ForbiddenException);
expect(repo.applyMove).not.toHaveBeenCalled();
});
it('fails a single book (not the batch) when the user has no access to its source library', async () => {
const { service, libraryService } = makeService();
libraryService.verifyUserAccess.mockRejectedValueOnce(new ForbiddenException()); // source library
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'failed', reason: 'no_source_access' }]);
});
it('skips books whose files are missing without touching the database', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValue(makeBook({ status: 'missing' }));
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'skipped', reason: 'book_missing' }]);
expect(repo.applyMove).not.toHaveBeenCalled();
expect(mockLink).not.toHaveBeenCalled();
});
it('fails a book whose resolved target path escapes the target folder', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValue(
makeBook({
files: [
{
id: 10,
absolutePath: '/elsewhere/Dune.epub',
relPath: '../../elsewhere/Dune.epub',
format: 'epub',
role: 'primary',
},
],
}),
);
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'failed', reason: 'path_escapes_target' }]);
expect(repo.applyMove).not.toHaveBeenCalled();
expect(mockLink).not.toHaveBeenCalled();
});
it('never removes the library folder root when cleaning up a flat book', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValue(
makeBook({
folderPath: '/src-lib',
files: [
{
id: 10,
absolutePath: '/src-lib/Dune.epub',
relPath: 'Dune.epub',
format: 'epub',
role: 'primary',
},
],
}),
);
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'moved' }]);
expect(mockRmdir).not.toHaveBeenCalled();
});
it('cleans up nested empty directories up to but excluding the folder root', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValue(makeBook());
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'moved' }]);
expect(mockRmdir).toHaveBeenCalledWith('/src-lib/Frank Herbert/Dune');
expect(mockRmdir).toHaveBeenCalledWith('/src-lib/Frank Herbert');
expect(mockRmdir).not.toHaveBeenCalledWith('/src-lib');
});
it('fails a single book with a distinct reason when the source access check errors unexpectedly', async () => {
const { service, libraryService } = makeService();
libraryService.verifyUserAccess.mockRejectedValueOnce(new Error('connection reset')); // source library, infra error
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'failed', reason: 'source_access_check_failed' }]);
});
it('fails a single book (not the batch) when the database re-parent throws', async () => {
const { service, repo, scanGateway } = makeService();
repo.findBookForMove.mockResolvedValueOnce(makeBook()).mockResolvedValueOnce(makeBook({ id: 6 }));
repo.applyMove.mockRejectedValueOnce(new Error('connection terminated'));
const results = await service.moveBooks([5, 6], 3, 9, makeUser());
expect(results).toEqual([
{ bookId: 5, status: 'failed', reason: 'database_update_failed' },
{ bookId: 6, status: 'moved' },
]);
expect(scanGateway.emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 3, bookIds: [6] });
});
it('rejects when the target folder does not belong to the target library', async () => {
const { service, repo } = makeService();
repo.findFolder.mockResolvedValue({ id: 9, libraryId: 999, path: '/other' });
await expect(service.moveBooks([5], 3, 9, makeUser())).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects when the target library does not exist', async () => {
const { service, repo } = makeService();
repo.findLibrary.mockResolvedValue(null);
await expect(service.moveBooks([5], 3, 9, makeUser())).rejects.toBeInstanceOf(BadRequestException);
});
it('uses the sole folder of the target library when no folder id is given', async () => {
const { service, repo } = makeService();
const results = await service.moveBooks([5], 3, undefined, makeUser());
expect(repo.findFoldersByLibrary).toHaveBeenCalledWith(3);
expect(results).toEqual([{ bookId: 5, status: 'moved' }]);
});
it('rejects when no folder id is given and the target library has several folders', async () => {
const { service, repo } = makeService();
repo.findFoldersByLibrary.mockResolvedValue([TARGET_FOLDER, { id: 12, libraryId: 3, path: '/dst-lib-2' }]);
await expect(service.moveBooks([5], 3, undefined, makeUser())).rejects.toBeInstanceOf(BadRequestException);
});
it('reports a missing book as failed and keeps processing the rest', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValueOnce(null).mockResolvedValueOnce(makeBook());
const results = await service.moveBooks([404, 5], 3, 9, makeUser());
expect(results).toEqual([
{ bookId: 404, status: 'failed', reason: 'book_not_found' },
{ bookId: 5, status: 'moved' },
]);
});
it('skips books that are still processing', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValue(makeBook({ status: 'processing' }));
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'skipped', reason: 'book_processing' }]);
expect(repo.applyMove).not.toHaveBeenCalled();
});
it('skips books already in the target folder', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValue(makeBook({ libraryId: 3, libraryFolderId: 9 }));
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'skipped', reason: 'already_in_target' }]);
expect(repo.applyMove).not.toHaveBeenCalled();
});
it('skips books whose content format is not allowed in the target library', async () => {
const { service, repo } = makeService();
repo.findLibrary.mockResolvedValue({ id: 3, allowedFormats: ['pdf', 'cbz'] });
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'skipped', reason: 'format_not_allowed' }]);
expect(repo.applyMove).not.toHaveBeenCalled();
});
it('skips when a target path is already registered to another book', async () => {
const { service, repo } = makeService();
repo.findExistingPaths.mockResolvedValue(new Map([['/dst-lib/Frank Herbert/Dune/Dune.epub', 77]]));
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'skipped', reason: 'target_path_taken' }]);
expect(repo.applyMove).not.toHaveBeenCalled();
});
it('skips when a target path already exists on disk', async () => {
const { service, repo } = makeService();
mockAccess.mockResolvedValue(undefined); // everything "exists"
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'skipped', reason: 'target_path_exists' }]);
expect(repo.applyMove).not.toHaveBeenCalled();
});
it('falls back to exclusive copy+unlink when hardlinking fails with EXDEV (cross-device move)', async () => {
const { service } = makeService();
mockLink.mockRejectedValue(Object.assign(new Error('cross-device'), { code: 'EXDEV' }));
mockCopyFile.mockResolvedValue(undefined);
mockUnlink.mockResolvedValue(undefined);
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'moved' }]);
expect(mockCopyFile).toHaveBeenCalledWith(
'/src-lib/Frank Herbert/Dune/Dune.epub',
'/dst-lib/Frank Herbert/Dune/Dune.epub',
fsConstants.COPYFILE_EXCL,
);
expect(mockUnlink).toHaveBeenCalledWith('/src-lib/Frank Herbert/Dune/Dune.epub');
});
it('fails the book instead of overwriting when a concurrent move already created the target file', async () => {
const { service } = makeService();
mockLink.mockRejectedValueOnce(Object.assign(new Error('file exists'), { code: 'EEXIST' }));
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'failed', reason: 'file_move_failed' }]);
expect(mockCopyFile).not.toHaveBeenCalled();
});
it('suppresses watcher events for all touched paths during the physical move', async () => {
const { service, selfWriteRegistry } = makeService();
await service.moveBooks([5], 3, 9, makeUser());
expect(selfWriteRegistry.begin).toHaveBeenCalledTimes(1);
const paths = selfWriteRegistry.begin.mock.calls[0][0] as string[];
expect(paths).toEqual(
expect.arrayContaining([
'/src-lib/Frank Herbert/Dune/Dune.epub',
'/dst-lib/Frank Herbert/Dune/Dune.epub',
'/src-lib/Frank Herbert/Dune',
'/src-lib/Frank Herbert',
'/dst-lib/Frank Herbert/Dune',
'/dst-lib/Frank Herbert',
]),
);
expect(paths).not.toContain('/src-lib');
expect(paths).not.toContain('/dst-lib');
expect(selfWriteRegistry.end).toHaveBeenCalledWith(paths);
});
it('emits one progress event per processed book', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValueOnce(makeBook()).mockResolvedValueOnce(makeBook({ id: 6, status: 'missing' }));
const events: unknown[] = [];
await service.moveBooks([5, 6], 3, 9, makeUser(), (event) => events.push(event));
expect(events).toEqual([
{ bookId: 5, status: 'moved' },
{ bookId: 6, status: 'skipped', reason: 'book_missing' },
]);
});
it('stops processing when the cancellation callback reports a closed stream', async () => {
const { service, repo } = makeService();
let calls = 0;
const results = await service.moveBooks([5, 6, 7], 3, 9, makeUser(), undefined, { isCancelled: () => ++calls > 1 });
expect(results).toHaveLength(1);
expect(repo.findBookForMove).toHaveBeenCalledTimes(1);
});
it('stops processing and keeps prior outcomes when the progress callback throws', async () => {
const { service, repo, scanGateway } = makeService();
repo.findBookForMove.mockResolvedValue(makeBook());
const onProgress = vi
.fn()
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => {
throw new Error('stream closed');
});
const results = await service.moveBooks([5, 6, 7], 3, 9, makeUser(), onProgress);
expect(results).toHaveLength(2);
expect(scanGateway.emitBookTransferred).toHaveBeenCalled();
});
it('cancels the source library unavailable notification for moved books', async () => {
const { service, scannerService } = makeService();
await service.moveBooks([5], 3, 9, makeUser());
expect(scannerService.cancelBooksUnavailableNotification).toHaveBeenCalledWith(1, [5]);
});
it('rolls back the DB update and already-moved files when a physical move fails', async () => {
const { service, repo } = makeService();
const book = makeBook();
repo.findBookForMove.mockResolvedValue(book);
mockLink
.mockResolvedValueOnce(undefined) // first file moves fine
.mockRejectedValueOnce(Object.assign(new Error('disk full'), { code: 'ENOSPC' })) // second file fails
.mockResolvedValueOnce(undefined); // rollback of first file
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'failed', reason: 'file_move_failed' }]);
// Rollback: file 10 moved back.
expect(mockLink).toHaveBeenCalledWith('/dst-lib/Frank Herbert/Dune/Dune.epub', '/src-lib/Frank Herbert/Dune/Dune.epub');
// Rollback: DB restored to source library values.
expect(repo.applyMove).toHaveBeenLastCalledWith(5, { libraryId: 1, libraryFolderId: 2, folderPath: '/src-lib/Frank Herbert/Dune' }, [
{ id: 10, absolutePath: '/src-lib/Frank Herbert/Dune/Dune.epub', relPath: 'Frank Herbert/Dune/Dune.epub' },
{ id: 11, absolutePath: '/src-lib/Frank Herbert/Dune/cover.jpg', relPath: 'Frank Herbert/Dune/cover.jpg' },
]);
});
it('flags the outcome when the rollback itself fails and the book may be inconsistent', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValue(makeBook());
mockLink
.mockResolvedValueOnce(undefined) // first file moves fine
.mockRejectedValueOnce(Object.assign(new Error('disk full'), { code: 'ENOSPC' })) // second file fails
.mockRejectedValueOnce(new Error('permission denied')); // rollback of first file fails too
const results = await service.moveBooks([5], 3, 9, makeUser());
expect(results).toEqual([{ bookId: 5, status: 'failed', reason: 'file_move_failed_rollback_incomplete' }]);
});
it('computes relPath from absolute paths when a file has no stored relPath', async () => {
const { service, repo } = makeService();
repo.findBookForMove.mockResolvedValue(
makeBook({
files: [
{
id: 10,
absolutePath: '/src-lib/Frank Herbert/Dune/Dune.epub',
relPath: null,
format: 'epub',
role: 'primary',
},
],
}),
);
await service.moveBooks([5], 3, 9, makeUser());
expect(repo.applyMove).toHaveBeenCalledWith(5, { libraryId: 3, libraryFolderId: 9, folderPath: '/dst-lib/Frank Herbert/Dune' }, [
{ id: 10, absolutePath: '/dst-lib/Frank Herbert/Dune/Dune.epub', relPath: 'Frank Herbert/Dune/Dune.epub' },
]);
});
it('emits a book:transferred event per source library so open views refresh', async () => {
const { service, repo, scanGateway } = makeService();
repo.findBookForMove.mockResolvedValueOnce(makeBook()).mockResolvedValueOnce(
makeBook({
id: 6,
libraryId: 2,
libraryFolderId: 4,
libraryFolderPath: '/other-lib',
folderPath: '/other-lib/Solo/Book',
files: [{ id: 20, absolutePath: '/other-lib/Solo/Book/Book.epub', relPath: 'Solo/Book/Book.epub', format: 'epub', role: 'primary' }],
}),
);
await service.moveBooks([5, 6], 3, 9, makeUser());
expect(scanGateway.emitBookTransferred).toHaveBeenCalledTimes(2);
expect(scanGateway.emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 1, toLibraryId: 3, bookIds: [5] });
expect(scanGateway.emitBookTransferred).toHaveBeenCalledWith({ fromLibraryId: 2, toLibraryId: 3, bookIds: [6] });
});
it('does not emit a transfer event when no book was moved', async () => {
const { service, repo, scanGateway } = makeService();
repo.findBookForMove.mockResolvedValue(makeBook({ status: 'processing' }));
await service.moveBooks([5], 3, 9, makeUser());
expect(scanGateway.emitBookTransferred).not.toHaveBeenCalled();
});
it('serializes each book move behind the shared book operation lock', async () => {
const { service, lockService } = makeService();
await service.moveBooks([5], 3, 9, makeUser());
expect(lockService.withLock).toHaveBeenCalledWith('book:5', expect.any(Function));
});
});
describe('validateTarget', () => {
it('passes for an accessible target library and folder', async () => {
const { service, libraryService } = makeService();
await expect(service.validateTarget(3, 9, makeUser())).resolves.toBeUndefined();
expect(libraryService.verifyUserAccessLevel).toHaveBeenCalledWith(7, 3, 'editor', false);
});
it('rejects an unknown target library', async () => {
const { service, repo } = makeService();
repo.findLibrary.mockResolvedValue(null);
await expect(service.validateTarget(99, undefined, makeUser())).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a folder belonging to another library', async () => {
const { service, repo } = makeService();
repo.findFolder.mockResolvedValue({ id: 9, libraryId: 999, path: '/other' });
await expect(service.validateTarget(3, 9, makeUser())).rejects.toBeInstanceOf(BadRequestException);
});
it('propagates missing editor access', async () => {
const { service, libraryService } = makeService();
libraryService.verifyUserAccessLevel.mockRejectedValueOnce(new ForbiddenException('Insufficient library access level'));
await expect(service.validateTarget(3, 9, makeUser())).rejects.toBeInstanceOf(ForbiddenException);
});
});
});
@@ -0,0 +1,362 @@
import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common';
import { access, constants as fsConstants, copyFile, link, mkdir, readdir, rmdir, unlink } from 'fs/promises';
import { dirname, extname, isAbsolute, join, relative } from 'path';
import type { MoveBookOutcome } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import type { RequestUser } from '../../common/types/request-user';
import { FileLockService, bookOperationLockKey } from '../file-write/file-lock.service';
import { FileRenameService } from '../file-write/file-rename.service';
import { LibraryService } from '../library/library.service';
import { ScanGateway } from '../scanner/scan.gateway';
import { ScannerService } from '../scanner/scanner.service';
import { SelfWriteRegistry } from '../../common/services/self-write-registry.service';
import type { BookMoveBookData, BookMoveFileUpdate, BookMoveFolder, BookMoveLibrary } from './book-move.repository';
import { BookMoveRepository } from './book-move.repository';
const BOOK_MOVE_EVENT = 'book.move';
@Injectable()
export class BookMoveService {
private readonly logger = new Logger(BookMoveService.name);
constructor(
private readonly moveRepo: BookMoveRepository,
private readonly libraryService: LibraryService,
private readonly lockService: FileLockService,
private readonly fileRenameService: FileRenameService,
private readonly scanGateway: ScanGateway,
private readonly scannerService: ScannerService,
private readonly selfWriteRegistry: SelfWriteRegistry,
) {}
// Validates the target before the controller opens the SSE stream, so user
// errors surface as regular JSON error responses instead of a broken stream.
async validateTarget(targetLibraryId: number, targetFolderId: number | undefined, user: RequestUser): Promise<void> {
const library = await this.moveRepo.findLibrary(targetLibraryId);
if (!library) throw new BadRequestException(`Library ${targetLibraryId} not found`);
// Moving writes files into the target library, so viewer access is not
// enough; every other write path requires editor level.
await this.libraryService.verifyUserAccessLevel(user.id, targetLibraryId, 'editor', user.isSuperuser);
await this.resolveTargetFolder(targetLibraryId, targetFolderId);
}
async moveBooks(
bookIds: number[],
targetLibraryId: number,
targetFolderId: number | undefined,
user: RequestUser,
onProgress?: (event: MoveBookOutcome) => void,
options?: { isCancelled?: () => boolean },
): Promise<MoveBookOutcome[]> {
const startedAt = Date.now();
const library = await this.moveRepo.findLibrary(targetLibraryId);
if (!library) throw new BadRequestException(`Library ${targetLibraryId} not found`);
await this.libraryService.verifyUserAccessLevel(user.id, targetLibraryId, 'editor', user.isSuperuser);
const folder = await this.resolveTargetFolder(targetLibraryId, targetFolderId);
this.logger.log(
`[${BOOK_MOVE_EVENT}] [start] userId=${user.id} toLibraryId=${library.id} toFolderId=${folder.id} total=${bookIds.length} - bulk move started`,
);
const results: MoveBookOutcome[] = [];
const movedBySourceLibrary = new Map<number, number[]>();
let cancelled = false;
let callbackInterrupted = false;
for (const bookId of bookIds) {
if (options?.isCancelled?.()) {
cancelled = true;
break;
}
const outcome = await this.lockService.withLock(bookOperationLockKey(bookId), () =>
this.moveBook(bookId, library, folder, user, movedBySourceLibrary),
);
results.push(outcome);
if (onProgress) {
try {
onProgress(outcome);
} catch {
// The client went away mid-stream; stop working but keep the
// outcomes so far for events and auditing.
callbackInterrupted = true;
break;
}
}
}
for (const [fromLibraryId, movedIds] of movedBySourceLibrary) {
// A move can race the source library's unavailable-books debounce; the
// scanner's own transfer path cancels the pending notification too.
this.scannerService.cancelBooksUnavailableNotification(fromLibraryId, movedIds);
this.scanGateway.emitBookTransferred({ fromLibraryId, toLibraryId: library.id, bookIds: movedIds });
}
const moved = results.filter((entry) => entry.status === 'moved').length;
const skipped = results.filter((entry) => entry.status === 'skipped').length;
const failed = results.filter((entry) => entry.status === 'failed').length;
this.logger.log(
`[${BOOK_MOVE_EVENT}] [end] userId=${user.id} toLibraryId=${library.id} durationMs=${Date.now() - startedAt} total=${bookIds.length} moved=${moved} skipped=${skipped} failed=${failed} cancelled=${cancelled} callbackInterrupted=${callbackInterrupted} - bulk move completed`,
);
return results;
}
private async resolveTargetFolder(libraryId: number, folderId: number | undefined): Promise<BookMoveFolder> {
if (folderId !== undefined) {
const folder = await this.moveRepo.findFolder(folderId);
if (!folder || folder.libraryId !== libraryId) {
throw new BadRequestException(`Folder ${folderId} does not belong to library ${libraryId}`);
}
return folder;
}
const folders = await this.moveRepo.findFoldersByLibrary(libraryId);
if (folders.length !== 1) {
throw new BadRequestException('targetFolderId is required when the target library does not have exactly one folder');
}
return folders[0];
}
private async moveBook(
bookId: number,
library: BookMoveLibrary,
folder: BookMoveFolder,
user: RequestUser,
movedBySourceLibrary: Map<number, number[]>,
): Promise<MoveBookOutcome> {
const book = await this.moveRepo.findBookForMove(bookId);
if (!book) return { bookId, status: 'failed', reason: 'book_not_found' };
try {
await this.libraryService.verifyUserAccess(user.id, book.libraryId, user.isSuperuser);
} catch (error) {
if (error instanceof ForbiddenException) {
return { bookId, status: 'failed', reason: 'no_source_access' };
}
const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.error(
`[${BOOK_MOVE_EVENT}] bookId=${bookId} userId=${user.id} errorClass=${error instanceof Error ? error.constructor.name : 'unknown'} error="${message}" - source library access check failed`,
);
return { bookId, status: 'failed', reason: 'source_access_check_failed' };
}
if (book.status === 'processing') return { bookId, status: 'skipped', reason: 'book_processing' };
if (book.status === 'missing') return { bookId, status: 'skipped', reason: 'book_missing' };
if (book.libraryId === library.id && book.libraryFolderId === folder.id) {
return { bookId, status: 'skipped', reason: 'already_in_target' };
}
if (library.allowedFormats.length > 0) {
for (const file of book.files) {
if (file.role !== 'primary' && file.role !== 'content') continue;
const format = (file.format ?? extname(file.absolutePath).slice(1)).toLowerCase();
if (!library.allowedFormats.includes(format)) {
this.logger.log(
`[${BOOK_MOVE_EVENT}] bookId=${bookId} toLibraryId=${library.id} format=${sanitizeLogValue(format)} - format not allowed in target library`,
);
return { bookId, status: 'skipped', reason: 'format_not_allowed' };
}
}
}
const fileUpdates: BookMoveFileUpdate[] = book.files.map((file) => {
const relPath = file.relPath ?? relative(book.libraryFolderPath, file.absolutePath);
return { id: file.id, absolutePath: join(folder.path, relPath), relPath };
});
const newFolderPath = join(folder.path, relative(book.libraryFolderPath, book.folderPath));
// A stored relPath (or a file row outside its folder root) can resolve to
// "../..", which would make the move write outside the target library. The
// folder path may equal the folder root itself (flat libraries), file
// paths must be strictly inside it.
const escapes =
fileUpdates.some((update) => !this.isContainedIn(folder.path, update.absolutePath)) || !this.isAtOrContainedIn(folder.path, newFolderPath);
if (escapes) {
this.logger.error(`[${BOOK_MOVE_EVENT}] bookId=${bookId} toFolderId=${folder.id} - resolved target path escapes the target folder`);
return { bookId, status: 'failed', reason: 'path_escapes_target' };
}
const existingPaths = await this.moveRepo.findExistingPaths(fileUpdates.map((update) => update.absolutePath));
for (const update of fileUpdates) {
const owner = existingPaths.get(update.absolutePath);
if (owner !== undefined && owner !== bookId) {
return { bookId, status: 'skipped', reason: 'target_path_taken' };
}
}
for (const update of fileUpdates) {
if (await this.pathExists(update.absolutePath)) {
return { bookId, status: 'skipped', reason: 'target_path_exists' };
}
}
try {
await this.moveRepo.applyMove(bookId, { libraryId: library.id, libraryFolderId: folder.id, folderPath: newFolderPath }, fileUpdates);
} catch (error) {
const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.error(
`[${BOOK_MOVE_EVENT}] bookId=${bookId} errorClass=${error instanceof Error ? error.constructor.name : 'unknown'} error="${message}" - database re-parent failed`,
);
return { bookId, status: 'failed', reason: 'database_update_failed' };
}
const moved: Array<{ from: string; to: string }> = [];
// Suppress watcher events for every touched path: without this the source
// library's watcher sees unlinks and the target's sees adds for a book
// whose row has already been re-parented, racing missing-marking and
// duplicate detection.
const suppressPaths = this.buildSuppressedMovePaths(book, fileUpdates, folder.path, newFolderPath);
this.selfWriteRegistry.begin(suppressPaths);
try {
try {
for (let i = 0; i < book.files.length; i++) {
const from = book.files[i].absolutePath;
const to = fileUpdates[i].absolutePath;
if (from === to) continue;
await mkdir(dirname(to), { recursive: true });
await this.moveFile(from, to);
moved.push({ from, to });
}
} catch (error) {
const rolledBack = await this.rollback(bookId, book, moved, error);
const message = sanitizeLogValue(error instanceof Error ? error.message : String(error));
this.logger.error(
`[${BOOK_MOVE_EVENT}] bookId=${bookId} rolledBack=${rolledBack} errorClass=${error instanceof Error ? error.constructor.name : 'unknown'} error="${message}" - physical file move failed`,
);
return { bookId, status: 'failed', reason: rolledBack ? 'file_move_failed' : 'file_move_failed_rollback_incomplete' };
}
for (const { from } of moved) {
await this.cleanupEmptyDirsUpTo(dirname(from), book.libraryFolderPath);
}
await this.cleanupEmptyDirsUpTo(book.folderPath, book.libraryFolderPath);
} finally {
this.selfWriteRegistry.end(suppressPaths);
}
const movedFromSource = movedBySourceLibrary.get(book.libraryId) ?? [];
movedFromSource.push(bookId);
movedBySourceLibrary.set(book.libraryId, movedFromSource);
this.fileRenameService.scheduleRename(bookId, user.id);
this.logger.log(
`[${BOOK_MOVE_EVENT}] bookId=${bookId} userId=${user.id} fromLibraryId=${book.libraryId} toLibraryId=${library.id} toFolderId=${folder.id} - book moved`,
);
return { bookId, status: 'moved' };
}
private async rollback(bookId: number, book: BookMoveBookData, moved: Array<{ from: string; to: string }>, cause: unknown): Promise<boolean> {
let rollbackOk = true;
for (const { from, to } of [...moved].reverse()) {
try {
await this.moveFile(to, from);
} catch (rollbackError) {
rollbackOk = false;
const causeMessage = sanitizeLogValue(cause instanceof Error ? cause.message : String(cause));
const rollbackMessage = sanitizeLogValue(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
this.logger.error(
`[${BOOK_MOVE_EVENT}] bookId=${bookId} error="${causeMessage}" rollbackError="${rollbackMessage}" - failed to move file back during rollback`,
);
}
}
const fileUpdates: BookMoveFileUpdate[] = book.files.map((file) => ({
id: file.id,
absolutePath: file.absolutePath,
relPath: file.relPath ?? relative(book.libraryFolderPath, file.absolutePath),
}));
try {
await this.moveRepo.applyMove(
bookId,
{ libraryId: book.libraryId, libraryFolderId: book.libraryFolderId, folderPath: book.folderPath },
fileUpdates,
);
} catch (rollbackError) {
rollbackOk = false;
const rollbackMessage = sanitizeLogValue(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
this.logger.error(`[${BOOK_MOVE_EVENT}] bookId=${bookId} rollbackError="${rollbackMessage}" - failed to restore book rows during rollback`);
}
return rollbackOk;
}
private async moveFile(from: string, to: string): Promise<void> {
// rename(2) silently overwrites the destination. link+unlink (or
// COPYFILE_EXCL on filesystems without hardlinks) makes a concurrent move
// that resolved the same target path fail with EEXIST instead of
// clobbering the other book's file.
try {
await link(from, to);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'EXDEV' || code === 'EPERM' || code === 'ENOTSUP' || code === 'EACCES') {
await copyFile(from, to, fsConstants.COPYFILE_EXCL);
} else {
throw err;
}
}
await unlink(from);
}
private buildSuppressedMovePaths(book: BookMoveBookData, fileUpdates: BookMoveFileUpdate[], targetRoot: string, newFolderPath: string): string[] {
const paths = new Set<string>();
const addWithParents = (path: string, root: string) => {
paths.add(path);
let current = dirname(path);
while (this.isContainedIn(root, current)) {
paths.add(current);
current = dirname(current);
}
};
for (let i = 0; i < book.files.length; i++) {
addWithParents(book.files[i].absolutePath, book.libraryFolderPath);
addWithParents(fileUpdates[i].absolutePath, targetRoot);
}
if (this.isContainedIn(book.libraryFolderPath, book.folderPath)) paths.add(book.folderPath);
if (this.isContainedIn(targetRoot, newFolderPath)) paths.add(newFolderPath);
return [...paths];
}
private isContainedIn(root: string, target: string): boolean {
const rel = relative(root, target);
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
}
private isAtOrContainedIn(root: string, target: string): boolean {
return relative(root, target) === '' || this.isContainedIn(root, target);
}
private async pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
// Removes empty directories from startDir upward, but never the library
// folder root itself: for flat libraries books.folderPath IS the folder root,
// and deleting it would break the library until someone recreates it.
private async cleanupEmptyDirsUpTo(startDir: string, stopDir: string): Promise<void> {
let current = startDir;
while (this.isContainedIn(stopDir, current)) {
const removed = await this.tryRemoveEmptyDir(current);
if (!removed) return;
current = dirname(current);
}
}
private async tryRemoveEmptyDir(dirPath: string): Promise<boolean> {
try {
const entries = await readdir(dirPath);
if (entries.length === 0) {
await rmdir(dirPath);
return true;
}
} catch {
// Best effort.
}
return false;
}
}
@@ -166,11 +166,20 @@ function makeController() {
const fileWriteService = {
findWriteLog: vi.fn(),
};
const bookMoveService = {
moveBooks: vi.fn(),
validateTarget: vi.fn().mockResolvedValue(undefined),
};
const auditEvents = {
emit: vi.fn(),
};
return {
controller: new BookController(bookService as never, fileWriteService as never),
controller: new BookController(bookService as never, fileWriteService as never, bookMoveService as never, auditEvents as never),
bookService,
fileWriteService,
bookMoveService,
auditEvents,
};
}
@@ -190,6 +199,71 @@ describe('BookController', () => {
await expect(controller.getCover(7, makeUser(), reply, undefined, undefined)).rejects.toThrow(NotFoundException);
});
it('validates the target before opening the stream and streams per-book move events plus a done summary', async () => {
const { controller, bookService, bookMoveService, auditEvents } = makeController();
const outcomes = [
{ bookId: 1, status: 'moved' },
{ bookId: 2, status: 'skipped', reason: 'target_path_exists' },
];
bookMoveService.moveBooks.mockImplementation(
(_ids: number[], _lib: number, _folder: number | undefined, _user: unknown, onProgress?: (event: unknown) => void) => {
for (const outcome of outcomes) onProgress?.(outcome);
return Promise.resolve(outcomes);
},
);
const { reply, raw } = makeReply();
const user = makeUser();
await controller.moveBooks({ bookIds: [1, 2], targetLibraryId: 3, targetFolderId: 9 } as never, user, reply);
expect(bookService.resolveSelectionToIds).toHaveBeenCalledWith(expect.objectContaining({ bookIds: [1, 2] }), user);
expect(bookMoveService.validateTarget).toHaveBeenCalledWith(3, 9, user);
expect(raw.write).toHaveBeenNthCalledWith(1, `data: ${JSON.stringify(outcomes[0])}\n\n`);
expect(raw.write).toHaveBeenNthCalledWith(2, `data: ${JSON.stringify(outcomes[1])}\n\n`);
expect(raw.write).toHaveBeenNthCalledWith(
3,
`data: ${JSON.stringify({ done: true, total: 2, moved: 1, skipped: 1, failed: 0, cancelled: false })}\n\n`,
);
expect(raw.end).toHaveBeenCalled();
expect(auditEvents.emit).toHaveBeenCalledWith(
'audit.log',
expect.objectContaining({
action: 'book.bulk.move',
description: 'Moved 1 of 2 books to library 3 (1 skipped)',
meta: expect.objectContaining({ targetLibraryId: 3, targetFolderId: 9, moved: 1, skipped: 1, failed: 0, total: 2 }),
}),
);
});
it('does not open the move stream when target validation fails', async () => {
const { controller, bookMoveService } = makeController();
bookMoveService.validateTarget.mockRejectedValue(new BadRequestException('Folder 9 does not belong to library 3'));
const { reply, raw } = makeReply();
await expect(controller.moveBooks({ bookIds: [1], targetLibraryId: 3, targetFolderId: 9 } as never, makeUser(), reply)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(raw.writeHead).not.toHaveBeenCalled();
expect(bookMoveService.moveBooks).not.toHaveBeenCalled();
});
it('audits the partial outcome when the move stream is interrupted', async () => {
const { controller, bookMoveService, auditEvents } = makeController();
bookMoveService.moveBooks.mockResolvedValue([{ bookId: 1, status: 'moved' }]);
const { reply } = makeReply();
await controller.moveBooks({ bookIds: [1, 2], targetLibraryId: 3 } as never, makeUser(), reply);
expect(auditEvents.emit).toHaveBeenCalledWith(
'audit.log',
expect.objectContaining({
description: 'Moved 1 of 2 books to library 3',
meta: expect.objectContaining({ cancelled: true }),
}),
);
});
it('delegates embed-all endpoint to service', async () => {
const { controller, bookService } = makeController();
bookService.embedAll.mockResolvedValue({ queued: 5 });
+65 -1
View File
@@ -29,6 +29,7 @@ import { ForbidPermission } from '../../common/decorators/forbid-permission.deco
import { imageContentTypeFromPath } from '../../common/image-content-type';
import type { RequestUser } from '../../common/types/request-user';
import { FileWriteService } from '../file-write/file-write.service';
import { BookMoveService } from '../book-move/book-move.service';
import { BookService } from './book.service';
import { BookQueryPipe } from './pipes/book-query.pipe';
import { BulkBookIdsDto } from './dto/bulk-book-ids.dto';
@@ -39,6 +40,7 @@ import { BulkUpdateTagsDto } from './dto/bulk-update-tags.dto';
import { BulkSetMetadataLockDto } from './dto/bulk-set-metadata-lock.dto';
import { BulkEditMetadataDto } from './dto/bulk-edit-metadata.dto';
import { DeleteBooksDto } from './dto/delete-books.dto';
import { MoveBooksDto } from './dto/move-books.dto';
import { ExportBooksDto } from './dto/export-books.dto';
import { MetadataExportDto } from './dto/metadata-export.dto';
import { SaveProgressDto } from './dto/save-progress.dto';
@@ -50,7 +52,8 @@ import { SearchBooksDto } from './dto/search-books.dto';
import { UpdateBookFileDto } from './dto/update-book-file.dto';
import { SetStatusDto } from '../user-book-status/dto/set-status.dto';
import { Permission, AuditAction, AuditResource } from '@bookorbit/types';
import type { BookDeletionAuditMeta } from '@bookorbit/types';
import { AUDIT_EVENT, AuditEventsService } from '../audit/audit-events.service';
import type { BookDeletionAuditMeta, MoveBookOutcome, MoveBooksSummary } from '@bookorbit/types';
import type { BookQuery } from '@bookorbit/types';
import { UpdateBookMetadataLocksDto } from '../book-metadata-lock/dto/update-book-metadata-locks.dto';
@@ -95,6 +98,8 @@ export class BookController {
constructor(
private readonly bookService: BookService,
private readonly fileWriteService: FileWriteService,
private readonly bookMoveService: BookMoveService,
private readonly auditEvents: AuditEventsService,
) {}
@Post('embed-all')
@@ -135,6 +140,65 @@ export class BookController {
return this.bookService.deleteBooks(ids, user);
}
// Streams one SSE event per book plus a final done summary; large moves run
// long and need progress. Audit is emitted manually because the interceptor
// never sees a response body on @Res() streaming handlers.
@Post('move')
@RequirePermission(Permission.LibraryDeleteBooks)
async moveBooks(@Body() dto: MoveBooksDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
const ids = await this.bookService.resolveSelectionToIds(dto, user);
// Target validation errors must surface as JSON errors, not as a broken
// event stream, so validate before any headers are written.
await this.bookMoveService.validateTarget(dto.targetLibraryId, dto.targetFolderId, user);
const stream = this.createSseStream(reply);
let results: MoveBookOutcome[] = [];
try {
results = await this.bookMoveService.moveBooks(
ids,
dto.targetLibraryId,
dto.targetFolderId,
user,
(event) => {
stream.send(event);
},
{ isCancelled: stream.isClosed },
);
if (!stream.isClosed()) {
stream.send({ done: true, ...this.buildMoveSummary(ids.length, results) });
}
} finally {
stream.close();
this.emitMoveAudit(dto, user, reply, ids.length, results);
}
}
private buildMoveSummary(total: number, results: MoveBookOutcome[]): MoveBooksSummary {
return {
total,
moved: results.filter((entry) => entry.status === 'moved').length,
skipped: results.filter((entry) => entry.status === 'skipped').length,
failed: results.filter((entry) => entry.status === 'failed').length,
cancelled: results.length < total,
};
}
private emitMoveAudit(dto: MoveBooksDto, user: RequestUser, reply: FastifyReply, total: number, results: MoveBookOutcome[]): void {
const summary = this.buildMoveSummary(total, results);
const parts = [summary.skipped > 0 ? `${summary.skipped} skipped` : null, summary.failed > 0 ? `${summary.failed} failed` : null]
.filter(Boolean)
.join(', ');
this.auditEvents.emit(AUDIT_EVENT, {
userId: user.id,
actorUsername: user.username,
action: AuditAction.BookBulkMove,
resource: AuditResource.Book,
description: `Moved ${summary.moved} of ${summary.total} book${summary.total !== 1 ? 's' : ''} to library ${dto.targetLibraryId}${parts ? ` (${parts})` : ''}`,
ip: reply.request?.ip,
meta: { targetLibraryId: dto.targetLibraryId, targetFolderId: dto.targetFolderId, ...summary, results },
});
}
// Must be before @Get(':id') so NestJS does not treat 'search' as an :id param
@Get('search')
searchBooks(@Query() dto: SearchBooksDto, @CurrentUser() user: RequestUser) {
+4
View File
@@ -2,6 +2,8 @@ import { Module, forwardRef } from '@nestjs/common';
import { AchievementModule } from '../achievement/achievement.module';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { AuditModule } from '../audit/audit.module';
import { BookMoveModule } from '../book-move/book-move.module';
import { EmbeddingModule } from '../embedding/embedding.module';
import { FileWriteModule } from '../file-write/file-write.module';
import { LibraryModule } from '../library/library.module';
@@ -37,6 +39,8 @@ import { ReadingAttemptController } from './reading-attempt.controller';
UserBookNoteModule,
UserBookStatusModule,
AchievementModule,
AuditModule,
BookMoveModule,
],
controllers: [BookController, ReadingAttemptController],
providers: [BookService, BookRepository, BookReadService, BookSortBuilder, BookQueryBuilder, BookAuthorSortKeyBackfillService],
@@ -15,6 +15,7 @@ import { BulkUpdateTagsDto } from './bulk-update-tags.dto';
import { DeleteBooksDto } from './delete-books.dto';
import { ExportBooksDto } from './export-books.dto';
import { MetadataExportDto } from './metadata-export.dto';
import { MoveBooksDto } from './move-books.dto';
import { GetBooksDto } from './get-books.dto';
import { SaveProgressDto } from './save-progress.dto';
import { SearchBooksDto } from './search-books.dto';
@@ -34,6 +35,16 @@ async function strictErrorsFor<T extends object>(cls: new () => T, value: Record
}
describe('Book DTO validation', () => {
it('validates move books payloads', async () => {
expect((await errorsFor(MoveBooksDto, { bookIds: [1, 2], targetLibraryId: 3 })).length).toBe(0);
expect((await errorsFor(MoveBooksDto, { bookIds: [1], targetLibraryId: 3, targetFolderId: 9 })).length).toBe(0);
expect((await errorsFor(MoveBooksDto, { query: { libraryId: 5 }, targetLibraryId: 3 })).length).toBe(0);
expect((await errorsFor(MoveBooksDto, { bookIds: [1] })).length).toBeGreaterThan(0);
expect((await errorsFor(MoveBooksDto, { bookIds: [1], targetLibraryId: 0 })).length).toBeGreaterThan(0);
expect((await errorsFor(MoveBooksDto, { bookIds: [1], targetLibraryId: 3, targetFolderId: 0 })).length).toBeGreaterThan(0);
expect((await errorsFor(MoveBooksDto, { bookIds: [1], targetLibraryId: 'x' })).length).toBeGreaterThan(0);
});
it('validates bulk selection DTOs for explicit ids and query payloads', async () => {
expect((await errorsFor(BulkQuerySelectionDto, { libraryId: 5 })).length).toBe(0);
expect((await errorsFor(BulkQuerySelectionDto, { filter: { type: 'group', join: 'AND', rules: [] } })).length).toBe(0);
@@ -0,0 +1,14 @@
import { IsInt, IsOptional, Min } from 'class-validator';
import { BulkSelectionDto } from './bulk-selection.dto';
export class MoveBooksDto extends BulkSelectionDto {
@IsInt()
@Min(1)
targetLibraryId!: number;
@IsOptional()
@IsInt()
@Min(1)
targetFolderId?: number;
}
@@ -75,6 +75,6 @@ import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
},
FormatWriterRegistry,
],
exports: [FileWriteService, FileWriteRepository, FileRenameService, FileRenameRepository, BulkRenameRepository],
exports: [FileWriteService, FileWriteRepository, FileRenameService, FileRenameRepository, FileLockService, BulkRenameRepository],
})
export class FileWriteModule {}
@@ -176,6 +176,13 @@ export class LibraryRepository {
return row !== undefined;
}
async getUserAccessLevel(userId: number, libraryId: number): Promise<AccessLevel | null> {
const row = await this.db.query.userLibraryAccess.findFirst({
where: and(eq(schema.userLibraryAccess.userId, userId), eq(schema.userLibraryAccess.libraryId, libraryId)),
});
return row?.accessLevel ?? null;
}
getAccess(libraryId: number) {
return this.db.query.userLibraryAccess.findMany({
where: eq(schema.userLibraryAccess.libraryId, libraryId),
@@ -32,6 +32,7 @@ function dirent(name: string, kind: 'file' | 'dir') {
describe('LibraryService', () => {
const libraryRepo = {
hasUserAccess: vi.fn(),
getUserAccessLevel: vi.fn(),
findAll: vi.fn(),
findAllForUser: vi.fn(),
findAllIds: vi.fn(),
@@ -159,6 +160,28 @@ describe('LibraryService', () => {
await expect(service.verifyUserAccess(1, 2, false)).rejects.toThrow('No access to this library');
});
it('verifyUserAccessLevel bypasses lookup for superusers', async () => {
await service.verifyUserAccessLevel(1, 2, 'editor', true);
expect(libraryRepo.getUserAccessLevel).not.toHaveBeenCalled();
});
it('verifyUserAccessLevel throws when the user has no access row at all', async () => {
libraryRepo.getUserAccessLevel.mockResolvedValue(null);
await expect(service.verifyUserAccessLevel(1, 2, 'editor', false)).rejects.toThrow('No access to this library');
});
it('verifyUserAccessLevel throws when the level is below the required one', async () => {
libraryRepo.getUserAccessLevel.mockResolvedValue('viewer');
await expect(service.verifyUserAccessLevel(1, 2, 'editor', false)).rejects.toThrow('Insufficient library access level');
});
it('verifyUserAccessLevel passes for the required level and above', async () => {
libraryRepo.getUserAccessLevel.mockResolvedValue('editor');
await expect(service.verifyUserAccessLevel(1, 2, 'editor', false)).resolves.toBeUndefined();
libraryRepo.getUserAccessLevel.mockResolvedValue('owner');
await expect(service.verifyUserAccessLevel(1, 2, 'editor', false)).resolves.toBeUndefined();
});
it('create applies defaults, inserts folders, and starts an async scan', async () => {
libraryRepo.findByName.mockResolvedValue([]);
libraryRepo.insert.mockResolvedValue([{ id: 5, name: 'Sci-Fi', icon: 'BookOpen' }]);
@@ -30,6 +30,10 @@ import { UpdateLibraryDto } from './dto/update-library.dto';
import { DEFAULT_LIBRARY_COVER_ASPECT_RATIO, DEFAULT_LIBRARY_ORGANIZATION_MODE, LIBRARY_METADATA_PRECEDENCE_DEFAULT } from './library.constants';
import { LibraryRepository } from './library.repository';
// Mirrors the ranking in LibraryAccessGuard, which only covers route-param
// library ids; service-side checks need the same semantics for body-provided ids.
const ACCESS_RANK: Record<AccessLevel, number> = { viewer: 1, editor: 2, owner: 3 };
interface LibraryMetadataWriteStreamOptions {
onProgress?: (event: LibraryFileSyncProgressEvent) => void;
isCancelled?: () => boolean;
@@ -66,6 +70,15 @@ export class LibraryService {
if (!hasAccess) throw new ForbiddenException('No access to this library');
}
async verifyUserAccessLevel(userId: number, libraryId: number, requiredLevel: AccessLevel, isSuperuser: boolean): Promise<void> {
if (isSuperuser) return;
const level = await this.libraryRepo.getUserAccessLevel(userId, libraryId);
if (!level) throw new ForbiddenException('No access to this library');
if (ACCESS_RANK[level] < ACCESS_RANK[requiredLevel]) {
throw new ForbiddenException('Insufficient library access level');
}
}
async findAll(user: RequestUser) {
const librariesForUser = user.isSuperuser
? await this.libraryRepo.findAll()
+188
View File
@@ -0,0 +1,188 @@
import { randomUUID } from 'crypto';
import { access, readFile } from 'fs/promises';
import { join } from 'path';
import { and, eq } from 'drizzle-orm';
import type { MoveBookOutcome } from '@bookorbit/types';
import { bookFiles, books, userBookStatus } from '../src/db/schema';
import {
authHeader,
closeMetadataWriteE2EContext,
createLibraryWithFolder,
createMetadataWriteE2EContext,
createUserAndLogin,
locateBookFileByRelPath,
triggerAndWaitForLibraryScan,
type CreatedLibrary,
type LocatedBookFile,
type MetadataWriteE2EContext,
} from './e2e/metadata-write/metadata-write-harness';
import { createEpubFixture, writeFixtureFile } from './e2e/metadata-write/metadata-write-fixture-builder';
const SCENARIO_TIMEOUT_MS = 120_000;
async function pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
describe('Book move between libraries (e2e)', () => {
let ctx: MetadataWriteE2EContext;
beforeAll(async () => {
ctx = await createMetadataWriteE2EContext();
}, SCENARIO_TIMEOUT_MS);
afterAll(async () => {
await closeMetadataWriteE2EContext(ctx);
});
async function seedBookInLibrary(library: CreatedLibrary, relPath: string): Promise<LocatedBookFile> {
await createEpubFixture(library.folderPath, relPath);
await triggerAndWaitForLibraryScan(ctx, library.libraryId);
return locateBookFileByRelPath(ctx, library.libraryId, relPath);
}
async function moveBooks(payload: Record<string, unknown>) {
return ctx.app.inject({
method: 'POST',
url: '/api/v1/books/move',
headers: authHeader(ctx.adminToken),
payload,
});
}
// The endpoint streams SSE frames: one outcome event per book plus a final
// done summary. Parse the raw body back into structured results.
function parseMoveStream(body: string): { results: MoveBookOutcome[]; summary: Record<string, unknown> | null } {
const results: MoveBookOutcome[] = [];
let summary: Record<string, unknown> | null = null;
for (const line of body.split('\n')) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6)) as Record<string, unknown>;
if (event.done === true) summary = event;
else results.push(event as unknown as MoveBookOutcome);
}
return { results, summary };
}
it(
'moves a book into another library, re-parenting rows, relocating files, and preserving user state',
async () => {
const src = await createLibraryWithFolder(ctx, { name: `move-src-${randomUUID()}` });
const dst = await createLibraryWithFolder(ctx, { name: `move-dst-${randomUUID()}` });
const book = await seedBookInLibrary(src, 'moving/book.epub');
const reader = await createUserAndLogin(ctx);
await ctx.db.insert(userBookStatus).values({ userId: reader.userId, bookId: book.bookId, status: 'reading', source: 'manual' });
const response = await moveBooks({ bookIds: [book.bookId], targetLibraryId: dst.libraryId });
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toBe('text/event-stream');
const { results, summary } = parseMoveStream(response.body);
expect(results).toEqual([{ bookId: book.bookId, status: 'moved' }]);
expect(summary).toMatchObject({ done: true, total: 1, moved: 1, skipped: 0, failed: 0, cancelled: false });
// Book row re-parented; this is the regression guard for
// book_files_book_folder_consistency_fk (books must be updated before files).
const [movedBook] = await ctx.db.select().from(books).where(eq(books.id, book.bookId));
expect(movedBook.libraryId).toBe(dst.libraryId);
expect(movedBook.libraryFolderId).toBe(dst.libraryFolderId);
expect(movedBook.folderPath.startsWith(dst.folderPath)).toBe(true);
const movedFiles = await ctx.db.select().from(bookFiles).where(eq(bookFiles.bookId, book.bookId));
expect(movedFiles.length).toBeGreaterThan(0);
for (const file of movedFiles) {
expect(file.libraryFolderId).toBe(dst.libraryFolderId);
expect(file.absolutePath.startsWith(dst.folderPath)).toBe(true);
await expect(pathExists(file.absolutePath)).resolves.toBe(true);
}
await expect(pathExists(join(src.folderPath, 'moving/book.epub'))).resolves.toBe(false);
// User state stays attached because the book id is preserved.
const [status] = await ctx.db
.select()
.from(userBookStatus)
.where(and(eq(userBookStatus.userId, reader.userId), eq(userBookStatus.bookId, book.bookId)));
expect(status?.status).toBe('reading');
},
SCENARIO_TIMEOUT_MS,
);
it(
'skips the move when the target path already exists on disk and leaves the book untouched',
async () => {
const src = await createLibraryWithFolder(ctx, { name: `move-src-${randomUUID()}` });
const dst = await createLibraryWithFolder(ctx, { name: `move-dst-${randomUUID()}` });
const book = await seedBookInLibrary(src, 'dupe/book.epub');
await writeFixtureFile(dst.folderPath, 'dupe/book.epub', 'already here');
const response = await moveBooks({ bookIds: [book.bookId], targetLibraryId: dst.libraryId });
expect(response.statusCode).toBe(200);
const { results } = parseMoveStream(response.body);
expect(results).toEqual([{ bookId: book.bookId, status: 'skipped', reason: 'target_path_exists' }]);
const [unchanged] = await ctx.db.select().from(books).where(eq(books.id, book.bookId));
expect(unchanged.libraryId).toBe(src.libraryId);
expect(unchanged.libraryFolderId).toBe(src.libraryFolderId);
await expect(pathExists(join(src.folderPath, 'dupe/book.epub'))).resolves.toBe(true);
// The colliding file in the target must not have been overwritten.
await expect(readFile(join(dst.folderPath, 'dupe/book.epub'), 'utf8')).resolves.toBe('already here');
},
SCENARIO_TIMEOUT_MS,
);
it(
'rejects a target folder that belongs to a different library and leaves the book untouched',
async () => {
const src = await createLibraryWithFolder(ctx, { name: `move-src-${randomUUID()}` });
const dst = await createLibraryWithFolder(ctx, { name: `move-dst-${randomUUID()}` });
const book = await seedBookInLibrary(src, 'wrong-folder/book.epub');
const response = await moveBooks({
bookIds: [book.bookId],
targetLibraryId: dst.libraryId,
targetFolderId: src.libraryFolderId,
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ statusCode: 400, message: expect.stringContaining('does not belong to library') });
const [unchanged] = await ctx.db.select().from(books).where(eq(books.id, book.bookId));
expect(unchanged.libraryId).toBe(src.libraryId);
expect(unchanged.libraryFolderId).toBe(src.libraryFolderId);
await expect(pathExists(join(src.folderPath, 'wrong-folder/book.epub'))).resolves.toBe(true);
await expect(pathExists(join(dst.folderPath, 'wrong-folder/book.epub'))).resolves.toBe(false);
},
SCENARIO_TIMEOUT_MS,
);
it(
'skips books whose format is not allowed in the target library and leaves files in place',
async () => {
const src = await createLibraryWithFolder(ctx, { name: `move-src-${randomUUID()}` });
const dst = await createLibraryWithFolder(ctx, { name: `move-dst-${randomUUID()}`, allowedFormats: ['pdf'] });
const book = await seedBookInLibrary(src, 'format/book.epub');
const response = await moveBooks({ bookIds: [book.bookId], targetLibraryId: dst.libraryId });
expect(response.statusCode).toBe(200);
const { results } = parseMoveStream(response.body);
expect(results).toEqual([{ bookId: book.bookId, status: 'skipped', reason: 'format_not_allowed' }]);
const [unchanged] = await ctx.db.select().from(books).where(eq(books.id, book.bookId));
expect(unchanged.libraryId).toBe(src.libraryId);
expect(unchanged.libraryFolderId).toBe(src.libraryFolderId);
await expect(pathExists(join(src.folderPath, 'format/book.epub'))).resolves.toBe(true);
await expect(pathExists(join(dst.folderPath, 'format/book.epub'))).resolves.toBe(false);
},
SCENARIO_TIMEOUT_MS,
);
});