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 @@ + + + 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 + +