From 2102d0f9d8d1919805cccef7f126b2c3de3fd6db Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 11 Mar 2026 03:33:31 -0600 Subject: [PATCH] feat(scanner): improve staging metadata and destination finalize flow --- client/src/components/AppHeader.vue | 27 ++- client/src/components/AppSidebar.vue | 12 +- .../library/components/BookUploadModal.vue | 26 ++- .../__tests__/useLibraryUploadEvents.spec.ts | 44 +++++ .../composables/useLibraryUploadEvents.ts | 24 +++ .../components/StagingBulkEditDialog.vue | 40 ++++- .../staging/components/StagingFileList.vue | 162 +++++++++++++++-- .../staging/components/StagingFileSheet.vue | 142 +++++++++------ .../components/StagingFinalizeDialog.vue | 92 ++++++++-- .../StagingSetDestinationDialog.vue | 163 ++++++++++++++++++ .../staging/components/StagingToolbar.vue | 14 +- .../staging/composables/useStagingDetail.ts | 9 +- .../staging/composables/useStagingFiles.ts | 17 +- .../staging/composables/useStagingFinalize.ts | 7 +- .../staging/composables/useStagingSummary.ts | 47 ++--- client/src/views/HomeView.vue | 11 ++ client/src/views/StagingView.vue | 136 +++++++++++++-- packages/types/src/staging.ts | 8 +- .../modules/metadata/metadata.service.spec.ts | 6 +- .../src/modules/metadata/metadata.service.ts | 8 +- server/src/modules/staging/dto/index.ts | 120 ++++++++++++- .../staging/staging-finalize.service.ts | 152 +++++++++++++--- .../src/modules/staging/staging.controller.ts | 32 +++- .../src/modules/staging/staging.repository.ts | 25 ++- server/src/modules/staging/staging.service.ts | 121 ++++++++++++- 25 files changed, 1250 insertions(+), 195 deletions(-) create mode 100644 client/src/features/library/composables/__tests__/useLibraryUploadEvents.spec.ts create mode 100644 client/src/features/library/composables/useLibraryUploadEvents.ts create mode 100644 client/src/features/staging/components/StagingSetDestinationDialog.vue diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index cb7a11fb7..77848bff9 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -2,6 +2,7 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { ArrowLeft, Search, Palette, Upload, X, KeyRound, Settings, LogOut } from 'lucide-vue-next' import { useRouter } from 'vue-router' +import { toast } from 'vue-sonner' import { SidebarTrigger } from '@/components/ui/sidebar' import { Separator } from '@/components/ui/separator' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' @@ -25,11 +26,13 @@ import { useAuth } from '@/features/auth/composables/useAuth' import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog' import { usePermissions } from '@/features/auth/composables/usePermissions' import BookUploadModal from '@/features/library/components/BookUploadModal.vue' +import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents' const router = useRouter() const { user, logout } = useAuth() const { open: openChangePassword } = useChangePasswordDialog() const { hasPermission } = usePermissions() +const { onLibraryUploadCompleted } = useLibraryUploadEvents() const uploadOpen = ref(false) @@ -113,7 +116,29 @@ function handleGlobalKeydown(e: KeyboardEvent) { } onMounted(() => window.addEventListener('keydown', handleGlobalKeydown)) -onUnmounted(() => window.removeEventListener('keydown', handleGlobalKeydown)) + +const stopUploadCompletedListener = onLibraryUploadCompleted((event) => { + if (event.uploadedCount === 0 && event.failedCount === 0) return + + const uploadedLabel = `${event.uploadedCount} book${event.uploadedCount === 1 ? '' : 's'}` + const failedLabel = `${event.failedCount} file${event.failedCount === 1 ? '' : 's'}` + + if (event.failedCount === 0) { + toast.success(`Uploaded ${uploadedLabel}`) + return + } + if (event.uploadedCount === 0) { + toast.error(`Upload failed for ${failedLabel}`) + return + } + + toast.warning(`Uploaded ${uploadedLabel}, ${failedLabel} failed`) +}) + +onUnmounted(() => { + window.removeEventListener('keydown', handleGlobalKeydown) + stopUploadCompletedListener() +}) function highlightSegments(text: string | null, query: string) { if (!text || !query.trim()) return [{ text: text ?? '', match: false }] diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue index dabb6840b..5c26f10c6 100644 --- a/client/src/components/AppSidebar.vue +++ b/client/src/components/AppSidebar.vue @@ -1,5 +1,5 @@ - diff --git a/client/src/features/staging/components/StagingFileList.vue b/client/src/features/staging/components/StagingFileList.vue index 3da1e0a2c..e73d752be 100644 --- a/client/src/features/staging/components/StagingFileList.vue +++ b/client/src/features/staging/components/StagingFileList.vue @@ -1,19 +1,24 @@ diff --git a/client/src/features/staging/components/StagingFileSheet.vue b/client/src/features/staging/components/StagingFileSheet.vue index 21b73eade..65147d3d3 100644 --- a/client/src/features/staging/components/StagingFileSheet.vue +++ b/client/src/features/staging/components/StagingFileSheet.vue @@ -50,6 +50,7 @@ const form = reactive({ seriesIndex: '', genres: '', }) +const selectedCoverUrl = ref('') watch( () => props.file.id, @@ -67,6 +68,7 @@ watch( form.seriesName = m.seriesName ?? '' form.seriesIndex = m.seriesIndex != null ? String(m.seriesIndex) : '' form.genres = m.genres?.join(', ') ?? '' + selectedCoverUrl.value = m.coverUrl ?? '' metaView.value = 'editor' targetLibraryId.value = props.file.targetLibraryId ?? libraries.value[0]?.id ?? null @@ -96,49 +98,57 @@ onUnmounted(() => { if (debounceTimer) clearTimeout(debounceTimer) }) +function buildMetadataPatchFromForm(): Partial { + return { + title: form.title || undefined, + subtitle: form.subtitle || undefined, + authors: form.authors + ? form.authors + .split(',') + .map((a) => a.trim()) + .filter(Boolean) + : undefined, + description: form.description || undefined, + publisher: form.publisher || undefined, + publishedYear: form.publishedYear ? ((n) => (isNaN(n) ? undefined : n))(parseInt(form.publishedYear, 10)) : undefined, + language: form.language || undefined, + isbn13: form.isbn13 || undefined, + isbn10: form.isbn10 || undefined, + seriesName: form.seriesName || undefined, + seriesIndex: form.seriesIndex ? ((n) => (isNaN(n) ? undefined : n))(parseFloat(form.seriesIndex)) : undefined, + genres: form.genres + ? form.genres + .split(',') + .map((g) => g.trim()) + .filter(Boolean) + : undefined, + coverUrl: selectedCoverUrl.value || undefined, + } +} + function onFieldChange() { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(async () => { - const patch: Partial = { - title: form.title || undefined, - subtitle: form.subtitle || undefined, - authors: form.authors - ? form.authors - .split(',') - .map((a) => a.trim()) - .filter(Boolean) - : undefined, - description: form.description || undefined, - publisher: form.publisher || undefined, - publishedYear: form.publishedYear ? ((n) => (isNaN(n) ? undefined : n))(parseInt(form.publishedYear, 10)) : undefined, - language: form.language || undefined, - isbn13: form.isbn13 || undefined, - isbn10: form.isbn10 || undefined, - seriesName: form.seriesName || undefined, - seriesIndex: form.seriesIndex ? ((n) => (isNaN(n) ? undefined : n))(parseFloat(form.seriesIndex)) : undefined, - genres: form.genres - ? form.genres - .split(',') - .map((g) => g.trim()) - .filter(Boolean) - : undefined, - } - const updated = await saveMetadata(props.file.id, patch) + const updated = await saveMetadata(props.file.id, buildMetadataPatchFromForm()) if (updated) emit('updated', updated) }, 1000) } -function onLibraryChange(event: Event) { - const id = Number((event.target as HTMLSelectElement).value) +async function onLibraryChange(event: Event) { + const raw = Number((event.target as HTMLSelectElement).value) + const id = Number.isFinite(raw) && raw > 0 ? raw : null targetLibraryId.value = id - const lib = libraries.value.find((l) => l.id === id) + const lib = libraries.value.find((l) => l.id === targetLibraryId.value) targetFolderId.value = lib?.folders?.[0]?.id ?? null - setTarget(props.file.id, targetLibraryId.value, targetFolderId.value) + const updated = await setTarget(props.file.id, targetLibraryId.value, targetFolderId.value) + if (updated) emit('updated', updated) } -function onFolderChange(event: Event) { - targetFolderId.value = Number((event.target as HTMLSelectElement).value) - setTarget(props.file.id, targetLibraryId.value, targetFolderId.value) +async function onFolderChange(event: Event) { + const raw = Number((event.target as HTMLSelectElement).value) + targetFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null + const updated = await setTarget(props.file.id, targetLibraryId.value, targetFolderId.value) + if (updated) emit('updated', updated) } function formatDate(iso: string): string { @@ -221,25 +231,30 @@ function backFromDiff() { async function handleApply(patch: { formPatch: MetadataPatch; coverUrl?: string }) { const p = patch.formPatch - if (p.title != null) form.title = p.title - if (p.subtitle != null) form.subtitle = p.subtitle - if (p.description != null) form.description = p.description - if (p.publisher != null) form.publisher = p.publisher - if (p.publishedYear != null) form.publishedYear = String(p.publishedYear) - if (p.language != null) form.language = p.language - if (p.isbn13 != null) form.isbn13 = p.isbn13 - if (p.isbn10 != null) form.isbn10 = p.isbn10 - if (p.seriesName != null) form.seriesName = p.seriesName - if (p.seriesIndex != null) form.seriesIndex = String(p.seriesIndex) - if (p.authors) form.authors = p.authors.join(', ') - if (p.genres) form.genres = p.genres.join(', ') + if ('title' in p) form.title = p.title ?? '' + if ('subtitle' in p) form.subtitle = p.subtitle ?? '' + if ('description' in p) form.description = p.description ?? '' + if ('publisher' in p) form.publisher = p.publisher ?? '' + if ('publishedYear' in p) form.publishedYear = p.publishedYear == null ? '' : String(p.publishedYear) + if ('language' in p) form.language = p.language ?? '' + if ('isbn13' in p) form.isbn13 = p.isbn13 ?? '' + if ('isbn10' in p) form.isbn10 = p.isbn10 ?? '' + if ('seriesName' in p) form.seriesName = p.seriesName ?? '' + if ('seriesIndex' in p) form.seriesIndex = p.seriesIndex == null ? '' : String(p.seriesIndex) + if ('authors' in p) form.authors = (p.authors ?? []).join(', ') + if ('genres' in p) form.genres = (p.genres ?? []).join(', ') - if (patch.coverUrl) { - const updated = await saveMetadata(props.file.id, { coverUrl: patch.coverUrl }) - if (updated) emit('updated', updated) + if (patch.coverUrl !== undefined) { + selectedCoverUrl.value = patch.coverUrl } - onFieldChange() + if (debounceTimer) { + clearTimeout(debounceTimer) + debounceTimer = null + } + const updated = await saveMetadata(props.file.id, buildMetadataPatchFromForm()) + if (updated) emit('updated', updated) + metaView.value = 'editor' selectedCandidate.value = null } @@ -250,7 +265,28 @@ const hasFetchedMetadata = computed(() => { return Object.values(f).some((v) => v !== undefined && v !== null && v !== '') }) -const currentStagingCoverUrl = computed(() => `${coverUrl(props.file.id)}?v=${new Date(props.file.updatedAt).getTime()}`) +const backendStagingCoverUrl = computed(() => `${coverUrl(props.file.id)}?v=${new Date(props.file.updatedAt).getTime()}`) +const normalizedSelectedCoverUrl = computed(() => selectedCoverUrl.value.trim()) +const currentStagingCoverUrl = computed(() => normalizedSelectedCoverUrl.value || backendStagingCoverUrl.value) +const currentStagingCoverFallbackUrl = computed(() => (normalizedSelectedCoverUrl.value ? backendStagingCoverUrl.value : null)) + +function onCurrentStagingCoverError(event: Event) { + const img = event.target as HTMLImageElement + const fallback = currentStagingCoverFallbackUrl.value + if (!fallback) { + img.style.display = 'none' + return + } + + const resolvedFallback = new URL(fallback, window.location.origin).href + const currentSrc = img.currentSrc || img.src + if (currentSrc !== resolvedFallback) { + img.src = fallback + return + } + + img.style.display = 'none' +} function openFetchedDiff() { const f = props.file.fetchedMetadata @@ -306,7 +342,8 @@ onMounted(() => { :src="currentStagingCoverUrl" alt="" class="size-full object-cover" - @error="($event.target as HTMLImageElement).style.display = 'none'" + @load="($event.target as HTMLImageElement).style.display = ''" + @error="onCurrentStagingCoverError" /> @@ -487,7 +524,10 @@ onMounted(() => {
-
+
+

+ {{ + requiresDefaultDestination + ? `${selectionSummary.withoutDestination} of ${selectionSummary.total} selected file${selectionSummary.total === 1 ? '' : 's'} need a destination` + : 'All selected files already have destination set' + }} +

+
+ +