diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue
index 19a7c103d..3e7b91b8c 100644
--- a/client/src/components/AppSidebar.vue
+++ b/client/src/components/AppSidebar.vue
@@ -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())
diff --git a/client/src/components/SelectionActionBar.vue b/client/src/components/SelectionActionBar.vue
index 133bf1732..ea76aa72c 100644
--- a/client/src/components/SelectionActionBar.vue
+++ b/client/src/components/SelectionActionBar.vue
@@ -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(
{{ t('components.selectionActionBar.moreActions') }}
-
+
+
+
+
+
+
+ {{ t('components.selectionActionBar.moveToLibrary') }}
+
diff --git a/client/src/components/__tests__/SelectionActionBar.spec.ts b/client/src/components/__tests__/SelectionActionBar.spec.ts
index a5509aef8..9fb1207c6 100644
--- a/client/src/components/__tests__/SelectionActionBar.spec.ts
+++ b/client/src/components/__tests__/SelectionActionBar.spec.ts
@@ -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')
diff --git a/client/src/features/book/components/MoveBooksDialog.vue b/client/src/features/book/components/MoveBooksDialog.vue
new file mode 100644
index 000000000..2b6804719
--- /dev/null
+++ b/client/src/features/book/components/MoveBooksDialog.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ t('book.moveDialog.title', { count }) }}
+
+ {{ t('book.moveDialog.description') }}
+
+
+
+ {{ t('book.moveDialog.multiLibraryHint') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/client/src/features/book/components/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/__tests__/DetailsTab.spec.ts
index 5ce09e8ab..2d38a6e18 100644
--- a/client/src/features/book/components/__tests__/DetailsTab.spec.ts
+++ b/client/src/features/book/components/__tests__/DetailsTab.spec.ts
@@ -70,6 +70,7 @@ const globalStubs = {
DialogClose: { template: '' },
AddToCollectionSheet: true,
DeleteBookDialog: true,
+ MoveBooksDialog: true,
ResetReadingStateDialog: true,
},
}
diff --git a/client/src/features/book/components/__tests__/MoveBooksDialog.spec.ts b/client/src/features/book/components/__tests__/MoveBooksDialog.spec.ts
new file mode 100644
index 000000000..838700073
--- /dev/null
+++ b/client/src/features/book/components/__tests__/MoveBooksDialog.spec.ts
@@ -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([])
+const fetchLibraries = vi.fn<() => Promise>().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: '
' }
+
+const globalStubs = {
+ stubs: {
+ DialogRoot: DialogRootStub,
+ DialogPortal: { template: '
' },
+ DialogOverlay: { template: '' },
+ DialogContent: { template: '
' },
+ DialogTitle: { template: '
' },
+ DialogDescription: { template: '
' },
+ },
+}
+
+function makeLibrary(overrides: Partial = {}): 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()
+ })
+})
diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue
index e6f46109f..6557af5bf 100644
--- a/client/src/features/book/components/detail/tabs/DetailsTab.vue
+++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue
@@ -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 {
+ 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(
Reset reading state
+
Reset reading state
+
+
+
({
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())
+ const onDeleted = vi.fn<(ids: number[]) => void>()
+ const querySelection = ref({
+ 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())
+ 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()
diff --git a/client/src/features/book/composables/__tests__/useBookTableShell.spec.ts b/client/src/features/book/composables/__tests__/useBookTableShell.spec.ts
index 289654f6a..d51fc45cb 100644
--- a/client/src/features/book/composables/__tests__/useBookTableShell.spec.ts
+++ b/client/src/features/book/composables/__tests__/useBookTableShell.spec.ts
@@ -5,6 +5,8 @@ import type { BookCard } from '@bookorbit/types'
const mockSelectionMode = { value: false }
const mockSelectedIds = { value: new Set() }
+const handleBulkMoveMock = vi.hoisted(() => vi.fn<(libraryId: number, folderId?: number) => Promise>().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 })
diff --git a/client/src/features/book/composables/useBookBulkActions.ts b/client/src/features/book/composables/useBookBulkActions.ts
index b7eb630db..8edc4f6ed 100644
--- a/client/src/features/book/composables/useBookBulkActions.ts
+++ b/client/src/features/book/composables/useBookBulkActions.ts
@@ -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 {
+ 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,
}
diff --git a/client/src/features/book/composables/useBookTableShell.ts b/client/src/features/book/composables/useBookTableShell.ts
index 70affe3b7..279afeb4c 100644
--- a/client/src/features/book/composables/useBookTableShell.ts
+++ b/client/src/features/book/composables/useBookTableShell.ts
@@ -15,11 +15,14 @@ interface BookTableShellOptions {
loading?: Ref
exitSelectionMode?: () => void
querySelection?: Ref
+ // Called after a bulk move so the view can refresh counts/buckets itself,
+ // independent of the book:transferred websocket round-trip.
+ onBooksMoved?: () => void | Promise
}
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(null)
const quickViewOpen = ref(false)
+ async function confirmMoveBooks(libraryId: number, folderId?: number): Promise {
+ 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,
diff --git a/client/src/locales/cs.json b/client/src/locales/cs.json
index c91419e2e..a57adbef3 100644
--- a/client/src/locales/cs.json
+++ b/client/src/locales/cs.json
@@ -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:",
diff --git a/client/src/locales/da.json b/client/src/locales/da.json
index 93198f3fe..55aa3f7d5 100644
--- a/client/src/locales/da.json
+++ b/client/src/locales/da.json
@@ -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:",
diff --git a/client/src/locales/de.json b/client/src/locales/de.json
index f5b536c10..2facdc8b5 100644
--- a/client/src/locales/de.json
+++ b/client/src/locales/de.json
@@ -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:",
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 9cc1ebb8c..4ca9f3323 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -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:",
diff --git a/client/src/locales/es.json b/client/src/locales/es.json
index 550845bd7..103cfd4e1 100644
--- a/client/src/locales/es.json
+++ b/client/src/locales/es.json
@@ -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:",
diff --git a/client/src/locales/fi.json b/client/src/locales/fi.json
index 2d9b46129..2ff2ec93a 100644
--- a/client/src/locales/fi.json
+++ b/client/src/locales/fi.json
@@ -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:",
diff --git a/client/src/locales/fr.json b/client/src/locales/fr.json
index 5280a2ba1..8c5e1e860 100644
--- a/client/src/locales/fr.json
+++ b/client/src/locales/fr.json
@@ -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 :",
diff --git a/client/src/locales/it.json b/client/src/locales/it.json
index e2db13910..de0154709 100644
--- a/client/src/locales/it.json
+++ b/client/src/locales/it.json
@@ -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:",
diff --git a/client/src/locales/nl.json b/client/src/locales/nl.json
index 76fdfb582..04c43adea 100644
--- a/client/src/locales/nl.json
+++ b/client/src/locales/nl.json
@@ -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:",
diff --git a/client/src/locales/pl.json b/client/src/locales/pl.json
index d22420548..b745c624c 100644
--- a/client/src/locales/pl.json
+++ b/client/src/locales/pl.json
@@ -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:",
diff --git a/client/src/locales/pt.json b/client/src/locales/pt.json
index d7adf1f29..8818a9201 100644
--- a/client/src/locales/pt.json
+++ b/client/src/locales/pt.json
@@ -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:",
diff --git a/client/src/locales/ru.json b/client/src/locales/ru.json
index 8febe71cb..fca30ffc8 100644
--- a/client/src/locales/ru.json
+++ b/client/src/locales/ru.json
@@ -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:",
diff --git a/client/src/locales/sl.json b/client/src/locales/sl.json
index c24bf8b4b..a79247945 100644
--- a/client/src/locales/sl.json
+++ b/client/src/locales/sl.json
@@ -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:",
diff --git a/client/src/locales/sv.json b/client/src/locales/sv.json
index 5bd685f31..3be472da2 100644
--- a/client/src/locales/sv.json
+++ b/client/src/locales/sv.json
@@ -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:",
diff --git a/client/src/locales/uk.json b/client/src/locales/uk.json
index f7b0e10b0..bee224ef5 100644
--- a/client/src/locales/uk.json
+++ b/client/src/locales/uk.json
@@ -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:",
diff --git a/client/src/locales/zh.json b/client/src/locales/zh.json
index 0adb6aa4e..13a023003 100644
--- a/client/src/locales/zh.json
+++ b/client/src/locales/zh.json
@@ -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下载文件:",
diff --git a/client/src/views/CollectionView.vue b/client/src/views/CollectionView.vue
index 36d3eb39b..f7204659b 100644
--- a/client/src/views/CollectionView.vue
+++ b/client/src/views/CollectionView.vue
@@ -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"
/>
+
+
{
+ 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"
/>
+
+
{
+ 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"
/>
+
+
({ 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;
+ const exports = Reflect.getMetadata(MODULE_METADATA.EXPORTS, BookMoveModule) as Array;
+
+ expect(providers).toEqual(expect.arrayContaining([BookMoveService, BookMoveRepository]));
+ expect(exports).toEqual([BookMoveService]);
+ });
+});
diff --git a/server/src/modules/book-move/book-move.module.ts b/server/src/modules/book-move/book-move.module.ts
new file mode 100644
index 000000000..736fd751c
--- /dev/null
+++ b/server/src/modules/book-move/book-move.module.ts
@@ -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 {}
diff --git a/server/src/modules/book-move/book-move.repository.test.ts b/server/src/modules/book-move/book-move.repository.test.ts
new file mode 100644
index 000000000..c449c49b2
--- /dev/null
+++ b/server/src/modules/book-move/book-move.repository.test.ts
@@ -0,0 +1,137 @@
+import { BookMoveRepository } from './book-move.repository';
+
+describe('BookMoveRepository', () => {
+ function chain(result: T) {
+ return {
+ from: vi.fn().mockReturnThis(),
+ innerJoin: vi.fn().mockReturnThis(),
+ where: vi.fn().mockResolvedValue(result),
+ };
+ }
+
+ function orderedChain(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) => 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 },
+ ]);
+ });
+});
diff --git a/server/src/modules/book-move/book-move.repository.ts b/server/src/modules/book-move/book-move.repository.ts
new file mode 100644
index 000000000..1016dbc1e
--- /dev/null
+++ b/server/src/modules/book-move/book-move.repository.ts
@@ -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;
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ 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