feat(scanner): improve staging metadata and destination finalize flow

This commit is contained in:
neon
2026-03-11 03:33:31 -06:00
parent 9d25e219ab
commit 2102d0f9d8
25 changed files with 1250 additions and 195 deletions
+26 -1
View File
@@ -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 }]
+11 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch, type Component } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import * as Icons from 'lucide-vue-next'
import { Aperture, FolderOpen, LayoutDashboard, PackageOpen, Users } from 'lucide-vue-next'
@@ -22,6 +22,7 @@ import { useCollections } from '@/features/collection/composables/useCollections
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress'
import { useStagingSummary } from '@/features/staging/composables/useStagingSummary'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import type { Library } from '@projectx/types'
import CreateLensDialog from '@/features/lens/components/CreateLensDialog.vue'
import CreateCollectionDialog from '@/features/collection/components/CreateCollectionDialog.vue'
@@ -49,6 +50,7 @@ const { collections, fetchCollections } = useCollections()
const { hasPermission } = usePermissions()
const { subscribeLibrary, getProgress, progressMap } = useScanProgress()
const { summary: stagingSummary, fetchSummary: fetchStagingSummary, subscribe: subscribeStagingSummary } = useStagingSummary()
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const createLensOpen = ref(false)
const createCollectionOpen = ref(false)
@@ -114,6 +116,14 @@ onMounted(async () => {
subscribeStagingSummary()
}
})
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount > 0) {
refreshLibraries()
}
})
onUnmounted(() => stopUploadCompletedListener())
</script>
<template>
@@ -4,6 +4,7 @@ import { CheckCircle2, FileUp, Loader2, Upload, X, XCircle } from 'lucide-vue-ne
import { api } from '@/lib/api'
import type { Library } from '@projectx/types'
import { SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT, useBookUpload } from '../composables/useBookUpload'
import { emitLibraryUploadCompleted } from '../composables/useLibraryUploadEvents'
import { useLibraries } from '../composables/useLibraries'
const props = defineProps<{
@@ -92,8 +93,29 @@ function onFileInputChange(e: Event) {
}
async function handleUpload() {
if (selectedLibraryId.value === undefined) return
await startUpload(selectedLibraryId.value, selectedFolderId.value)
const libraryId = selectedLibraryId.value
if (libraryId === undefined) return
const folderId = selectedFolderId.value
const pendingAtStart = files.value.filter((f) => f.status === 'pending').length
if (pendingAtStart === 0) return
const uploadedBefore = new Set(files.value.filter((f) => f.status === 'done' && f.bookId !== undefined).map((f) => f.bookId!))
const errorCountBefore = files.value.filter((f) => f.status === 'error').length
await startUpload(libraryId, folderId)
const uploadedAfter = files.value.filter((f) => f.status === 'done' && f.bookId !== undefined).map((f) => f.bookId!)
const uploadedBookIds = uploadedAfter.filter((id) => !uploadedBefore.has(id))
const errorCountAfter = files.value.filter((f) => f.status === 'error').length
emitLibraryUploadCompleted({
libraryId,
uploadedBookIds,
uploadedCount: uploadedBookIds.length,
failedCount: Math.max(0, errorCountAfter - errorCountBefore),
attemptedCount: pendingAtStart,
})
}
function formatBytes(bytes: number): string {
@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from 'vitest'
import { emitLibraryUploadCompleted, useLibraryUploadEvents } from '../useLibraryUploadEvents'
describe('useLibraryUploadEvents', () => {
it('notifies subscribers with the emitted event payload', () => {
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const cb = vi.fn()
const off = onLibraryUploadCompleted(cb)
const event = {
libraryId: 7,
uploadedBookIds: [101, 102],
uploadedCount: 2,
failedCount: 1,
attemptedCount: 3,
}
emitLibraryUploadCompleted(event)
expect(cb).toHaveBeenCalledTimes(1)
expect(cb).toHaveBeenCalledWith(event)
off()
})
it('stops notifying once unsubscribed', () => {
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const cb = vi.fn()
const off = onLibraryUploadCompleted(cb)
off()
emitLibraryUploadCompleted({
libraryId: 5,
uploadedBookIds: [1],
uploadedCount: 1,
failedCount: 0,
attemptedCount: 1,
})
expect(cb).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,24 @@
export interface LibraryUploadCompletedEvent {
libraryId: number
uploadedBookIds: number[]
uploadedCount: number
failedCount: number
attemptedCount: number
}
type LibraryUploadCompletedCallback = (event: LibraryUploadCompletedEvent) => void
const completedCallbacks = new Set<LibraryUploadCompletedCallback>()
export function emitLibraryUploadCompleted(event: LibraryUploadCompletedEvent): void {
for (const cb of completedCallbacks) cb(event)
}
export function useLibraryUploadEvents() {
function onLibraryUploadCompleted(cb: LibraryUploadCompletedCallback): () => void {
completedCallbacks.add(cb)
return () => completedCallbacks.delete(cb)
}
return { onLibraryUploadCompleted }
}
@@ -5,7 +5,7 @@ import type { StagingBulkEditResult, StagingMetadata } from '@projectx/types'
import { api } from '@/lib/api'
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[] }
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
selectionCount: number
}>()
@@ -50,12 +50,15 @@ async function submit() {
if (!enabledFields.has(f.key)) continue
const raw = values[f.key] ?? ''
if (f.type === 'array') {
(fields as Record<string, unknown>)[f.key] = raw.split(',').map((s) => s.trim()).filter(Boolean)
;(fields as Record<string, unknown>)[f.key] = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean)
} else if (f.type === 'number') {
const n = parseFloat(raw)
if (!isNaN(n)) (fields as Record<string, unknown>)[f.key] = n
} else {
(fields as Record<string, unknown>)[f.key] = raw || undefined
;(fields as Record<string, unknown>)[f.key] = raw || undefined
}
}
@@ -98,7 +101,10 @@ function handleClose() {
<h2 class="text-base font-semibold text-foreground">
{{ result ? 'Bulk Edit Results' : `Bulk Edit ${selectionCount} file${selectionCount === 1 ? '' : 's'}` }}
</h2>
<button class="size-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-all" @click="handleClose">
<button
class="size-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
@click="handleClose"
>
<X class="size-4" />
</button>
</div>
@@ -108,10 +114,20 @@ function handleClose() {
<div class="space-y-2">
<div v-for="f in FIELDS" :key="f.key" class="flex items-center gap-2">
<input type="checkbox" :checked="enabledFields.has(f.key)" class="size-3.5 rounded border-input accent-primary shrink-0" @change="toggleField(f.key)" />
<input
type="checkbox"
:checked="enabledFields.has(f.key)"
class="size-3.5 rounded border-input accent-primary shrink-0"
@change="toggleField(f.key)"
/>
<label class="flex-1">
<span class="text-xs font-medium text-muted-foreground block mb-0.5">{{ f.label }}</span>
<input v-model="values[f.key]" :disabled="!enabledFields.has(f.key)" :placeholder="f.type === 'array' ? 'comma-separated' : ''" class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring disabled:opacity-40" />
<input
v-model="values[f.key]"
:disabled="!enabledFields.has(f.key)"
:placeholder="f.type === 'array' ? 'comma-separated' : ''"
class="w-full h-8 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring disabled:opacity-40"
/>
</label>
</div>
</div>
@@ -127,7 +143,11 @@ function handleClose() {
<button class="h-8 px-4 rounded-lg text-sm text-muted-foreground hover:text-foreground transition-all" @click="handleClose">
Cancel
</button>
<button class="flex items-center gap-1.5 h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95 disabled:opacity-50" :disabled="enabledFields.size === 0 || loading" @click="submit">
<button
class="flex items-center gap-1.5 h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95 disabled:opacity-50"
:disabled="enabledFields.size === 0 || loading"
@click="submit"
>
<Loader2 v-if="loading" class="size-3.5 animate-spin" />
Apply
</button>
@@ -140,7 +160,10 @@ function handleClose() {
<p v-if="result.failed > 0" class="text-xs text-muted-foreground mt-0.5">{{ result.failed }} failed</p>
</div>
<div class="flex justify-end">
<button class="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95" @click="handleClose">
<button
class="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95"
@click="handleClose"
>
Done
</button>
</div>
@@ -149,4 +172,3 @@ function handleClose() {
</div>
</Teleport>
</template>
@@ -1,19 +1,24 @@
<script setup lang="ts">
import { BookOpen, FileText, Wand2, Pencil } from 'lucide-vue-next'
import { onMounted, ref } from 'vue'
import { BookOpen, FileText, Wand2, Pencil, X } from 'lucide-vue-next'
import type { StagingFile, StagingMetadata } from '@projectx/types'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { formatBytes } from '@/lib/formatting'
import StagingStatusBadge from './StagingStatusBadge.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useLibraries } from '@/features/library/composables/useLibraries'
withDefaults(
const props = withDefaults(
defineProps<{
items: StagingFile[]
loading: boolean
isSelected: (id: number) => boolean
selectAll: boolean
namePreviewByFileId?: Record<number, string>
emptyMessage?: string
}>(),
{
namePreviewByFileId: () => ({}),
emptyMessage: 'Upload files or drop them in the staging folder',
},
)
@@ -29,18 +34,62 @@ function backendCoverUrl(file: StagingFile): string {
return `/api/v1/staging/files/${file.id}/cover?v=${new Date(file.updatedAt).getTime()}`
}
function coverUrl(file: StagingFile): string {
function selectedCoverUrl(file: StagingFile): string | null {
const url = file.selectedMetadata?.coverUrl?.trim()
return url ? url : null
}
function newCoverUrl(file: StagingFile): string | null {
const selected = selectedCoverUrl(file)
if (selected) return selected
const fetched = file.fetchedMetadata?.coverUrl?.trim()
return fetched ? fetched : null
}
function currentCoverUrl(file: StagingFile): string {
return backendCoverUrl(file)
}
function onCoverError(event: Event, file: StagingFile) {
function onCurrentCoverError(event: Event) {
const img = event.target as HTMLImageElement
const externalUrl = file.selectedMetadata?.coverUrl ?? file.fetchedMetadata?.coverUrl
if (img.src.includes('/api/v1/staging/files/') && externalUrl) {
img.src = externalUrl
} else {
img.style.display = 'none'
img.style.display = 'none'
}
function onFetchedCoverError(event: Event) {
const img = event.target as HTMLImageElement
img.style.display = 'none'
}
function currentCoverLightboxFallback(): null {
return null
}
const coverLightbox = ref<{ src: string; fallback: string | null; title: string } | null>(null)
function openCoverLightbox(src: string, title: string, fallback: string | null = null) {
coverLightbox.value = { src, fallback, title }
}
function closeCoverLightbox() {
coverLightbox.value = null
}
function onLightboxOpenChange(open: boolean) {
if (!open) closeCoverLightbox()
}
function onLightboxCoverError(event: Event) {
const img = event.target as HTMLImageElement
const fallback = coverLightbox.value?.fallback
if (fallback) {
const currentSrc = img.currentSrc || img.src
const resolvedFallback = new URL(fallback, window.location.origin).href
if (currentSrc !== resolvedFallback) {
img.src = fallback
return
}
}
img.style.display = 'none'
}
function displayTitle(file: StagingFile): string {
@@ -74,6 +123,35 @@ function confidenceBadgeClass(file: StagingFile): string {
if (c >= 50) return 'bg-amber-500/15 text-amber-600 dark:text-amber-400'
return 'bg-red-500/15 text-red-600 dark:text-red-400'
}
const { libraries, fetchLibraries } = useLibraries()
onMounted(() => {
void fetchLibraries()
})
function joinTargetPath(folderPath: string, fileName: string): string {
const base = folderPath.replace(/[\\/]+$/, '')
const separator = base.includes('\\') ? '\\' : '/'
return `${base}${separator}${fileName}`
}
function targetSummary(file: StagingFile): string {
const lib = file.targetLibraryId != null ? libraries.value.find((l) => l.id === file.targetLibraryId) : null
const folder = file.targetFolderId != null ? lib?.folders.find((f) => f.id === file.targetFolderId) : null
const libLabel = lib?.name ?? (file.targetLibraryId != null ? 'Unknown library' : 'Unassigned')
const previewName = props.namePreviewByFileId?.[file.id] ?? file.fileName
if (lib && folder?.path) {
return `Target: ${libLabel} · ${joinTargetPath(folder.path, previewName)}`
}
if (file.targetLibraryId == null || file.targetFolderId == null) {
return 'Target: Unassigned'
}
return `Target: ${libLabel} · Unknown destination path`
}
</script>
<template>
@@ -101,6 +179,8 @@ function confidenceBadgeClass(file: StagingFile): string {
<div v-else class="divide-y divide-border">
<div class="flex items-center gap-3 px-4 py-2 bg-muted/30 border-b border-border">
<input type="checkbox" :checked="selectAll" class="size-3.5 rounded border-input accent-primary" @change="$emit('selectAll')" />
<span class="text-xs font-medium text-muted-foreground w-12 text-center shrink-0">Current</span>
<span class="text-xs font-medium text-muted-foreground w-12 text-center shrink-0">New</span>
<span class="text-xs font-medium text-muted-foreground flex-1">File</span>
<span class="text-xs font-medium text-muted-foreground w-16 text-right hidden sm:block">Size</span>
<span class="text-xs font-medium text-muted-foreground w-12 text-center hidden sm:block">Format</span>
@@ -112,7 +192,7 @@ function confidenceBadgeClass(file: StagingFile): string {
<button
v-for="file in items"
:key="file.id"
class="flex items-center gap-3 px-4 py-2.5 w-full text-left hover:bg-muted/50 transition-colors cursor-pointer"
class="flex items-center gap-3 px-4 py-3.5 w-full text-left hover:bg-muted/50 transition-colors cursor-pointer"
@click="$emit('open', file)"
>
<input
@@ -123,9 +203,38 @@ function confidenceBadgeClass(file: StagingFile): string {
@change.stop="$emit('select', file.id)"
/>
<div class="relative size-10 rounded-lg bg-muted flex items-center justify-center shrink-0 overflow-hidden">
<img :src="coverUrl(file)" alt="" class="size-full object-cover" @load="($event.target as HTMLImageElement).style.display = ''" @error="onCoverError($event, file)" />
<BookOpen class="size-4 text-muted-foreground absolute" />
<div class="w-12 shrink-0 flex justify-center">
<div
class="relative size-12 rounded-lg bg-muted flex items-center justify-center overflow-hidden cursor-zoom-in ring-1 ring-border"
@click.stop="openCoverLightbox(currentCoverUrl(file), `${displayTitle(file)} current cover`, currentCoverLightboxFallback())"
>
<img
:src="currentCoverUrl(file)"
alt=""
class="size-full object-cover"
@load="($event.target as HTMLImageElement).style.display = ''"
@error="onCurrentCoverError"
/>
<BookOpen class="size-4 text-muted-foreground absolute" />
</div>
</div>
<div class="w-12 shrink-0 flex justify-center">
<div
v-if="newCoverUrl(file)"
class="relative size-12 rounded-lg bg-amber-500/5 border border-amber-500/30 flex items-center justify-center overflow-hidden cursor-zoom-in"
@click.stop="openCoverLightbox(newCoverUrl(file)!, `${displayTitle(file)} new cover`)"
>
<img
:src="newCoverUrl(file)!"
alt=""
class="size-full object-cover"
@load="($event.target as HTMLImageElement).style.display = ''"
@error="onFetchedCoverError($event)"
/>
<Wand2 class="size-3 text-amber-500 absolute" />
</div>
<div v-else class="size-12 rounded-lg border border-dashed border-border/70 bg-muted/30" />
</div>
<div class="flex-1 min-w-0">
@@ -148,6 +257,7 @@ function confidenceBadgeClass(file: StagingFile): string {
Edited
</span>
</div>
<p class="mt-0.5 text-[11px] text-muted-foreground truncate">{{ targetSummary(file) }}</p>
</div>
<span class="text-xs text-muted-foreground w-16 text-right shrink-0 hidden sm:block tabular-nums">
@@ -188,5 +298,31 @@ function confidenceBadgeClass(file: StagingFile): string {
</div>
</button>
</div>
<DialogRoot :open="!!coverLightbox" @update:open="onLightboxOpenChange">
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<DialogContent
class="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 max-w-[90vw] max-h-[90vh] outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
>
<DialogTitle class="sr-only">{{ coverLightbox?.title ?? 'Cover preview' }}</DialogTitle>
<DialogDescription class="sr-only">Enlarged cover image preview dialog.</DialogDescription>
<img
v-if="coverLightbox"
:src="coverLightbox.src"
:alt="coverLightbox.title"
class="max-w-[90vw] max-h-[90vh] rounded-md shadow-2xl object-contain"
@error="onLightboxCoverError"
/>
<DialogClose
class="absolute -top-3 -right-3 p-1 rounded-full bg-background border border-border text-muted-foreground hover:text-foreground transition-colors"
>
<X class="size-4" />
</DialogClose>
</DialogContent>
</DialogPortal>
</DialogRoot>
</div>
</template>
@@ -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<StagingMetadata> {
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<StagingMetadata> = {
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"
/>
<BookOpen class="size-5 text-muted-foreground absolute" />
</div>
@@ -487,7 +524,10 @@ onMounted(() => {
<div class="flex items-center gap-2">
<button
class="flex items-center gap-1.5 h-8 px-3.5 rounded-lg text-primary-foreground text-sm font-medium transition-all active:scale-95"
style="background: linear-gradient(to right, var(--primary), color-mix(in oklch, var(--primary) 65%, oklch(0.7 0.25 280))); box-shadow: 0 2px 10px color-mix(in oklch, var(--primary) 45%, transparent);"
style="
background: linear-gradient(to right, var(--primary), color-mix(in oklch, var(--primary) 65%, oklch(0.7 0.25 280)));
box-shadow: 0 2px 10px color-mix(in oklch, var(--primary) 45%, transparent);
"
@click="openSearch"
>
<Sparkles class="size-3.5" />
@@ -8,7 +8,7 @@ import { useLibraries } from '@/features/library/composables/useLibraries'
import { useStagingFinalize } from '../composables/useStagingFinalize'
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[] }
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
selectionCount: number
}>()
@@ -24,56 +24,102 @@ const { result, loading, error, finalize, reset } = useStagingFinalize()
const defaultLibraryId = ref<number | null>(null)
const defaultFolderId = ref<number | null>(null)
const expandedErrors = ref<Set<number>>(new Set())
const selectionSummary = ref<{ total: number; withDestination: number; withoutDestination: number } | null>(null)
const duplicateCount = computed(() => result.value?.results.filter((r) => r.isDuplicate).length ?? 0)
const namePreview = ref<{ fileId: number; fileName: string; newName: string }[]>([])
const previewLoading = ref(false)
let previewReqSeq = 0
const selectedLibrary = computed(() => libraries.value.find((l) => l.id === defaultLibraryId.value))
const folders = computed(() => selectedLibrary.value?.folders ?? [])
const canStart = computed(() => defaultLibraryId.value !== null && defaultFolderId.value !== null)
const requiresDefaultDestination = computed(() => (selectionSummary.value?.withoutDestination ?? props.selectionCount) > 0)
const canStart = computed(() => {
if (!selectionSummary.value) return false
if (!requiresDefaultDestination.value) return true
return defaultLibraryId.value !== null && defaultFolderId.value !== null
})
onMounted(async () => {
await fetchLibraries()
await Promise.all([fetchLibraries(), fetchSelectionSummary()])
const first = libraries.value[0]
if (first) {
if (requiresDefaultDestination.value && first) {
defaultLibraryId.value = first.id
const firstFolder = first.folders?.[0]
if (firstFolder) defaultFolderId.value = firstFolder.id
}
void fetchNamePreview(defaultLibraryId.value)
void fetchNamePreview(requiresDefaultDestination.value ? defaultLibraryId.value : null)
})
function onLibraryChange(event: Event) {
const id = Number((event.target as HTMLSelectElement).value)
const raw = Number((event.target as HTMLSelectElement).value)
const id = Number.isFinite(raw) && raw > 0 ? raw : null
if (id === null) {
defaultLibraryId.value = null
defaultFolderId.value = null
return
}
defaultLibraryId.value = id
const lib = libraries.value.find((l) => l.id === id)
defaultFolderId.value = lib?.folders?.[0]?.id ?? null
}
function onFolderChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
defaultFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null
}
async function fetchNamePreview(libId: number | null) {
if (!libId) {
if (requiresDefaultDestination.value && !libId) {
namePreview.value = []
previewLoading.value = false
return
}
const reqId = ++previewReqSeq
previewLoading.value = true
try {
const payload = {
...props.selectionPayload,
...(requiresDefaultDestination.value && libId ? { defaultLibraryId: libId } : {}),
}
const res = await api('/api/v1/staging/files/preview-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...props.selectionPayload, defaultLibraryId: libId }),
body: JSON.stringify(payload),
})
namePreview.value = res.ok ? await res.json() : []
if (!res.ok || reqId !== previewReqSeq) return
const rows: { fileId: number; fileName: string; newName: string }[] = await res.json()
if (reqId !== previewReqSeq) return
namePreview.value = rows
} catch {
if (reqId !== previewReqSeq) return
namePreview.value = []
} finally {
if (reqId !== previewReqSeq) return
previewLoading.value = false
}
}
watch(defaultLibraryId, (libId) => {
async function fetchSelectionSummary() {
try {
const res = await api('/api/v1/staging/files/selection-summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(props.selectionPayload),
})
if (res.ok) {
selectionSummary.value = await res.json()
return
}
} catch {
// fallback handled below
}
selectionSummary.value = { total: props.selectionCount, withDestination: 0, withoutDestination: props.selectionCount }
}
watch([defaultLibraryId, requiresDefaultDestination], ([libId]) => {
void fetchNamePreview(libId)
})
@@ -81,8 +127,12 @@ async function start() {
if (!canStart.value) return
await finalize({
...props.selectionPayload,
defaultLibraryId: defaultLibraryId.value!,
defaultFolderId: defaultFolderId.value!,
...(requiresDefaultDestination.value
? {
defaultLibraryId: defaultLibraryId.value!,
defaultFolderId: defaultFolderId.value!,
}
: {}),
})
if (result.value?.succeeded) refreshLibraries()
}
@@ -117,9 +167,19 @@ function goToBook(bookId: number) {
</div>
<div v-if="!result" class="px-5 py-4 space-y-4">
<div class="space-y-3">
<div v-if="selectionSummary" class="rounded-lg border border-border bg-muted/20 px-3 py-2.5">
<p class="text-xs text-muted-foreground">
{{
requiresDefaultDestination
? `${selectionSummary.withoutDestination} of ${selectionSummary.total} selected file${selectionSummary.total === 1 ? '' : 's'} need a destination`
: 'All selected files already have destination set'
}}
</p>
</div>
<div v-if="requiresDefaultDestination" class="space-y-3">
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Destination Library</span>
<span class="text-xs font-medium text-muted-foreground">Default Destination Library</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="defaultLibraryId ?? ''"
@@ -130,11 +190,11 @@ function goToBook(bookId: number) {
</label>
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Destination Folder</span>
<span class="text-xs font-medium text-muted-foreground">Default Destination Folder</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="defaultFolderId ?? ''"
@change="defaultFolderId = Number(($event.target as HTMLSelectElement).value)"
@change="onFolderChange"
>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
@@ -0,0 +1,163 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { X, Loader2, FolderPlus } from 'lucide-vue-next'
import { api } from '@/lib/api'
import { useLibraries } from '@/features/library/composables/useLibraries'
const props = defineProps<{
selectionPayload: { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: string; search?: string }
selectionCount: number
}>()
const emit = defineEmits<{
close: []
updated: []
}>()
const { libraries, fetchLibraries } = useLibraries()
const loading = ref(false)
const error = ref<string | null>(null)
const result = ref<{ total: number; updated: number; failed: number } | null>(null)
const targetLibraryId = ref<number | null>(null)
const targetFolderId = ref<number | null>(null)
const selectedLibrary = computed(() => libraries.value.find((l) => l.id === targetLibraryId.value))
const folders = computed(() => selectedLibrary.value?.folders ?? [])
const canApply = computed(() => targetLibraryId.value !== null && targetFolderId.value !== null && !loading.value)
onMounted(async () => {
await fetchLibraries()
const first = libraries.value[0]
if (!first) return
targetLibraryId.value = first.id
targetFolderId.value = first.folders?.[0]?.id ?? null
})
function onLibraryChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
const id = Number.isFinite(raw) && raw > 0 ? raw : null
if (id === null) {
targetLibraryId.value = null
targetFolderId.value = null
return
}
targetLibraryId.value = id
const lib = libraries.value.find((l) => l.id === id)
targetFolderId.value = lib?.folders?.[0]?.id ?? null
}
function onFolderChange(event: Event) {
const raw = Number((event.target as HTMLSelectElement).value)
targetFolderId.value = Number.isFinite(raw) && raw > 0 ? raw : null
}
async function applyDestination() {
if (!canApply.value) return
loading.value = true
error.value = null
try {
const res = await api('/api/v1/staging/files/set-target', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...props.selectionPayload,
targetLibraryId: targetLibraryId.value,
targetFolderId: targetFolderId.value,
}),
})
if (res.ok) {
result.value = await res.json()
} else {
const body = await res.json().catch(() => null)
error.value = (body as { message?: string } | null)?.message ?? `Error ${res.status}`
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'Failed to set destination'
} finally {
loading.value = false
}
}
function handleClose() {
if (result.value) emit('updated')
else emit('close')
}
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" @click="handleClose" />
<div class="relative z-10 w-full max-w-md mx-4 bg-card border border-border rounded-xl shadow-2xl overflow-hidden">
<div class="flex items-center justify-between px-5 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">
{{ result ? 'Destination Updated' : `Set Destination for ${selectionCount} file${selectionCount === 1 ? '' : 's'}` }}
</h2>
<button
class="size-7 rounded-full flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-all"
@click="handleClose"
>
<X class="size-4" />
</button>
</div>
<div v-if="!result" class="px-5 py-4 space-y-4">
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Destination Library</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="targetLibraryId ?? ''"
@change="onLibraryChange"
>
<option v-for="lib in libraries" :key="lib.id" :value="lib.id">{{ lib.name }}</option>
</select>
</label>
<label class="block">
<span class="text-xs font-medium text-muted-foreground">Destination Folder</span>
<select
class="mt-1 w-full h-9 rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
:value="targetFolderId ?? ''"
@change="onFolderChange"
>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
</label>
<p v-if="error" class="text-xs text-red-500 bg-red-500/10 rounded-lg p-2">{{ error }}</p>
<div class="flex items-center justify-end gap-2 pt-1">
<button class="h-8 px-4 rounded-lg text-sm text-muted-foreground hover:text-foreground transition-all" @click="handleClose">
Cancel
</button>
<button
class="flex items-center gap-1.5 h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95 disabled:opacity-50"
:disabled="!canApply"
@click="applyDestination"
>
<Loader2 v-if="loading" class="size-3.5 animate-spin" />
<FolderPlus v-else class="size-3.5" />
Apply
</button>
</div>
</div>
<div v-else class="px-5 py-4 space-y-4">
<div class="rounded-lg bg-emerald-500/10 p-3">
<p class="text-sm font-medium">Updated {{ result.updated }} of {{ result.total }} files</p>
<p v-if="result.failed > 0" class="text-xs text-muted-foreground mt-0.5">{{ result.failed }} failed</p>
</div>
<div class="flex justify-end">
<button
class="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-sm font-medium transition-all hover:opacity-90 active:scale-95"
@click="handleClose"
>
Done
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw } from 'lucide-vue-next'
import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw, FolderPlus } from 'lucide-vue-next'
import type { StagingFileStatus } from '@projectx/types'
import { api } from '@/lib/api'
import { SUPPORTED_FORMATS_ACCEPT, useStagingUpload } from '../composables/useStagingUpload'
@@ -19,6 +19,7 @@ const emit = defineEmits<{
rescan: []
rescanError: []
retryFetch: []
setDestination: []
bulkDiscard: []
bulkEdit: []
finalize: []
@@ -148,6 +149,13 @@ function clearSearch() {
<FileText class="size-3.5" />
Finalize
</button>
<button
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-emerald-500/12 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/20 transition-all active:scale-95"
@click="$emit('setDestination')"
>
<FolderPlus class="size-3.5" />
Set Destination
</button>
<Tooltip v-if="fetchedCount > 0">
<TooltipTrigger as-child>
<button
@@ -167,7 +175,7 @@ function clearSearch() {
<Tooltip v-if="errorCount > 0">
<TooltipTrigger as-child>
<button
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-muted text-muted-foreground hover:text-foreground transition-all active:scale-95"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-sky-500/12 text-sky-700 dark:text-sky-300 hover:bg-sky-500/20 transition-all active:scale-95"
@click="$emit('retryFetch')"
>
<RefreshCw class="size-3.5" />
@@ -181,7 +189,7 @@ function clearSearch() {
<TooltipContent>Retry metadata fetch for {{ errorCount }} error file{{ errorCount !== 1 ? 's' : '' }}</TooltipContent>
</Tooltip>
<button
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-muted text-muted-foreground hover:text-foreground transition-all active:scale-95"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-violet-500/12 text-violet-700 dark:text-violet-300 hover:bg-violet-500/20 transition-all active:scale-95"
@click="$emit('bulkEdit')"
>
<PenLine class="size-3.5" />
@@ -54,13 +54,18 @@ export function useStagingDetail() {
debounceTimer = setTimeout(() => saveMetadata(id, metadata), 1000)
}
async function setTarget(id: number, libraryId: number | null, folderId: number | null) {
async function setTarget(id: number, libraryId: number | null, folderId: number | null): Promise<StagingFile | null> {
const res = await api(`/api/v1/staging/files/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetLibraryId: libraryId, targetFolderId: folderId }),
})
if (res.ok) file.value = await res.json()
if (res.ok) {
const updated: StagingFile = await res.json()
file.value = updated
return updated
}
return null
}
async function discardFile(id: number) {
@@ -22,10 +22,12 @@ export function useStagingFiles() {
const selectedIds = ref<Set<number>>(new Set())
const selectAll = ref(false)
const excludedIds = ref<Set<number>>(new Set())
let fetchReqSeq = 0
const pageCount = computed(() => Math.ceil(total.value / filters.limit) || 1)
async function fetchFiles() {
const reqId = ++fetchReqSeq
loading.value = true
try {
const params = new URLSearchParams()
@@ -39,21 +41,25 @@ export function useStagingFiles() {
const res = await api(`/api/v1/staging/files?${params}`)
if (res.ok) {
const data: StagingFilesPage = await res.json()
if (reqId !== fetchReqSeq) return
items.value = data.items
total.value = data.total
}
} finally {
if (reqId !== fetchReqSeq) return
loading.value = false
}
}
function setStatus(status: StagingFileStatus | undefined) {
clearSelection()
filters.status = status
filters.page = 1
fetchFiles()
}
function setSearch(search: string) {
clearSelection()
filters.search = search
filters.page = 1
fetchFiles()
@@ -108,15 +114,20 @@ export function useStagingFiles() {
}
const selectionCount = computed(() => {
if (selectAll.value) return total.value - excludedIds.value.size
if (selectAll.value) return Math.max(0, total.value - excludedIds.value.size)
return selectedIds.value.size
})
const hasSelection = computed(() => selectionCount.value > 0)
function getSelectionPayload(): { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[] } {
function getSelectionPayload(): { fileIds?: number[]; selectAll?: boolean; excludedIds?: number[]; status?: StagingFileStatus; search?: string } {
if (selectAll.value) {
return { selectAll: true, excludedIds: [...excludedIds.value] }
return {
selectAll: true,
excludedIds: [...excludedIds.value],
...(filters.status ? { status: filters.status } : {}),
...(filters.search ? { search: filters.search } : {}),
}
}
return { fileIds: [...selectedIds.value] }
}
@@ -11,8 +11,10 @@ export function useStagingFinalize() {
fileIds?: number[]
selectAll?: boolean
excludedIds?: number[]
defaultLibraryId: number
defaultFolderId: number
status?: string
search?: string
defaultLibraryId?: number
defaultFolderId?: number
overrides?: StagingFinalizeOverride[]
}) {
loading.value = true
@@ -46,4 +48,3 @@ export function useStagingFinalize() {
return { result, loading, error, finalize, reset }
}
@@ -11,14 +11,28 @@ const changeListeners = new Set<() => void>()
let fetchPromise: Promise<void> | null = null
let loaded = false
async function restFetchSummary() {
function requestSummary(markLoading: boolean): Promise<void> {
if (fetchPromise) return fetchPromise
try {
const res = await api('/api/v1/staging/summary')
if (res.ok) summary.value = await res.json()
} catch {
// best-effort refresh on reconnect
}
if (markLoading) loading.value = true
fetchPromise = api('/api/v1/staging/summary')
.then(async (res) => {
if (res.ok) {
summary.value = await res.json()
loaded = true
}
})
.catch(() => {
// best-effort refresh on reconnect
})
.finally(() => {
if (markLoading) loading.value = false
fetchPromise = null
})
return fetchPromise
}
async function restFetchSummary() {
return requestSummary(false)
}
function getSocket(): Socket {
@@ -49,22 +63,9 @@ function getSocket(): Socket {
}
export function useStagingSummary() {
async function fetchSummary(): Promise<void> {
if (loaded) return
if (fetchPromise) return fetchPromise
loading.value = true
fetchPromise = api('/api/v1/staging/summary')
.then(async (res) => {
if (res.ok) {
summary.value = await res.json()
loaded = true
}
})
.finally(() => {
loading.value = false
fetchPromise = null
})
return fetchPromise
async function fetchSummary(force = false): Promise<void> {
if (!force && loaded) return
return requestSummary(true)
}
function subscribe() {
+11
View File
@@ -24,6 +24,7 @@ import { useBookNavigation } from '@/features/book/composables/useBookNavigation
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
import { SORT_FIELD_LABELS } from '@/features/book/lib/filter-labels'
import type { GroupRule, SortSpec } from '@projectx/types'
@@ -39,6 +40,7 @@ const title = computed(() => currentLibrary.value?.name ?? 'Library')
const libraryIcon = computed(() => currentLibrary.value?.icon ?? 'BookOpen')
const { items: books, total, loading, error, filter, sort, hasMore, load, clear } = useBookQuery(libraryId)
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { setBookContext, registerLoadMore } = useBookNavigation()
watch(
@@ -199,6 +201,15 @@ onMounted(() => {
onUnmounted(() => observer?.disconnect())
const stopUploadCompletedListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount === 0) return
if (libraryId.value === event.libraryId) {
load(true)
}
})
onUnmounted(() => stopUploadCompletedListener())
watch(libraryId, (newId) => {
if (newId === null) clear()
})
+120 -16
View File
@@ -13,6 +13,14 @@ import StagingFileList from '@/features/staging/components/StagingFileList.vue'
import StagingFileSheet from '@/features/staging/components/StagingFileSheet.vue'
import StagingFinalizeDialog from '@/features/staging/components/StagingFinalizeDialog.vue'
import StagingBulkEditDialog from '@/features/staging/components/StagingBulkEditDialog.vue'
import StagingSetDestinationDialog from '@/features/staging/components/StagingSetDestinationDialog.vue'
type ApplyFetchedResult = {
total: number
applied: number
skipped: number
skippedEdited: number
}
const {
items,
@@ -40,15 +48,19 @@ const { addFiles, isUploading } = useStagingUpload()
const selectedFile = ref<StagingFile | null>(null)
const showFinalizeDialog = ref(false)
const showBulkEditDialog = ref(false)
const showSetDestinationDialog = ref(false)
const dragOver = ref(false)
const newFilesDetected = ref(false)
const applyFetchedResult = ref<{ applied: number; skipped: number; skippedEdited: number } | null>(null)
const namePreviewByFileId = ref<Record<number, string>>({})
const applyFetchedResult = ref<ApplyFetchedResult | null>(null)
const rescanFailed = ref(false)
const retryQueued = ref<number | null>(null)
let newFilesTimer: ReturnType<typeof setTimeout> | null = null
let applyFetchedTimer: ReturnType<typeof setTimeout> | null = null
let rescanFailedTimer: ReturnType<typeof setTimeout> | null = null
let retryQueuedTimer: ReturnType<typeof setTimeout> | null = null
let namePreviewTimer: ReturnType<typeof setTimeout> | null = null
let namePreviewReqSeq = 0
let prevTotal = -1
@@ -80,7 +92,7 @@ function onDiscarded() {
function refresh() {
fetchFiles()
fetchSummary()
fetchSummary(true)
void fetchStatistics()
}
@@ -88,6 +100,7 @@ function onFileUpdated(updated: StagingFile) {
const idx = items.value.findIndex((f) => f.id === updated.id)
if (idx !== -1) items.value[idx] = updated
if (selectedFile.value?.id === updated.id) selectedFile.value = updated
scheduleVisibleNamePreview()
}
function hasContent(obj: Record<string, unknown> | null | undefined): boolean {
@@ -131,7 +144,12 @@ async function handleApplyFetched() {
})
if (res.ok) {
const result = await res.json()
applyFetchedResult.value = { applied: result.applied, skipped: result.skipped, skippedEdited: result.skippedEdited ?? 0 }
applyFetchedResult.value = {
total: result.total ?? 0,
applied: result.applied ?? 0,
skipped: result.skipped ?? 0,
skippedEdited: result.skippedEdited ?? 0,
}
if (applyFetchedTimer) clearTimeout(applyFetchedTimer)
applyFetchedTimer = setTimeout(() => {
applyFetchedResult.value = null
@@ -160,17 +178,48 @@ async function handleRetryFetch() {
async function handleInlineApplyFetched(fileId: number) {
const file = items.value.find((f) => f.id === fileId)
if (!file?.fetchedMetadata) return
const res = await api(`/api/v1/staging/files/${fileId}`, {
method: 'PATCH',
const res = await api('/api/v1/staging/files/apply-fetched', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selectedMetadata: file.fetchedMetadata }),
body: JSON.stringify({ fileIds: [fileId] }),
})
if (res.ok) {
const updated: StagingFile = await res.json()
onFileUpdated(updated)
const result = await res.json()
applyFetchedResult.value = {
total: result.total ?? 0,
applied: result.applied ?? 0,
skipped: result.skipped ?? 0,
skippedEdited: result.skippedEdited ?? 0,
}
if (applyFetchedTimer) clearTimeout(applyFetchedTimer)
applyFetchedTimer = setTimeout(() => {
applyFetchedResult.value = null
}, 4000)
refresh()
scheduleVisibleNamePreview()
}
}
function pluralizeFiles(count: number): string {
return `${count} file${count === 1 ? '' : 's'}`
}
function applyFetchedResultMessage(result: ApplyFetchedResult): string {
const reasons: string[] = []
if (result.skippedEdited > 0)
reasons.push(`skipped ${pluralizeFiles(result.skippedEdited)} because ${result.skippedEdited === 1 ? 'it has' : 'they have'} manual edits`)
if (result.skipped > 0)
reasons.push(`skipped ${pluralizeFiles(result.skipped)} because ${result.skipped === 1 ? 'it has' : 'they have'} no fetched metadata`)
if (result.applied > 0) {
const base = `Applied to ${pluralizeFiles(result.applied)}`
return reasons.length ? `${base}; ${reasons.join('; ')}` : base
}
if (reasons.length) return `No files applied; ${reasons.join('; ')}`
return 'No files selected'
}
function handleRescanError() {
rescanFailed.value = true
if (rescanFailedTimer) clearTimeout(rescanFailedTimer)
@@ -185,6 +234,10 @@ function openBulkEdit() {
showBulkEditDialog.value = true
}
function openSetDestination() {
showSetDestinationDialog.value = true
}
function onFinalized() {
showFinalizeDialog.value = false
clearSelection()
@@ -197,6 +250,43 @@ function onBulkEdited() {
refresh()
}
function onDestinationSet() {
showSetDestinationDialog.value = false
refresh()
scheduleVisibleNamePreview()
}
async function fetchVisibleNamePreview() {
const ids = items.value.map((f) => f.id)
if (!ids.length) {
namePreviewByFileId.value = {}
return
}
const reqId = ++namePreviewReqSeq
try {
const res = await api('/api/v1/staging/files/preview-names', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileIds: ids }),
})
if (!res.ok || reqId !== namePreviewReqSeq) return
const rows: { fileId: number; newName: string }[] = await res.json()
if (reqId !== namePreviewReqSeq) return
namePreviewByFileId.value = Object.fromEntries(rows.map((r) => [r.fileId, r.newName]))
} catch {
if (reqId !== namePreviewReqSeq) return
namePreviewByFileId.value = {}
}
}
function scheduleVisibleNamePreview() {
if (namePreviewTimer) clearTimeout(namePreviewTimer)
namePreviewTimer = setTimeout(() => {
void fetchVisibleNamePreview()
}, 120)
}
function onDragOver(e: DragEvent) {
e.preventDefault()
}
@@ -210,7 +300,7 @@ function onDragEnter(e: DragEvent) {
}
function onDragLeave() {
dragCounter--
dragCounter = Math.max(0, dragCounter - 1)
if (dragCounter === 0) dragOver.value = false
}
@@ -234,9 +324,16 @@ watch(isUploading, (uploading, was) => {
if (was && !uploading) refresh()
})
watch(
() => items.value.map((f) => `${f.id}:${f.updatedAt}:${f.targetLibraryId ?? ''}:${f.targetFolderId ?? ''}`).join('|'),
() => {
scheduleVisibleNamePreview()
},
)
onMounted(() => {
fetchFiles()
fetchSummary()
fetchSummary(true)
fetchStatistics()
subscribe()
})
@@ -247,12 +344,13 @@ onUnmounted(() => {
if (applyFetchedTimer) clearTimeout(applyFetchedTimer)
if (rescanFailedTimer) clearTimeout(rescanFailedTimer)
if (retryQueuedTimer) clearTimeout(retryQueuedTimer)
if (namePreviewTimer) clearTimeout(namePreviewTimer)
})
</script>
<template>
<main class="flex-1" @dragover="onDragOver" @dragenter="onDragEnter" @dragleave="onDragLeave" @drop="onDrop">
<div class="flex flex-col gap-4 p-4 sm:p-6 max-w-7xl w-full">
<div class="flex flex-col gap-4 p-4 sm:p-6 max-w-8xl w-full">
<div class="flex items-center gap-2.5">
<div class="flex items-center justify-center size-9 rounded-lg bg-primary/10">
<PackageOpen class="size-4.5 text-primary" />
@@ -279,11 +377,7 @@ onUnmounted(() => {
class="ml-2 inline-flex items-center gap-1.5 h-6 px-2.5 rounded-full bg-amber-500/15 text-amber-600 dark:text-amber-400 text-xs font-medium"
>
<CheckCircle2 class="size-3.5" />
{{
applyFetchedResult.applied === 0
? 'No fetched metadata to apply'
: `Applied to ${applyFetchedResult.applied} file${applyFetchedResult.applied !== 1 ? 's' : ''}${applyFetchedResult.skippedEdited > 0 ? `, skipped ${applyFetchedResult.skippedEdited} with manual edits` : ''}`
}}
{{ applyFetchedResultMessage(applyFetchedResult) }}
</span>
</Transition>
<Transition name="fade">
@@ -329,6 +423,7 @@ onUnmounted(() => {
@bulk-discard="handleBulkDiscard"
@finalize="openFinalize"
@bulk-edit="openBulkEdit"
@set-destination="openSetDestination"
@refresh="refresh"
@apply-fetched="handleApplyFetched"
/>
@@ -360,6 +455,7 @@ onUnmounted(() => {
:loading="loading"
:is-selected="isSelected"
:select-all="selectAll"
:name-preview-by-file-id="namePreviewByFileId"
:empty-message="emptyMessage"
@select="toggleSelect"
@select-all="toggleSelectAll"
@@ -400,6 +496,14 @@ onUnmounted(() => {
@close="showBulkEditDialog = false"
@edited="onBulkEdited"
/>
<StagingSetDestinationDialog
v-if="showSetDestinationDialog"
:selection-payload="getSelectionPayload()"
:selection-count="selectionCount"
@close="showSetDestinationDialog = false"
@updated="onDestinationSet"
/>
</template>
<style scoped>
+6 -2
View File
@@ -60,8 +60,10 @@ export interface StagingFinalizeRequest {
fileIds?: number[];
selectAll?: boolean;
excludedIds?: number[];
defaultLibraryId: number;
defaultFolderId: number;
status?: StagingFileStatus;
search?: string;
defaultLibraryId?: number;
defaultFolderId?: number;
overrides?: StagingFinalizeOverride[];
}
@@ -87,6 +89,8 @@ export interface StagingBulkEditRequest {
fileIds?: number[];
selectAll?: boolean;
excludedIds?: number[];
status?: StagingFileStatus;
search?: string;
fields: Partial<StagingMetadata>;
enabledFields: string[];
mergeArrays: boolean;
@@ -117,7 +117,7 @@ describe('MetadataService', () => {
arrayBuffer: () => Promise.resolve(Buffer.from('image-bytes')),
}) as never;
await service.downloadAndSaveCover('https://img.example/cover.png', 9);
await expect(service.downloadAndSaveCover('https://img.example/cover.png', 9)).resolves.toBe(true);
expect(mockMkdir).toHaveBeenCalledWith('/books/covers/9', { recursive: true });
expect(mockWriteFile).toHaveBeenCalledWith('/books/covers/9/cover_extracted.png', Buffer.from('image-bytes'));
@@ -134,13 +134,13 @@ describe('MetadataService', () => {
ok: true,
arrayBuffer: () => Promise.resolve(Buffer.alloc(0)),
}) as never;
await service.downloadAndSaveCover('https://img.example/empty.png', 4);
await expect(service.downloadAndSaveCover('https://img.example/empty.png', 4)).resolves.toBe(false);
expect(mockWriteFile).not.toHaveBeenCalled();
expect(db.update).not.toHaveBeenCalled();
(global.fetch as jest.Mock).mockRejectedValue(new Error('timeout'));
await expect(service.downloadAndSaveCover('https://img.example/fail.png', 4)).resolves.toBeUndefined();
await expect(service.downloadAndSaveCover('https://img.example/fail.png', 4)).resolves.toBe(false);
expect(mockWriteFile).not.toHaveBeenCalled();
});
@@ -38,12 +38,12 @@ export class MetadataService {
await Promise.all([this.extractMetadata(bookId, absolutePath, format), this.extractCover(bookId, absolutePath, format)]);
}
async downloadAndSaveCover(url: string, bookId: number): Promise<void> {
async downloadAndSaveCover(url: string, bookId: number): Promise<boolean> {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!res.ok) return;
if (!res.ok) return false;
const buffer = Buffer.from(await res.arrayBuffer());
if (buffer.length === 0) return;
if (buffer.length === 0) return false;
const ext = imageExt(buffer);
const dir = join(this.booksPath, 'covers', String(bookId));
@@ -54,8 +54,10 @@ export class MetadataService {
await this.db.update(bookMetadata).set({ coverSource: 'extracted', updatedAt: new Date() }).where(eq(bookMetadata.bookId, bookId));
this.logger.debug(`Online cover saved for book ${bookId}`);
return true;
} catch (err) {
this.logger.warn(`Cover download failed for book ${bookId}: ${err instanceof Error ? err.message : String(err)}`);
return false;
}
}
+116 -4
View File
@@ -46,11 +46,23 @@ export class FinalizeStagingDto {
@IsInt({ each: true })
excludedIds?: number[];
@IsInt()
defaultLibraryId: number;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@ValidateIf((_, value) => value !== undefined)
@IsInt()
defaultFolderId: number;
defaultLibraryId?: number;
@IsOptional()
@ValidateIf((_, value) => value !== undefined)
@IsInt()
defaultFolderId?: number;
@IsOptional()
@IsArray()
@@ -73,6 +85,14 @@ export class BulkDiscardDto {
@IsArray()
@IsInt({ each: true })
excludedIds?: number[];
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
}
export class PreviewNamesDto {
@@ -90,8 +110,18 @@ export class PreviewNamesDto {
@IsInt({ each: true })
excludedIds?: number[];
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@ValidateIf((_, value) => value !== undefined)
@IsInt()
defaultLibraryId: number;
defaultLibraryId?: number;
}
export class BulkRetryFetchDto {
@@ -108,6 +138,14 @@ export class BulkRetryFetchDto {
@IsArray()
@IsInt({ each: true })
excludedIds?: number[];
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
}
export class BulkApplyFetchedDto {
@@ -124,6 +162,72 @@ export class BulkApplyFetchedDto {
@IsArray()
@IsInt({ each: true })
excludedIds?: number[];
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
}
export class BulkSetTargetDto {
@IsOptional()
@IsArray()
@IsInt({ each: true })
fileIds?: number[];
@IsOptional()
@IsBoolean()
selectAll?: boolean;
@IsOptional()
@IsArray()
@IsInt({ each: true })
excludedIds?: number[];
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsInt()
targetLibraryId?: number | null;
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsInt()
targetFolderId?: number | null;
}
export class SelectionSummaryDto {
@IsOptional()
@IsArray()
@IsInt({ each: true })
fileIds?: number[];
@IsOptional()
@IsBoolean()
selectAll?: boolean;
@IsOptional()
@IsArray()
@IsInt({ each: true })
excludedIds?: number[];
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
}
export class StagingMetadataFieldsDto {
@@ -158,6 +262,14 @@ export class BulkEditStagingDto {
@IsInt({ each: true })
excludedIds?: number[];
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
search?: string;
@ValidateNested()
@Type(() => StagingMetadataFieldsDto)
fields: StagingMetadataFieldsDto;
@@ -1,7 +1,7 @@
import { BadRequestException, Inject, Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { basename, extname, join } from 'path';
import { access as fsAccess, readFile, stat, unlink } from 'fs/promises';
import { and, eq, ilike, or } from 'drizzle-orm';
import { and, eq, ilike, inArray, or } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { StagingFinalizeFileResult, StagingFinalizeResult, StagingMetadata } from '@projectx/types';
@@ -24,6 +24,23 @@ type Db = NodePgDatabase<typeof schema>;
const BATCH_SIZE = 100;
type NormalizedFinalizeMetadata = {
title: string | null;
subtitle: string | null;
description: string | null;
isbn10: string | null;
isbn13: string | null;
publisher: string | null;
publishedYear: number | null;
language: string | null;
pageCount: number | null;
seriesName: string | null;
seriesIndex: number | null;
authors: string[];
genres: string[];
coverUrl: string | null;
};
@Injectable()
export class StagingFinalizeService implements OnModuleInit {
private readonly logger = new Logger(StagingFinalizeService.name);
@@ -53,11 +70,13 @@ export class StagingFinalizeService implements OnModuleInit {
fileIds: number[] | undefined,
selectAll: boolean | undefined,
excludedIds: number[] | undefined,
defaultLibraryId: number,
defaultFolderId: number,
defaultLibraryId: number | undefined,
defaultFolderId: number | undefined,
overrides?: Array<{ fileId: number; libraryId?: number; folderId?: number }>,
status?: string,
search?: string,
): Promise<StagingFinalizeResult> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds) : (fileIds ?? []);
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search) : (fileIds ?? []);
const overrideMap = new Map((overrides ?? []).map((o) => [o.fileId, o]));
const results: StagingFinalizeFileResult[] = [];
@@ -83,16 +102,25 @@ export class StagingFinalizeService implements OnModuleInit {
private async finalizeFile(
row: StagingFileRow,
defaultLibraryId: number,
defaultFolderId: number,
defaultLibraryId: number | undefined,
defaultFolderId: number | undefined,
overrideMap: Map<number, { libraryId?: number; folderId?: number }>,
userId: number,
isSuperuser: boolean,
): Promise<StagingFinalizeFileResult> {
try {
const override = overrideMap.get(row.id);
const libraryId = override?.libraryId ?? row.targetLibraryId ?? defaultLibraryId;
const folderId = override?.folderId ?? row.targetFolderId ?? defaultFolderId;
const libraryId = override?.libraryId ?? row.targetLibraryId ?? defaultLibraryId ?? null;
const folderId = override?.folderId ?? row.targetFolderId ?? defaultFolderId ?? null;
if (libraryId === null || folderId === null) {
return {
fileId: row.id,
fileName: row.fileName,
success: false,
message: 'Destination is not set for this file',
};
}
const library = await this.findLibraryOrFail(libraryId);
await this.libraryService.verifyUserAccess(userId, libraryId, isSuperuser);
@@ -110,7 +138,7 @@ export class StagingFinalizeService implements OnModuleInit {
return { fileId: row.id, fileName: row.fileName, success: false, message: 'A file with this name already exists at the target location' };
}
const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {};
const meta = normalizeFinalizeMetadata(row.selectedMetadata ?? row.embeddedMetadata ?? {});
const existingBookId = await this.findDuplicate(libraryId, meta);
if (existingBookId !== null) {
return {
@@ -174,19 +202,27 @@ export class StagingFinalizeService implements OnModuleInit {
fileIds: number[] | undefined,
selectAll: boolean | undefined,
excludedIds: number[] | undefined,
defaultLibraryId: number,
defaultLibraryId: number | undefined,
status?: string,
search?: string,
): Promise<{ fileId: number; fileName: string; newName: string }[]> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds) : (fileIds ?? []);
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search) : (fileIds ?? []);
if (!ids.length) return [];
const rows = await this.repo.findByIds(ids);
const library = await this.findLibraryOrFail(defaultLibraryId);
const pattern = library.fileNamingPattern ?? (await this.appSettings.getUploadPattern());
const appPattern = await this.appSettings.getUploadPattern();
const libraryIds = [...new Set(rows.map((row) => row.targetLibraryId ?? defaultLibraryId).filter((id): id is number => id != null))];
const libraryMap = libraryIds.length
? new Map((await this.db.select().from(libraries).where(inArray(libraries.id, libraryIds))).map((lib) => [lib.id, lib]))
: new Map<number, typeof libraries.$inferSelect>();
return rows.map((row) => {
const format = row.format ?? extname(row.fileName).toLowerCase().slice(1);
const meta = row.selectedMetadata ?? row.embeddedMetadata ?? {};
let newName = row.fileName;
const effectiveLibraryId = row.targetLibraryId ?? defaultLibraryId ?? null;
const libraryPattern = effectiveLibraryId !== null ? (libraryMap.get(effectiveLibraryId)?.fileNamingPattern ?? null) : null;
const pattern = libraryPattern ?? appPattern;
if (pattern) {
const tokens = this.buildPatternTokens(meta, row.fileName, format);
@@ -198,7 +234,7 @@ export class StagingFinalizeService implements OnModuleInit {
});
}
private async findDuplicate(libraryId: number, meta: StagingMetadata): Promise<number | null> {
private async findDuplicate(libraryId: number, meta: Pick<NormalizedFinalizeMetadata, 'isbn13' | 'isbn10' | 'title'>): Promise<number | null> {
const isbn = meta.isbn13 ?? meta.isbn10;
if (isbn) {
@@ -271,7 +307,15 @@ export class StagingFinalizeService implements OnModuleInit {
}
private async applyMetadata(bookId: number, row: StagingFileRow): Promise<void> {
if (row.coverPath) {
const meta = normalizeFinalizeMetadata(row.selectedMetadata ?? row.embeddedMetadata);
let selectedCoverApplied = false;
const selectedCoverUrl = meta.coverUrl;
if (selectedCoverUrl) {
selectedCoverApplied = await this.metadataService.downloadAndSaveCover(selectedCoverUrl, bookId);
}
if (!selectedCoverApplied && row.coverPath) {
try {
const bytes = await readFile(row.coverPath);
await this.metadataService.saveExtractedCoverBytes(bookId, bytes);
@@ -280,9 +324,6 @@ export class StagingFinalizeService implements OnModuleInit {
}
}
const meta = row.selectedMetadata ?? row.embeddedMetadata;
if (!meta) return;
await this.db
.update(bookMetadata)
.set({
@@ -301,14 +342,14 @@ export class StagingFinalizeService implements OnModuleInit {
})
.where(eq(bookMetadata.bookId, bookId));
if (meta.authors && meta.authors.length > 0) {
if (meta.authors.length > 0) {
await this.metadataService.replaceAuthors(
bookId,
meta.authors.map((name) => ({ name, sortName: null })),
);
}
if (meta.genres && meta.genres.length > 0) {
if (meta.genres.length > 0) {
await this.metadataService.replaceGenres(bookId, meta.genres);
}
}
@@ -347,3 +388,74 @@ async function safeUnlink(path: string): Promise<void> {
// file may already be deleted
}
}
function normalizeText(value: unknown, maxLength?: number): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
if (!maxLength) return trimmed;
return trimmed.slice(0, maxLength);
}
function normalizeInteger(value: unknown): number | null {
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseInt(value.trim(), 10) : NaN;
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return Math.floor(parsed);
}
function normalizeReal(value: unknown): number | null {
const parsed = typeof value === 'number' ? value : typeof value === 'string' ? Number.parseFloat(value.trim()) : NaN;
if (!Number.isFinite(parsed) || parsed <= 0) return null;
return parsed;
}
function normalizeIsbn(value: unknown, len: 10 | 13): string | null {
if (typeof value !== 'string') return null;
const compact = value.replace(/[\s-]+/g, '').toUpperCase();
if (!compact) return null;
if (len === 10) {
return /^[0-9]{9}[0-9X]$/.test(compact) ? compact : null;
}
return /^[0-9]{13}$/.test(compact) ? compact : null;
}
function normalizeLanguage(value: unknown): string | null {
const raw = normalizeText(value);
if (!raw) return null;
const primary = raw.split(/[;,/|]/)[0]?.trim() ?? '';
if (primary.length > 0 && primary.length <= 10) return primary;
const firstWord = primary.split(/\s+/)[0]?.trim() ?? '';
if (firstWord.length > 0 && firstWord.length <= 10) return firstWord;
return raw.length <= 10 ? raw : null;
}
function normalizeStringArray(value: unknown, maxLength: number): string[] {
if (!Array.isArray(value)) return [];
const normalized: string[] = [];
for (const item of value) {
if (typeof item !== 'string') continue;
const trimmed = item.trim();
if (!trimmed) continue;
normalized.push(trimmed.slice(0, maxLength));
}
return normalized;
}
function normalizeFinalizeMetadata(meta: StagingMetadata | null | undefined): NormalizedFinalizeMetadata {
return {
title: normalizeText(meta?.title, 1000),
subtitle: normalizeText(meta?.subtitle, 1000),
description: normalizeText(meta?.description),
isbn10: normalizeIsbn(meta?.isbn10, 10),
isbn13: normalizeIsbn(meta?.isbn13, 13),
publisher: normalizeText(meta?.publisher, 500),
publishedYear: normalizeInteger(meta?.publishedYear),
language: normalizeLanguage(meta?.language),
pageCount: normalizeInteger(meta?.pageCount),
seriesName: normalizeText(meta?.seriesName, 500),
seriesIndex: normalizeReal(meta?.seriesIndex),
authors: normalizeStringArray(meta?.authors, 500),
genres: normalizeStringArray(meta?.genres, 200),
coverUrl: normalizeText(meta?.coverUrl),
};
}
@@ -38,7 +38,9 @@ import {
BulkEditStagingDto,
BulkApplyFetchedDto,
BulkRetryFetchDto,
BulkSetTargetDto,
PreviewNamesDto,
SelectionSummaryDto,
} from './dto/index';
import { MAX_UPLOAD_BYTES } from '../upload/upload-storage.service';
@@ -125,17 +127,35 @@ export class StagingController {
@Post('files/discard')
@HttpCode(HttpStatus.NO_CONTENT)
bulkDiscard(@Body() dto: BulkDiscardDto) {
return this.service.bulkDiscard(dto.fileIds ?? [], dto.selectAll, dto.excludedIds);
return this.service.bulkDiscard(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search);
}
@Post('files/apply-fetched')
applyFetched(@Body() dto: BulkApplyFetchedDto) {
return this.service.bulkApplyFetched(dto.fileIds ?? [], dto.selectAll, dto.excludedIds);
return this.service.bulkApplyFetched(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search);
}
@Post('files/retry-fetch')
retryFetch(@Body() dto: BulkRetryFetchDto) {
return this.service.bulkRetryFetch(dto.fileIds, dto.selectAll, dto.excludedIds);
return this.service.bulkRetryFetch(dto.fileIds, dto.selectAll, dto.excludedIds, dto.status, dto.search);
}
@Post('files/set-target')
setTarget(@Body() dto: BulkSetTargetDto) {
return this.service.bulkSetTarget(
dto.fileIds ?? [],
dto.selectAll,
dto.excludedIds,
dto.targetLibraryId ?? null,
dto.targetFolderId ?? null,
dto.status,
dto.search,
);
}
@Post('files/selection-summary')
selectionSummary(@Body() dto: SelectionSummaryDto) {
return this.service.selectionSummary(dto.fileIds ?? [], dto.selectAll, dto.excludedIds, dto.status, dto.search);
}
@Post('files/bulk-edit')
@@ -147,12 +167,14 @@ export class StagingController {
dto.fields as Partial<StagingMetadata & Record<string, unknown>>,
dto.enabledFields,
dto.mergeArrays,
dto.status,
dto.search,
);
}
@Post('files/preview-names')
previewNames(@Body() dto: PreviewNamesDto) {
return this.finalizeService.previewNames(dto.fileIds, dto.selectAll, dto.excludedIds, dto.defaultLibraryId);
return this.finalizeService.previewNames(dto.fileIds, dto.selectAll, dto.excludedIds, dto.defaultLibraryId, dto.status, dto.search);
}
@Post('finalize')
@@ -167,6 +189,8 @@ export class StagingController {
dto.defaultLibraryId,
dto.defaultFolderId,
dto.overrides,
dto.status,
dto.search,
);
}
@@ -30,13 +30,7 @@ export class StagingRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findAll(opts: ListOptions): Promise<{ items: StagingFileRow[]; total: number }> {
const conditions: SQL[] = [];
if (opts.status === 'pending') {
conditions.push(inArray(stagingFiles.status, ['pending', 'extracting', 'fetching']));
} else if (opts.status) {
conditions.push(eq(stagingFiles.status, opts.status));
}
if (opts.search) conditions.push(ilike(stagingFiles.fileName, `%${opts.search}%`));
const conditions = this.buildSelectionConditions(opts.status, opts.search);
const where = conditions.length ? and(...conditions) : undefined;
@@ -91,8 +85,10 @@ export class StagingRepository {
await this.db.delete(stagingFiles).where(eq(stagingFiles.absolutePath, path));
}
async findAllIds(excludedIds?: number[]): Promise<number[]> {
const where = excludedIds?.length ? notInArray(stagingFiles.id, excludedIds) : undefined;
async findAllIds(excludedIds?: number[], status?: string, search?: string): Promise<number[]> {
const conditions = this.buildSelectionConditions(status, search);
if (excludedIds?.length) conditions.push(notInArray(stagingFiles.id, excludedIds));
const where = conditions.length ? and(...conditions) : undefined;
const rows = await this.db.select({ id: stagingFiles.id }).from(stagingFiles).where(where);
return rows.map((r) => r.id);
}
@@ -144,4 +140,15 @@ export class StagingRepository {
return { totalSizeBytes, byFormat };
}
private buildSelectionConditions(status?: string, search?: string): SQL[] {
const conditions: SQL[] = [];
if (status === 'pending') {
conditions.push(inArray(stagingFiles.status, ['pending', 'extracting', 'fetching']));
} else if (status) {
conditions.push(eq(stagingFiles.status, status));
}
if (search) conditions.push(ilike(stagingFiles.fileName, `%${search}%`));
return conditions;
}
}
+114 -7
View File
@@ -1,14 +1,22 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { unlink } from 'fs/promises';
import { eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import type { StagingFile, StagingFilesPage, StagingMetadata, StagingSummary } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { libraries, libraryFolders } from '../../db/schema';
import { StagingRepository, type ListOptions } from './staging.repository';
import { StagingIngestService } from './staging-ingest.service';
import type { StagingFileRow } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class StagingService {
constructor(
@Inject(DB) private readonly db: Db,
private readonly repo: StagingRepository,
private readonly ingestService: StagingIngestService,
) {}
@@ -36,6 +44,10 @@ export class StagingService {
const row = await this.repo.findById(id);
if (!row) throw new NotFoundException('Staging file not found');
if (data.targetLibraryId !== undefined || data.targetFolderId !== undefined) {
await this.assertValidTarget(data.targetLibraryId, data.targetFolderId);
}
const updateData = data.selectedMetadata !== undefined ? { ...data, metadataEditedAt: new Date() } : data;
const updated = await this.repo.update(id, updateData);
if (!updated) throw new NotFoundException('Staging file not found');
@@ -50,8 +62,8 @@ export class StagingService {
await this.repo.deleteById(id);
}
async bulkDiscard(fileIds: number[], selectAll?: boolean, excludedIds?: number[]): Promise<void> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds) : fileIds;
async bulkDiscard(fileIds: number[], selectAll?: boolean, excludedIds?: number[], status?: string, search?: string): Promise<void> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search) : fileIds;
if (!ids.length) return;
const rows = await this.repo.findByIds(ids);
@@ -68,8 +80,10 @@ export class StagingService {
fields: Partial<StagingMetadata & Record<string, unknown>>,
enabledFields: string[],
mergeArrays: boolean,
status?: string,
search?: string,
): Promise<{ total: number; updated: number; failed: number }> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds) : (fileIds ?? []);
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search) : (fileIds ?? []);
const rows = await this.repo.findByIds(ids);
let updated = 0;
let failed = 0;
@@ -104,8 +118,10 @@ export class StagingService {
fileIds: number[],
selectAll?: boolean,
excludedIds?: number[],
status?: string,
search?: string,
): Promise<{ total: number; applied: number; skipped: number; skippedEdited: number }> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds) : fileIds;
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search) : fileIds;
if (!ids.length) return { total: 0, applied: 0, skipped: 0, skippedEdited: 0 };
const rows = await this.repo.findByIds(ids);
@@ -129,8 +145,14 @@ export class StagingService {
return { total: rows.length, applied, skipped, skippedEdited };
}
async bulkRetryFetch(fileIds: number[] | undefined, selectAll?: boolean, excludedIds?: number[]): Promise<{ total: number; queued: number }> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds) : (fileIds ?? []);
async bulkRetryFetch(
fileIds: number[] | undefined,
selectAll?: boolean,
excludedIds?: number[],
status?: string,
search?: string,
): Promise<{ total: number; queued: number }> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search) : (fileIds ?? []);
if (!ids.length) return { total: 0, queued: 0 };
const rows = await this.repo.findByIds(ids);
@@ -142,6 +164,60 @@ export class StagingService {
return { total: rows.length, queued: errorRows.length };
}
async bulkSetTarget(
fileIds: number[],
selectAll?: boolean,
excludedIds?: number[],
targetLibraryId?: number | null,
targetFolderId?: number | null,
status?: string,
search?: string,
): Promise<{ total: number; updated: number; failed: number }> {
await this.assertValidTarget(targetLibraryId, targetFolderId);
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search) : fileIds;
if (!ids.length) return { total: 0, updated: 0, failed: 0 };
const rows = await this.repo.findByIds(ids);
let updated = 0;
let failed = 0;
for (const row of rows) {
try {
await this.repo.update(row.id, { targetLibraryId: targetLibraryId ?? null, targetFolderId: targetFolderId ?? null });
updated++;
} catch {
failed++;
}
}
return { total: rows.length, updated, failed };
}
async selectionSummary(
fileIds: number[],
selectAll?: boolean,
excludedIds?: number[],
status?: string,
search?: string,
): Promise<{ total: number; withDestination: number; withoutDestination: number }> {
const ids = selectAll ? await this.repo.findAllIds(excludedIds, status, search) : fileIds;
if (!ids.length) return { total: 0, withDestination: 0, withoutDestination: 0 };
const rows = await this.repo.findByIds(ids);
const candidates = rows.filter((row) => row.targetLibraryId !== null && row.targetFolderId !== null);
const folderIds = [...new Set(candidates.map((row) => row.targetFolderId!).filter((id): id is number => id != null))];
const folderRows = folderIds.length
? await this.db
.select({ id: libraryFolders.id, libraryId: libraryFolders.libraryId })
.from(libraryFolders)
.where(inArray(libraryFolders.id, folderIds))
: [];
const folderById = new Map(folderRows.map((row) => [row.id, row.libraryId]));
const withDestination = candidates.filter((row) => folderById.get(row.targetFolderId!) === row.targetLibraryId).length;
return { total: rows.length, withDestination, withoutDestination: rows.length - withDestination };
}
async getSummary(): Promise<StagingSummary> {
return this.repo.countsByStatus();
}
@@ -158,6 +234,37 @@ export class StagingService {
await safeUnlink(thumbPath);
}
}
private async assertValidTarget(targetLibraryId?: number | null, targetFolderId?: number | null): Promise<void> {
const hasLibrary = targetLibraryId !== undefined;
const hasFolder = targetFolderId !== undefined;
if (!hasLibrary && !hasFolder) return;
const libraryId = targetLibraryId ?? null;
const folderId = targetFolderId ?? null;
if ((libraryId === null) !== (folderId === null)) {
throw new BadRequestException('targetLibraryId and targetFolderId must both be set or both be null');
}
if (libraryId === null && folderId === null) return;
const resolvedLibraryId = libraryId as number;
const resolvedFolderId = folderId as number;
const [library] = await this.db.select({ id: libraries.id }).from(libraries).where(eq(libraries.id, resolvedLibraryId)).limit(1);
if (!library) throw new BadRequestException('Destination library not found');
const [folder] = await this.db
.select({ id: libraryFolders.id, libraryId: libraryFolders.libraryId })
.from(libraryFolders)
.where(eq(libraryFolders.id, resolvedFolderId))
.limit(1);
if (!folder) throw new BadRequestException('Destination folder not found');
if (folder.libraryId !== resolvedLibraryId) {
throw new BadRequestException('Destination folder does not belong to destination library');
}
}
}
function toDto(row: StagingFileRow): StagingFile {