mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(scanner): improve staging metadata and destination finalize flow
This commit is contained in:
@@ -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 }]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user