feat: file upload with configurable naming patterns

This commit is contained in:
neon
2026-02-25 17:17:43 -06:00
parent a6ef586dbe
commit ede3bd926a
30 changed files with 1813 additions and 3080 deletions
+20 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { ArrowLeft, Search, Palette, X, KeyRound, Settings, LogOut } from 'lucide-vue-next'
import { ArrowLeft, Search, Palette, Upload, X, KeyRound, Settings, LogOut } from 'lucide-vue-next'
import { useRouter } from 'vue-router'
import { SidebarTrigger } from '@/components/ui/sidebar'
import { Separator } from '@/components/ui/separator'
@@ -23,11 +23,16 @@ import BookCoverImage from '@/features/book/components/BookCoverImage.vue'
import { useAuth } from '@/features/auth/composables/useAuth'
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import BookUploadModal from '@/features/library/components/BookUploadModal.vue'
const router = useRouter()
const { user, logout } = useAuth()
const { open: openSettings } = useSettingsDrawer()
const { open: openChangePassword } = useChangePasswordDialog()
const { hasPermission } = usePermissions()
const uploadOpen = ref(false)
const searchFocused = ref(false)
const mobileSearchOpen = ref(false)
@@ -174,6 +179,18 @@ function navigateToResult(result: GlobalSearchResult) {
<Search :size="15" />
</Button>
<!-- Upload button -->
<Button
v-if="hasPermission('library_upload')"
variant="ghost"
size="icon"
class="hidden md:flex h-8 w-8 text-muted-foreground hover:text-foreground"
title="Upload books"
@click="uploadOpen = true"
>
<Upload :size="15" />
</Button>
<!-- Desktop: appearance settings popover -->
<Popover>
<PopoverTrigger as-child>
@@ -237,4 +254,6 @@ function navigateToResult(result: GlobalSearchResult) {
</div>
</template>
</header>
<BookUploadModal v-if="uploadOpen" @close="uploadOpen = false" @uploaded="uploadOpen = false" />
</template>
@@ -0,0 +1,243 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { CheckCircle2, FileUp, Loader2, Upload, X, XCircle } from 'lucide-vue-next'
import { api } from '@/lib/api'
import type { Library } from '@projectx/types'
import { SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT, useBookUpload } from '../composables/useBookUpload'
import { useLibraries } from '../composables/useLibraries'
const props = defineProps<{
libraryId?: number
}>()
const emit = defineEmits<{
close: []
uploaded: [bookIds: number[]]
}>()
const { libraries, fetchLibraries } = useLibraries()
const selectedLibraryId = ref<number | undefined>(props.libraryId)
const folders = ref<Library['folders']>([])
const selectedFolderId = ref<number | undefined>(undefined)
const isDragging = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const { files, pendingCount, isUploading, doneCount, errorCount, uploadedBookIds, addFiles, removeFile, reset, startUpload } = useBookUpload()
const canUpload = computed(() => pendingCount.value > 0 && !isUploading.value && selectedLibraryId.value !== undefined)
const hasFiles = computed(() => files.value.length > 0)
const allDone = computed(() => hasFiles.value && files.value.every((f) => f.status === 'done' || f.status === 'error'))
async function loadFolders(libraryId: number) {
const res = await api(`/api/libraries/${libraryId}`)
if (res.ok) {
const data: Library = await res.json()
folders.value = data.folders ?? []
selectedFolderId.value = folders.value[0]?.id
}
}
watch(selectedLibraryId, (id) => {
folders.value = []
selectedFolderId.value = undefined
if (id !== undefined) loadFolders(id)
})
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') handleClose()
}
onMounted(async () => {
if (!props.libraryId) {
if (!libraries.value.length) await fetchLibraries()
if (selectedLibraryId.value !== undefined) loadFolders(selectedLibraryId.value)
} else {
loadFolders(props.libraryId)
}
document.addEventListener('keydown', onKeydown)
})
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
function handleClose() {
if (uploadedBookIds.value.length > 0) emit('uploaded', uploadedBookIds.value)
emit('close')
}
function onDragOver(e: DragEvent) {
e.preventDefault()
isDragging.value = true
}
function onDragLeave() {
isDragging.value = false
}
function onDrop(e: DragEvent) {
e.preventDefault()
isDragging.value = false
const dropped = Array.from(e.dataTransfer?.files ?? [])
if (dropped.length > 0) addFiles(dropped)
}
function openFilePicker() {
fileInputRef.value?.click()
}
function onFileInputChange(e: Event) {
const input = e.target as HTMLInputElement
const picked = Array.from(input.files ?? [])
if (picked.length > 0) addFiles(picked)
input.value = ''
}
async function handleUpload() {
if (selectedLibraryId.value === undefined) return
await startUpload(selectedLibraryId.value, selectedFolderId.value)
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
</script>
<template>
<Teleport to="body">
<div class="fixed inset-0 z-[70] flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/50 backdrop-blur-[2px]" @click="handleClose" />
<div
class="relative flex flex-col w-full max-w-lg bg-background rounded-xl shadow-2xl border border-border overflow-hidden"
style="max-height: min(90vh, 640px)"
>
<!-- Header -->
<div class="flex items-center justify-between px-5 py-4 border-b border-border shrink-0">
<span class="text-sm font-semibold text-foreground">Upload Books</span>
<button
class="flex items-center justify-center w-7 h-7 rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="handleClose"
>
<X :size="15" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-5 flex flex-col gap-4">
<!-- Library selector (only when not pre-scoped to a library) -->
<div v-if="!libraryId" class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-muted-foreground">Library</label>
<select
v-model="selectedLibraryId"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option :value="undefined" disabled>Select a library...</option>
<option v-for="lib in libraries" :key="lib.id" :value="lib.id">{{ lib.name }}</option>
</select>
</div>
<!-- Folder selector (only shown when library has multiple folders) -->
<div v-if="folders.length > 1" class="flex flex-col gap-1.5">
<label class="text-xs font-medium text-muted-foreground">Target folder</label>
<select
v-model="selectedFolderId"
class="h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option v-for="folder in folders" :key="folder.id" :value="folder.id">{{ folder.path }}</option>
</select>
</div>
<!-- Dropzone -->
<div
class="relative flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-8 text-center transition-colors cursor-pointer"
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-border/80 hover:bg-muted/30'"
@dragover="onDragOver"
@dragleave="onDragLeave"
@drop="onDrop"
@click="openFilePicker"
>
<div class="flex items-center justify-center w-10 h-10 rounded-full bg-muted">
<FileUp :size="20" class="text-muted-foreground" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Drop files here or click to browse</p>
<p class="text-xs text-muted-foreground mt-0.5">{{ SUPPORTED_FORMATS.join(', ') }} - up to 500 MB each</p>
</div>
<input ref="fileInputRef" type="file" multiple :accept="SUPPORTED_FORMATS_ACCEPT" class="sr-only" @change="onFileInputChange" />
</div>
<!-- File list -->
<div v-if="hasFiles" class="flex flex-col gap-1">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium text-muted-foreground">{{ files.length }} file{{ files.length === 1 ? '' : 's' }}</span>
<button v-if="!isUploading" class="text-xs text-muted-foreground hover:text-foreground transition-colors" @click="reset">
Clear all
</button>
</div>
<div v-for="item in files" :key="item.id" class="flex items-center gap-3 px-3 py-2.5 rounded-lg bg-muted/40 border border-border/40">
<!-- Status icon -->
<div class="shrink-0">
<Loader2 v-if="item.status === 'uploading'" :size="16" class="text-primary animate-spin" />
<CheckCircle2 v-else-if="item.status === 'done'" :size="16" class="text-green-500" />
<XCircle v-else-if="item.status === 'error'" :size="16" class="text-destructive" />
<div v-else class="w-4 h-4 rounded-full border-2 border-border" />
</div>
<!-- File info -->
<div class="flex-1 min-w-0">
<p class="text-xs font-medium text-foreground truncate">{{ item.file.name }}</p>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[11px] text-muted-foreground">{{ formatBytes(item.file.size) }}</span>
<span v-if="item.status === 'error'" class="text-[11px] text-destructive truncate">{{ item.error }}</span>
<span v-else-if="item.status === 'uploading'" class="text-[11px] text-primary">{{ item.progress }}%</span>
<span v-else-if="item.status === 'done'" class="text-[11px] text-green-500">Done</span>
</div>
<!-- Progress bar -->
<div v-if="item.status === 'uploading'" class="mt-1.5 h-0.5 rounded-full bg-muted overflow-hidden">
<div class="h-full bg-primary rounded-full transition-all duration-150" :style="{ width: `${item.progress}%` }" />
</div>
</div>
<!-- Remove button -->
<button
v-if="item.status !== 'uploading'"
class="shrink-0 flex items-center justify-center w-5 h-5 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted transition-colors"
@click="removeFile(item.id)"
>
<X :size="12" />
</button>
</div>
</div>
</div>
<!-- Footer -->
<div class="shrink-0 px-5 py-4 border-t border-border flex items-center justify-between gap-3">
<!-- Summary -->
<span v-if="allDone" class="text-xs text-muted-foreground">
{{ doneCount }} uploaded<template v-if="errorCount > 0">, {{ errorCount }} failed</template>
</span>
<span v-else class="text-xs text-muted-foreground" />
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 rounded-md border border-border text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
@click="handleClose"
>
{{ allDone ? 'Close' : 'Cancel' }}
</button>
<button
class="flex items-center gap-1.5 px-4 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 transition-opacity disabled:opacity-50"
:disabled="!canUpload"
@click="handleUpload"
>
<Upload :size="13" />
Upload{{ pendingCount > 0 ? ` (${pendingCount})` : '' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,144 @@
import { computed, ref } from 'vue'
import { getAccessToken } from '@/lib/api'
import type { UploadResult } from '@projectx/types'
export const SUPPORTED_FORMATS = ['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2']
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024 // 500 MB
export type FileUploadStatus = 'pending' | 'uploading' | 'done' | 'error'
export interface FileUploadItem {
id: string
file: File
status: FileUploadStatus
progress: number
error?: string
bookId?: number
}
const UPLOAD_CONCURRENCY = 3
function validateFile(file: File): string | null {
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!SUPPORTED_FORMATS.includes(ext)) {
return `Unsupported type .${ext}. Allowed: ${SUPPORTED_FORMATS.join(', ')}`
}
if (file.size > MAX_UPLOAD_BYTES) {
return `File exceeds the 500 MB limit`
}
return null
}
function uploadSingle(item: FileUploadItem, url: string): Promise<void> {
return new Promise((resolve) => {
const formData = new FormData()
formData.append('file', item.file)
const xhr = new XMLHttpRequest()
xhr.open('POST', url)
const token = getAccessToken()
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
item.progress = Math.round((e.loaded / e.total) * 100)
}
}
xhr.onload = () => {
if (xhr.status === 201) {
const result: UploadResult = JSON.parse(xhr.responseText)
item.status = 'done'
item.progress = 100
item.bookId = result.bookId
} else {
item.status = 'error'
try {
const body = JSON.parse(xhr.responseText)
item.error = body.message ?? 'Upload failed'
} catch {
item.error = `Upload failed (${xhr.status})`
}
}
resolve()
}
xhr.onerror = () => {
item.status = 'error'
item.error = 'Network error'
resolve()
}
item.status = 'uploading'
xhr.send(formData)
})
}
export function useBookUpload() {
const files = ref<FileUploadItem[]>([])
const pendingCount = computed(() => files.value.filter((f) => f.status === 'pending').length)
const isUploading = computed(() => files.value.some((f) => f.status === 'uploading'))
const doneCount = computed(() => files.value.filter((f) => f.status === 'done').length)
const errorCount = computed(() => files.value.filter((f) => f.status === 'error').length)
const uploadedBookIds = computed(() => files.value.filter((f) => f.bookId !== undefined).map((f) => f.bookId!))
function addFiles(incoming: File[]) {
for (const file of incoming) {
// Skip duplicates already in the queue
if (files.value.some((f) => f.file.name === file.name && f.file.size === file.size)) continue
const error = validateFile(file) ?? undefined
files.value.push({
id: `${file.name}-${file.size}-${file.lastModified}`,
file,
status: error ? 'error' : 'pending',
progress: 0,
error,
})
}
}
function removeFile(id: string) {
files.value = files.value.filter((f) => f.id !== id)
}
function reset() {
files.value = []
}
async function startUpload(libraryId: number, folderId?: number) {
const pending = files.value.filter((f) => f.status === 'pending')
if (pending.length === 0) return
const baseUrl = `/api/libraries/${libraryId}/upload`
const url = folderId !== undefined ? `${baseUrl}?folderId=${folderId}` : baseUrl
// Process pending items with a concurrency limit of UPLOAD_CONCURRENCY
let index = 0
async function runNext(): Promise<void> {
const item = pending[index++]
if (!item) return
await uploadSingle(item, url)
await runNext()
}
const workers = Array.from({ length: Math.min(UPLOAD_CONCURRENCY, pending.length) }, () => runNext())
await Promise.all(workers)
}
return {
files,
pendingCount,
isUploading,
doneCount,
errorCount,
uploadedBookIds,
addFiles,
removeFile,
reset,
startUpload,
}
}
@@ -0,0 +1,224 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { Check, ChevronDown, ChevronUp, FileText, Loader2, RotateCcw, Save } from 'lucide-vue-next'
import { DEFAULT_UPLOAD_PATTERN, PATTERN_TOKENS } from '@projectx/types'
import { useFileNamingPattern } from './composables/useFileNamingPattern'
const {
globalPattern,
globalError,
libraries,
loadingGlobal,
savingGlobal,
savingLibraryId,
fetchGlobalPattern,
fetchLibraries,
onGlobalPatternInput,
saveGlobalPattern,
saveLibraryPattern,
clearLibraryPattern,
getEffectivePreview,
previewPath,
} = useFileNamingPattern()
const referenceOpen = ref(true)
const EXAMPLE_PATTERN_CONDITIONAL = '{authors}/<{series}/><{seriesIndex}. >{title}'
const EXAMPLE_PATTERN_ELSE = '<{series}|Standalone>/{title}'
onMounted(async () => {
await Promise.all([fetchGlobalPattern(), fetchLibraries()])
})
</script>
<template>
<div class="px-5 py-6 sm:px-10 sm:py-8 max-w-3xl mx-auto space-y-8">
<!-- Page header -->
<div>
<h2 class="font-serif font-semibold text-foreground text-2xl tracking-tight">File Naming</h2>
<p class="mt-1 text-sm text-muted-foreground">
Control how uploaded files are named and organized on disk. Patterns apply when a file is uploaded.
</p>
</div>
<!-- Global default -->
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Default Pattern</p>
<div class="border border-border rounded-lg bg-card px-5 py-5 space-y-4">
<p class="text-xs text-muted-foreground leading-relaxed">
Applied to all libraries unless overridden. Leave blank to use the built-in default layout.
</p>
<div class="space-y-1.5">
<div class="flex gap-2">
<input
:value="globalPattern"
type="text"
:placeholder="DEFAULT_UPLOAD_PATTERN"
class="flex-1 h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring"
:class="globalError ? 'border-destructive focus:ring-destructive' : ''"
:disabled="loadingGlobal"
@input="onGlobalPatternInput(($event.target as HTMLInputElement).value)"
/>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-md border border-border bg-background text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="savingGlobal || !!globalError || loadingGlobal"
@click="saveGlobalPattern"
>
<Loader2 v-if="savingGlobal" :size="13" class="animate-spin" />
<Save v-else :size="13" />
Save
</button>
</div>
<p v-if="globalError" class="text-xs text-destructive">{{ globalError }}</p>
<p class="text-xs text-muted-foreground font-mono">
Preview: <span class="text-foreground">{{ previewPath(globalPattern) }}</span>
</p>
</div>
</div>
</div>
<!-- Per-library overrides -->
<div>
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-3">Library Overrides</p>
<div class="border border-border rounded-lg bg-card divide-y divide-border">
<div v-if="libraries.length === 0" class="px-5 py-8 text-center text-sm text-muted-foreground">No libraries configured.</div>
<div v-for="lib in libraries" :key="lib.id" class="px-5 py-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<FileText :size="14" class="text-muted-foreground shrink-0" />
<span class="text-sm font-medium text-foreground truncate">{{ lib.name }}</span>
<span v-if="lib.fileNamingPattern" class="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/10 text-primary">
custom
</span>
<span v-else class="shrink-0 text-[10px] font-medium px-1.5 py-0.5 rounded bg-muted text-muted-foreground"> using default </span>
</div>
<button
v-if="lib.fileNamingPattern"
class="shrink-0 flex items-center gap-1 h-7 px-2 rounded-md text-xs text-muted-foreground hover:text-foreground hover:bg-muted transition-colors disabled:opacity-50"
title="Clear override"
:disabled="savingLibraryId === lib.id"
@click="clearLibraryPattern(lib)"
>
<RotateCcw :size="11" />
Reset
</button>
</div>
<div class="space-y-1.5">
<div class="flex gap-2">
<input
:value="lib.fileNamingPattern ?? ''"
type="text"
:placeholder="globalPattern || DEFAULT_UPLOAD_PATTERN"
class="flex-1 h-8 rounded-md border border-input bg-background px-2.5 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring"
@input="lib.fileNamingPattern = ($event.target as HTMLInputElement).value || null"
/>
<button
class="flex items-center gap-1.5 h-8 px-3 rounded-md border border-border bg-background text-sm text-foreground hover:bg-muted transition-colors disabled:opacity-50"
:disabled="savingLibraryId === lib.id"
@click="saveLibraryPattern(lib)"
>
<Loader2 v-if="savingLibraryId === lib.id" :size="13" class="animate-spin" />
<Check v-else :size="13" />
Save
</button>
</div>
<p class="text-xs text-muted-foreground font-mono">
Preview: <span class="text-foreground">{{ getEffectivePreview(lib) }}</span>
</p>
</div>
</div>
</div>
</div>
<!-- Placeholder reference (collapsible) -->
<div class="border border-border rounded-lg bg-card overflow-hidden">
<button
class="w-full flex items-center justify-between px-5 py-4 text-sm font-medium text-foreground hover:bg-muted/50 transition-colors"
@click="referenceOpen = !referenceOpen"
>
<span>Placeholder Reference</span>
<ChevronDown v-if="!referenceOpen" :size="15" class="text-muted-foreground" />
<ChevronUp v-else :size="15" class="text-muted-foreground" />
</button>
<div v-if="referenceOpen" class="px-5 pb-5 space-y-5 border-t border-border">
<!-- Tokens -->
<div class="pt-4 space-y-2">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Available tokens</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
<div v-for="t in PATTERN_TOKENS" :key="t.token" class="flex items-center gap-2">
<code class="text-xs bg-muted px-1.5 py-0.5 rounded text-foreground shrink-0">{{ '{' + t.token + '}' }}</code>
<span class="text-xs text-muted-foreground">{{ t.description }}</span>
</div>
</div>
</div>
<!-- Modifiers -->
<div class="space-y-2">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Modifiers</p>
<p class="text-xs text-muted-foreground">
Append <code class="bg-muted px-1 rounded">:modifier</code> inside a token. Example:
<code class="bg-muted px-1 rounded">{'{authors:sort}'}</code>
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-1.5">
<div class="flex items-center gap-2">
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">:first</code><span class="text-xs text-muted-foreground">First value only</span>
</div>
<div class="flex items-center gap-2">
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">:sort</code><span class="text-xs text-muted-foreground">Last, First format</span>
</div>
<div class="flex items-center gap-2">
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">:initial</code><span class="text-xs text-muted-foreground">First letter only</span>
</div>
<div class="flex items-center gap-2">
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">:upper</code><span class="text-xs text-muted-foreground">UPPERCASE</span>
</div>
<div class="flex items-center gap-2">
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">:lower</code><span class="text-xs text-muted-foreground">lowercase</span>
</div>
</div>
</div>
<!-- Optional blocks -->
<div class="space-y-2">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Optional blocks</p>
<p class="text-xs text-muted-foreground leading-relaxed">
Wrap in <code class="bg-muted px-1 rounded">&lt;...&gt;</code> to omit a segment when all its tokens are empty. Use
<code class="bg-muted px-1 rounded">|</code> for a fallback: <code class="bg-muted px-1 rounded">&lt;primary|fallback&gt;</code>.
</p>
<div class="rounded-md bg-muted/50 px-4 py-3 space-y-2 text-xs font-mono">
<div>
<span class="text-muted-foreground">Pattern: </span><span>{{ EXAMPLE_PATTERN_CONDITIONAL }}</span>
</div>
<div>
<span class="text-muted-foreground">With series: </span>
<span class="text-foreground">Patrick Rothfuss/The Kingkiller Chronicle/01. The Name of the Wind.epub</span>
</div>
<div><span class="text-muted-foreground">Without: </span><span class="text-foreground">Andy Weir/Project Hail Mary.epub</span></div>
</div>
<div class="rounded-md bg-muted/50 px-4 py-3 space-y-2 text-xs font-mono">
<div>
<span class="text-muted-foreground">Pattern: </span><span>{{ EXAMPLE_PATTERN_ELSE }}</span>
</div>
<div>
<span class="text-muted-foreground">With series: </span>
<span class="text-foreground">The Kingkiller Chronicle/The Name of the Wind.epub</span>
</div>
<div><span class="text-muted-foreground">Without: </span><span class="text-foreground">Standalone/Project Hail Mary.epub</span></div>
</div>
</div>
<!-- Folder-only note -->
<div class="space-y-1">
<p class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Folder-only mode</p>
<p class="text-xs text-muted-foreground leading-relaxed">
End the pattern with <code class="bg-muted px-1 rounded">/</code> to specify a folder only. The original filename is preserved inside it.
</p>
</div>
</div>
</div>
</div>
</template>
@@ -18,6 +18,7 @@ import {
Rss,
Tablet,
Wrench,
FolderPen,
} from 'lucide-vue-next'
import { useSettingsDrawer } from '@/composables/useSettingsDrawer'
import { usePermissions } from '@/features/auth/composables/usePermissions'
@@ -32,6 +33,7 @@ import AboutSettings from './AboutSettings.vue'
import UsersPage from '@/features/admin/UsersPage.vue'
import RolesPage from '@/features/admin/RolesPage.vue'
import PermissionsPage from '@/features/admin/PermissionsPage.vue'
import FileNamingSettings from './FileNamingSettings.vue'
import OidcSettings from './OidcSettings.vue'
import OpdsSettings from './OpdsSettings.vue'
import KoboSettings from './KoboSettings.vue'
@@ -56,6 +58,7 @@ type SectionId =
| 'roles'
| 'permissions'
| 'oidc'
| 'file-naming'
| 'maintenance'
const navGroups = computed(() => {
@@ -91,6 +94,7 @@ const navGroups = computed(() => {
}
if (su || perms.includes('manage_app_settings')) {
adminItems.push({ id: 'oidc', label: 'OIDC / SSO', icon: LogIn, component: OidcSettings })
adminItems.push({ id: 'file-naming', label: 'File Naming', icon: FolderPen, component: FileNamingSettings })
adminItems.push({ id: 'maintenance', label: 'Maintenance', icon: Wrench, component: MaintenanceSettings })
}
if (adminItems.length) {
@@ -0,0 +1,106 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { EXAMPLE_PATTERN_METADATA, resolveUploadPath, validatePattern } from '@projectx/types'
import type { Library } from '@projectx/types'
import { useLibraries } from '@/features/library/composables/useLibraries'
export function previewPath(pattern: string): string {
if (!pattern) return '/neuromancer.epub'
const resolved = resolveUploadPath(pattern, EXAMPLE_PATTERN_METADATA, 'epub')
if (!resolved) return '/the_name_of_the_wind.epub'
return resolved.startsWith('/') ? resolved : `/${resolved}`
}
export function useFileNamingPattern() {
const globalPattern = ref('')
const globalError = ref('')
const loadingGlobal = ref(false)
const savingGlobal = ref(false)
const savingLibraryId = ref<number | null>(null)
const { libraries, fetchLibraries } = useLibraries()
async function fetchGlobalPattern() {
loadingGlobal.value = true
try {
const res = await api('/api/app-settings/upload-pattern')
if (res.ok) {
const data: { pattern: string } = await res.json()
globalPattern.value = data.pattern
}
} finally {
loadingGlobal.value = false
}
}
function onGlobalPatternInput(value: string) {
globalPattern.value = value
globalError.value = value && !validatePattern(value) ? 'Pattern contains invalid characters' : ''
}
async function saveGlobalPattern() {
if (globalError.value) return
savingGlobal.value = true
try {
const res = await api('/api/app-settings/upload-pattern', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: globalPattern.value }),
})
if (res.ok) {
toast.success('Default pattern saved')
} else {
toast.error('Failed to save pattern')
}
} finally {
savingGlobal.value = false
}
}
async function saveLibraryPattern(library: Library) {
savingLibraryId.value = library.id
try {
const res = await api(`/api/libraries/${library.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileNamingPattern: library.fileNamingPattern ?? null }),
})
if (res.ok) {
toast.success(`Pattern saved for "${library.name}"`)
} else {
toast.error('Failed to save library pattern')
}
} finally {
savingLibraryId.value = null
}
}
async function clearLibraryPattern(library: Library) {
library.fileNamingPattern = null
await saveLibraryPattern(library)
}
function getEffectivePreview(library: Library): string {
return previewPath(library.fileNamingPattern ?? globalPattern.value)
}
return {
globalPattern,
globalError,
libraries,
loadingGlobal,
savingGlobal,
savingLibraryId,
fetchGlobalPattern,
fetchLibraries,
onGlobalPatternInput,
saveGlobalPattern,
saveLibraryPattern,
clearLibraryPattern,
getEffectivePreview,
previewPath,
}
}
+2
View File
@@ -1,9 +1,11 @@
export * from "./auth";
export * from "./dashboard";
export * from "./book";
export * from "./upload";
export * from "./collection";
export * from "./library";
export * from "./metadata-fetch";
export * from "./pattern-resolver";
export * from "./query";
export * from "./reader-settings";
export * from "./scanner";
+1
View File
@@ -21,6 +21,7 @@ export interface Library {
excludePatterns: string[];
markAsFinishedSecondsRemaining?: number | null;
markAsFinishedPercentComplete?: number | null;
fileNamingPattern?: string | null;
folders: LibraryFolder[];
bookCount?: number;
createdAt: string;
+118
View File
@@ -0,0 +1,118 @@
export const DEFAULT_UPLOAD_PATTERN = '<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. >{title}< ({year})>';
export const EXAMPLE_PATTERN_METADATA: Record<string, string> = {
title: 'Neuromancer',
subtitle: '20th Anniversary Edition',
authors: 'William Gibson',
year: '1984',
series: 'Sprawl',
seriesIndex: '01',
language: 'English',
publisher: 'Ace Books',
isbn: '9780441569595',
originalFilename: 'neuromancer',
extension: 'epub',
};
export const PATTERN_TOKENS = [
{ token: 'title', description: 'Book title' },
{ token: 'subtitle', description: 'Book subtitle' },
{ token: 'authors', description: 'Author(s), comma-separated' },
{ token: 'year', description: 'Publication year' },
{ token: 'series', description: 'Series name' },
{ token: 'seriesIndex', description: 'Series index (zero-padded)' },
{ token: 'publisher', description: 'Publisher' },
{ token: 'isbn', description: 'ISBN-13' },
{ token: 'language', description: 'Language' },
{ token: 'originalFilename', description: 'Original filename (without extension)' },
{ token: 'extension', description: 'File extension (without dot)' },
] as const;
export type PatternToken = (typeof PATTERN_TOKENS)[number]['token'];
const MODIFIER_PLACEHOLDER_REGEX = /\{([^}:]+)(?::([^}]+))?}/g;
export function applyModifier(value: string, modifier: string, fieldName: string): string {
if (!value) return value;
switch (modifier) {
case 'first':
return value.split(', ')[0].trim();
case 'sort': {
const first = value.split(', ')[0].trim();
const lastSpace = first.lastIndexOf(' ');
return lastSpace > 0 ? `${first.substring(lastSpace + 1)}, ${first.substring(0, lastSpace)}` : first;
}
case 'initial': {
let target = value;
if (fieldName === 'authors') {
const firstAuthor = value.split(', ')[0].trim();
const lastSpace = firstAuthor.lastIndexOf(' ');
target = lastSpace > 0 ? firstAuthor.substring(lastSpace + 1) : firstAuthor;
}
return target.charAt(0).toUpperCase();
}
case 'upper':
return value.toUpperCase();
case 'lower':
return value.toLowerCase();
default:
return value;
}
}
function resolveModifierPlaceholders(block: string, values: Record<string, string>): string {
return block.replace(MODIFIER_PLACEHOLDER_REGEX, (_, fieldName: string, modifier?: string) => {
const val = values[fieldName] ?? '';
return modifier ? applyModifier(val, modifier, fieldName) : val;
});
}
function checkAllPlaceholdersPresent(block: string, values: Record<string, string>): boolean {
const matches = [...block.matchAll(MODIFIER_PLACEHOLDER_REGEX)];
return matches.every((m) => values[m[1]]?.trim());
}
export function replacePlaceholders(pattern: string, values: Record<string, string>): string {
// Handle optional blocks with else clause: <primary|fallback>
pattern = pattern.replace(/<([^<>]+)>/g, (_, blockContent: string) => {
const pipeIndex = blockContent.indexOf('|');
const primary = pipeIndex >= 0 ? blockContent.substring(0, pipeIndex) : blockContent;
const fallback = pipeIndex >= 0 ? blockContent.substring(pipeIndex + 1) : null;
if (checkAllPlaceholdersPresent(primary, values)) {
return resolveModifierPlaceholders(primary, values);
}
return fallback != null ? resolveModifierPlaceholders(fallback, values) : '';
});
return resolveModifierPlaceholders(pattern, values).trim();
}
export function validatePattern(pattern: string): boolean {
const validPatternRegex = /^[\w\s\-{}\[\]/<>().,:'"|]*$/;
return validPatternRegex.test(pattern);
}
/**
* Resolves a file naming pattern to a relative path (no leading slash).
* - Pattern ending with '/' → folder path; original filename is used as the file stem.
* - Otherwise → the resolved string is the full relative path; extension is appended if missing.
*
* Returns null if the pattern resolves to an empty string.
*/
export function resolveUploadPath(pattern: string, values: Record<string, string>, ext: string): string | null {
const resolved = replacePlaceholders(pattern, values);
if (!resolved) return null;
const dotExt = ext.startsWith('.') ? ext : `.${ext}`;
if (resolved.endsWith('/')) {
const filename = (values['originalFilename'] ?? 'upload') + dotExt;
return resolved + filename;
}
const lastSegment = resolved.split('/').pop() ?? '';
const hasExt = /\.[a-z0-9]{2,5}$/i.test(lastSegment);
return hasExt ? resolved : resolved + dotExt;
}
+6
View File
@@ -0,0 +1,6 @@
export type UploadResult = {
bookId: number;
filename: string;
format: string;
sizeBytes: number;
};
+2
View File
@@ -31,6 +31,7 @@ import { RecommendationModule } from './modules/recommendation/recommendation.mo
import { RoleModule } from './modules/role/role.module';
import { ScannerModule } from './modules/scanner/scanner.module';
import { SeedModule } from './modules/seed/seed.module';
import { UploadModule } from './modules/upload/upload.module';
import { UserModule } from './modules/user/user.module';
@Module({
@@ -56,6 +57,7 @@ import { UserModule } from './modules/user/user.module';
LensModule,
ScannerModule,
MetadataModule,
UploadModule,
MetadataFetchModule,
RecommendationModule,
KoboModule,
@@ -144,6 +144,7 @@ CREATE TABLE "libraries" (
"exclude_patterns" jsonb DEFAULT '[]'::jsonb NOT NULL,
"mark_as_finished_seconds_remaining" integer,
"mark_as_finished_percent_complete" integer,
"file_naming_pattern" varchar(500),
"scan_mode" varchar(20) DEFAULT 'auto' NOT NULL,
"poll_interval_seconds" integer DEFAULT 300,
"created_at" timestamp DEFAULT now() NOT NULL,
@@ -209,6 +210,7 @@ CREATE TABLE "book_metadata" (
"amazon_id" varchar(20),
"hardcover_id" varchar(50),
"open_library_id" varchar(50),
"embedding" vector(256),
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
@@ -1 +0,0 @@
ALTER TABLE "book_metadata" ADD COLUMN "embedding" vector(256);
@@ -1,5 +1,5 @@
{
"id": "0eeff5ae-6f70-4e72-b6f3-c06a1e702c9a",
"id": "6567aba8-023d-477f-aaf1-6b45d728860f",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
@@ -1006,6 +1006,12 @@
"primaryKey": false,
"notNull": false
},
"file_naming_pattern": {
"name": "file_naming_pattern",
"type": "varchar(500)",
"primaryKey": false,
"notNull": false
},
"scan_mode": {
"name": "scan_mode",
"type": "varchar(20)",
@@ -1432,6 +1438,12 @@
"primaryKey": false,
"notNull": false
},
"embedding": {
"name": "embedding",
"type": "vector(256)",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -5,16 +5,9 @@
{
"idx": 0,
"version": "7",
"when": 1772062347076,
"when": 1772107749977,
"tag": "0000_baseline",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1772062347076,
"tag": "0001_add_embedding_column",
"breakpoints": true
}
]
}
+3
View File
@@ -24,6 +24,9 @@ export const libraries = pgTable('libraries', {
markAsFinishedSecondsRemaining: integer('mark_as_finished_seconds_remaining'),
markAsFinishedPercentComplete: integer('mark_as_finished_percent_complete'),
// File naming pattern for uploads (null = use global default)
fileNamingPattern: varchar('file_naming_pattern', { length: 500 }),
// Legacy — kept for scanner compatibility
scanMode: varchar('scan_mode', { length: 20 }).notNull().default('auto'),
pollInterval: integer('poll_interval_seconds').default(300),
@@ -5,6 +5,7 @@ import { RequirePermission } from '../../common/decorators/require-permission.de
import { AppSettingsService } from './app-settings.service';
import { UpdateAppSettingDto } from './dto/update-app-setting.dto';
import { UpdateOidcConfigDto } from './dto/update-oidc-config.dto';
import { UpdateUploadPatternDto } from './dto/update-upload-pattern.dto';
@Controller('app-settings')
@RequirePermission('manage_app_settings')
@@ -22,6 +23,18 @@ export class AppSettingsController {
return this.appSettingsService.update(key, dto.value);
}
@Get('upload-pattern')
async getUploadPattern() {
return { pattern: await this.appSettingsService.getUploadPattern() };
}
@Put('upload-pattern')
@HttpCode(HttpStatus.OK)
async setUploadPattern(@Body() dto: UpdateUploadPatternDto) {
await this.appSettingsService.setUploadPattern(dto.pattern);
return { pattern: dto.pattern };
}
@Public()
@Get('oidc/public')
async getOidcPublicConfig() {
@@ -2,9 +2,16 @@ import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DEFAULT_UPLOAD_PATTERN } from '@projectx/types';
import { DB } from '../../db';
import * as schema from '../../db/schema';
const APP_SETTING_KEYS = {
OIDC_CONFIG: 'oidc_config',
UPLOAD_FILE_PATTERN: 'upload_file_pattern',
} as const;
type Db = NodePgDatabase<typeof schema>;
export interface OidcFullConfig {
@@ -54,7 +61,7 @@ export class AppSettingsService {
}
async getOidcConfig(): Promise<OidcFullConfig> {
const row = await this.db.query.appSettings.findFirst({ where: eq(schema.appSettings.key, 'oidc_config') });
const row = await this.db.query.appSettings.findFirst({ where: eq(schema.appSettings.key, APP_SETTING_KEYS.OIDC_CONFIG) });
if (!row) return { ...DEFAULT_OIDC_CONFIG };
try {
return JSON.parse(row.value) as OidcFullConfig;
@@ -63,6 +70,18 @@ export class AppSettingsService {
}
}
async getUploadPattern(): Promise<string> {
const row = await this.db.query.appSettings.findFirst({ where: eq(schema.appSettings.key, APP_SETTING_KEYS.UPLOAD_FILE_PATTERN) });
return row?.value ?? DEFAULT_UPLOAD_PATTERN;
}
async setUploadPattern(pattern: string): Promise<void> {
await this.db
.insert(schema.appSettings)
.values({ key: APP_SETTING_KEYS.UPLOAD_FILE_PATTERN, value: pattern })
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value: pattern } });
}
async updateOidcConfig(config: Partial<OidcFullConfig>): Promise<OidcFullConfig> {
const current = await this.getOidcConfig();
const merged: OidcFullConfig = {
@@ -75,7 +94,7 @@ export class AppSettingsService {
await this.db
.insert(schema.appSettings)
.values({ key: 'oidc_config', value })
.values({ key: APP_SETTING_KEYS.OIDC_CONFIG, value })
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value } });
return merged;
@@ -0,0 +1,26 @@
import { IsString, MaxLength, registerDecorator, ValidationOptions } from 'class-validator';
import { validatePattern } from '@projectx/types';
function IsValidPattern(options?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isValidPattern',
target: object.constructor,
propertyName,
options: { message: 'Pattern contains invalid characters', ...options },
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return false;
return validatePattern(value);
},
},
});
};
}
export class UpdateUploadPatternDto {
@IsString()
@MaxLength(500)
@IsValidPattern()
pattern: string;
}
@@ -1,4 +1,4 @@
import { ArrayMinSize, IsArray, IsBoolean, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ArrayMinSize, IsArray, IsBoolean, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min, ValidateIf } from 'class-validator';
export class CreateLibraryDto {
@IsString()
@@ -64,4 +64,10 @@ export class CreateLibraryDto {
@Min(0)
@Max(100)
markAsFinishedPercentComplete?: number | null;
@IsOptional()
@ValidateIf((o: { fileNamingPattern?: unknown }) => o.fileNamingPattern !== null)
@IsString()
@MaxLength(500)
fileNamingPattern?: string | null;
}
@@ -1,4 +1,4 @@
import { ArrayMinSize, IsArray, IsBoolean, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ArrayMinSize, IsArray, IsBoolean, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min, ValidateIf } from 'class-validator';
export class UpdateLibraryDto {
@IsOptional()
@@ -66,4 +66,10 @@ export class UpdateLibraryDto {
@Min(0)
@Max(100)
markAsFinishedPercentComplete?: number | null;
@IsOptional()
@ValidateIf((o: { fileNamingPattern?: unknown }) => o.fileNamingPattern !== null)
@IsString()
@MaxLength(500)
fileNamingPattern?: string | null;
}
@@ -60,6 +60,7 @@ export class LibraryService {
excludePatterns: dto.excludePatterns ?? [],
markAsFinishedSecondsRemaining: dto.markAsFinishedSecondsRemaining ?? null,
markAsFinishedPercentComplete: dto.markAsFinishedPercentComplete ?? null,
fileNamingPattern: dto.fileNamingPattern ?? null,
});
const folders = await Promise.all(dto.folders.map((path) => this.libraryRepo.insertFolder({ libraryId: library.id, path })));
@@ -0,0 +1,429 @@
import {
applyModifier,
DEFAULT_UPLOAD_PATTERN,
EXAMPLE_PATTERN_METADATA,
replacePlaceholders,
resolveUploadPath,
validatePattern,
} from '@projectx/types';
// ── Shared fixtures ───────────────────────────────────────────────────────────
const FULL = EXAMPLE_PATTERN_METADATA;
const PARTIAL: Record<string, string> = {
title: 'Project Hail Mary',
authors: 'Andy Weir',
year: '2021',
originalFilename: 'project_hail_mary',
extension: 'epub',
};
const MULTI_AUTHOR: Record<string, string> = {
...FULL,
authors: 'Bruce Sterling, William Gibson',
};
const DECIMAL_INDEX: Record<string, string> = {
...FULL,
seriesIndex: '01.5',
};
// ── applyModifier ─────────────────────────────────────────────────────────────
describe('applyModifier', () => {
describe('first', () => {
it('returns the single value unchanged', () => {
expect(applyModifier('Patrick Rothfuss', 'first', 'authors')).toBe('Patrick Rothfuss');
});
it('returns only the first item from a comma-separated list', () => {
expect(applyModifier('Patrick Rothfuss, Brent Weeks', 'first', 'authors')).toBe('Patrick Rothfuss');
});
it('trims whitespace around the first item', () => {
expect(applyModifier(' Andy Weir , Someone Else', 'first', 'authors')).toBe('Andy Weir');
});
});
describe('sort', () => {
it('converts "First Last" to "Last, First"', () => {
expect(applyModifier('Patrick Rothfuss', 'sort', 'authors')).toBe('Rothfuss, Patrick');
});
it('uses only the first author from a list', () => {
expect(applyModifier('Patrick Rothfuss, Brent Weeks', 'sort', 'authors')).toBe('Rothfuss, Patrick');
});
it('handles a single-word name without crashing', () => {
expect(applyModifier('Voltaire', 'sort', 'authors')).toBe('Voltaire');
});
it('works on non-author fields', () => {
expect(applyModifier('The Name of the Wind', 'sort', 'title')).toBe('Wind, The Name of the');
});
});
describe('initial', () => {
it('returns first letter of title uppercased', () => {
expect(applyModifier('the name of the wind', 'initial', 'title')).toBe('T');
});
it('returns first letter of last name for authors field', () => {
expect(applyModifier('Patrick Rothfuss', 'initial', 'authors')).toBe('R');
});
it('uses only the first author from a list', () => {
expect(applyModifier('Patrick Rothfuss, Brent Weeks', 'initial', 'authors')).toBe('R');
});
it('handles single-word author name', () => {
expect(applyModifier('Voltaire', 'initial', 'authors')).toBe('V');
});
});
describe('upper', () => {
it('converts value to uppercase', () => {
expect(applyModifier('The Name of the Wind', 'upper', 'title')).toBe('THE NAME OF THE WIND');
});
});
describe('lower', () => {
it('converts value to lowercase', () => {
expect(applyModifier('The Name of the Wind', 'lower', 'title')).toBe('the name of the wind');
});
});
describe('unknown modifier', () => {
it('returns the value unchanged', () => {
expect(applyModifier('Patrick Rothfuss', 'nonexistent', 'authors')).toBe('Patrick Rothfuss');
});
});
describe('empty value', () => {
it('returns empty string without throwing', () => {
expect(applyModifier('', 'sort', 'authors')).toBe('');
expect(applyModifier('', 'first', 'authors')).toBe('');
expect(applyModifier('', 'upper', 'title')).toBe('');
});
});
});
// ── replacePlaceholders ───────────────────────────────────────────────────────
describe('replacePlaceholders', () => {
describe('simple token substitution', () => {
it('replaces a single token', () => {
expect(replacePlaceholders('{title}', FULL)).toBe('Neuromancer');
});
it('replaces multiple tokens', () => {
expect(replacePlaceholders('{authors}/{title}', FULL)).toBe('William Gibson/Neuromancer');
});
it('leaves literal text intact', () => {
expect(replacePlaceholders('Books/{title}', FULL)).toBe('Books/Neuromancer');
});
it('replaces a missing token with an empty string', () => {
expect(replacePlaceholders('{series}', PARTIAL)).toBe('');
});
it('trims leading and trailing whitespace from the result', () => {
expect(replacePlaceholders(' {title} ', FULL)).toBe('Neuromancer');
});
it('returns empty string for an empty pattern', () => {
expect(replacePlaceholders('', FULL)).toBe('');
});
});
describe('modifiers', () => {
it('{authors:first} picks the first author', () => {
expect(replacePlaceholders('{authors:first}', MULTI_AUTHOR)).toBe('Bruce Sterling');
});
it('{authors:sort} sorts the first author', () => {
expect(replacePlaceholders('{authors:sort}', FULL)).toBe('Gibson, William');
});
it('{authors:initial} gives the last-name initial', () => {
expect(replacePlaceholders('{authors:initial}', FULL)).toBe('G');
});
it('{title:upper} uppercases', () => {
expect(replacePlaceholders('{title:upper}', FULL)).toBe('NEUROMANCER');
});
it('{title:lower} lowercases', () => {
expect(replacePlaceholders('{title:lower}', FULL)).toBe('neuromancer');
});
it('{title:initial} gives the first letter of the title', () => {
expect(replacePlaceholders('{title:initial}', FULL)).toBe('N');
});
it('modifier on a missing token resolves to empty string', () => {
expect(replacePlaceholders('{series:upper}', PARTIAL)).toBe('');
});
it('combines modifier result with other tokens', () => {
expect(replacePlaceholders('{authors:initial}/{authors:sort}/{title}', FULL)).toBe('G/Gibson, William/Neuromancer');
});
});
describe('optional blocks <...>', () => {
it('includes the block when all tokens are present', () => {
expect(replacePlaceholders('<{series}/>', FULL)).toBe('Sprawl/');
});
it('omits the block when a token is missing', () => {
expect(replacePlaceholders('<{series}/>', PARTIAL)).toBe('');
});
it('omits the block when a token is an empty string', () => {
expect(replacePlaceholders('<{series}/>', { ...FULL, series: '' })).toBe('');
});
it('includes a block with multiple tokens when all are present', () => {
// trailing space is trimmed from the overall result — test in context
expect(replacePlaceholders('<{seriesIndex}. >{title}', FULL)).toBe('01. Neuromancer');
});
it('omits a multi-token block when any token is missing', () => {
expect(replacePlaceholders('<{seriesIndex}. >', PARTIAL)).toBe('');
});
it('includes a year block when year is present', () => {
// leading space is trimmed from the overall result — test in context
expect(replacePlaceholders('{title}< ({year})>', FULL)).toBe('Neuromancer (1984)');
});
it('omits a year block when year is missing', () => {
expect(replacePlaceholders('< ({year})>', { ...FULL, year: '' })).toBe('');
});
it('multiple optional blocks are evaluated independently', () => {
expect(replacePlaceholders('{title}<: {subtitle}>< ({year})>', FULL)).toBe('Neuromancer: 20th Anniversary Edition (1984)');
});
it('omits only the absent block when some are present', () => {
expect(replacePlaceholders('{title}<: {subtitle}>< ({year})>', PARTIAL)).toBe('Project Hail Mary (2021)');
});
});
describe('optional blocks with fallback <primary|fallback>', () => {
it('uses the primary when its token is present', () => {
expect(replacePlaceholders('<{series}|Standalone>', FULL)).toBe('Sprawl');
});
it('uses the fallback when the primary token is missing', () => {
expect(replacePlaceholders('<{series}|Standalone>', PARTIAL)).toBe('Standalone');
});
it('uses the fallback when the primary token is empty', () => {
expect(replacePlaceholders('<{series}|Standalone>', { ...FULL, series: '' })).toBe('Standalone');
});
it('applies modifiers to the fallback', () => {
expect(replacePlaceholders('<{series}|{authors:sort}>', PARTIAL)).toBe('Weir, Andy');
});
it('uses primary over fallback when primary is fully resolved', () => {
expect(replacePlaceholders('<{series}/{seriesIndex}|{title}>', FULL)).toBe('Sprawl/01');
});
it('falls back to the full fallback when primary has any missing token', () => {
expect(replacePlaceholders('<{series}/{seriesIndex}|{title}>', PARTIAL)).toBe('Project Hail Mary');
});
});
describe('author fallback pattern', () => {
it('uses the author when present', () => {
expect(replacePlaceholders('<{authors:first}|Unknown Author>', FULL)).toBe('William Gibson');
});
it('falls back to "Unknown Author" when authors is missing', () => {
expect(replacePlaceholders('<{authors:first}|Unknown Author>', { title: 'Untitled' })).toBe('Unknown Author');
});
it('falls back to "Unknown Author" when authors is empty string', () => {
expect(replacePlaceholders('<{authors:first}|Unknown Author>', { ...FULL, authors: '' })).toBe('Unknown Author');
});
});
describe('realistic path patterns', () => {
it('author/series/index. title (year) -- full metadata', () => {
expect(replacePlaceholders('{authors:first}/{series}/{seriesIndex}. {title} ({year})', FULL)).toBe(
'William Gibson/Sprawl/01. Neuromancer (1984)',
);
});
it('author/series/index. title -- no year', () => {
expect(replacePlaceholders('{authors:first}/{series}/{seriesIndex}. {title}', FULL)).toBe(
'William Gibson/Sprawl/01. Neuromancer',
);
});
it('optional series folder collapses when absent', () => {
expect(replacePlaceholders('{authors:first}/<{series}/><{seriesIndex}. >{title}', PARTIAL)).toBe('Andy Weir/Project Hail Mary');
});
it('optional year appended when present', () => {
expect(replacePlaceholders('{authors:first}/<{series}/><{seriesIndex}. >{title}< ({year})>', PARTIAL)).toBe(
'Andy Weir/Project Hail Mary (2021)',
);
});
it('letter-bucketed layout', () => {
expect(replacePlaceholders('{authors:initial}/{authors:sort}/{title}', FULL)).toBe('G/Gibson, William/Neuromancer');
});
it('decimal series index is preserved', () => {
expect(replacePlaceholders('<{seriesIndex}. >{title}', DECIMAL_INDEX)).toBe('01.5. Neuromancer');
});
});
});
// ── resolveUploadPath ─────────────────────────────────────────────────────────
describe('resolveUploadPath', () => {
describe('extension handling', () => {
it('appends the extension when the resolved path has none', () => {
expect(resolveUploadPath('{authors}/{title}', FULL, 'epub')).toBe('William Gibson/Neuromancer.epub');
});
it('does not double-append when the pattern already ends with an extension', () => {
expect(resolveUploadPath('{title}.epub', FULL, 'epub')).toBe('Neuromancer.epub');
});
it('accepts the extension with or without a leading dot', () => {
expect(resolveUploadPath('{title}', FULL, 'epub')).toBe('Neuromancer.epub');
expect(resolveUploadPath('{title}', FULL, '.epub')).toBe('Neuromancer.epub');
});
});
describe('folder-only mode (trailing slash)', () => {
it('preserves the original filename inside the folder', () => {
expect(resolveUploadPath('{authors}/', FULL, 'epub')).toBe('William Gibson/neuromancer.epub');
});
it('works with a nested folder pattern', () => {
expect(resolveUploadPath('{authors}/{series}/', FULL, 'epub')).toBe('William Gibson/Sprawl/neuromancer.epub');
});
});
describe('originalFilename token', () => {
it('uses the originalFilename token in path position', () => {
expect(resolveUploadPath('{authors}/{originalFilename}', FULL, 'epub')).toBe('William Gibson/neuromancer.epub');
});
});
describe('empty / null resolution', () => {
it('returns null when the pattern resolves to an empty string', () => {
expect(resolveUploadPath('{series}', PARTIAL, 'epub')).toBeNull();
});
it('returns null for an empty pattern string', () => {
expect(resolveUploadPath('', FULL, 'epub')).toBeNull();
});
});
describe('leading slash', () => {
it('preserves a leading slash in the resolved path', () => {
expect(resolveUploadPath('/{authors}/{title}', FULL, 'epub')).toBe('/William Gibson/Neuromancer.epub');
});
});
});
// ── validatePattern ───────────────────────────────────────────────────────────
describe('validatePattern', () => {
it('accepts a simple token pattern', () => {
expect(validatePattern('{title}')).toBe(true);
});
it('accepts a complex realistic pattern', () => {
expect(validatePattern(DEFAULT_UPLOAD_PATTERN)).toBe(true);
});
it('accepts path separators and common punctuation', () => {
expect(validatePattern('{authors}/{series} - {title} ({year})')).toBe(true);
});
it('accepts the pipe character used in fallbacks', () => {
expect(validatePattern('<{series}|Standalone>')).toBe(true);
});
it('rejects a null byte', () => {
expect(validatePattern('{title}\0bad')).toBe(false);
});
it('rejects a backslash', () => {
expect(validatePattern('{authors}\\{title}')).toBe(false);
});
it('rejects an asterisk', () => {
expect(validatePattern('{title}*')).toBe(false);
});
it('rejects a question mark', () => {
expect(validatePattern('{title}?')).toBe(false);
});
it('accepts an empty string', () => {
expect(validatePattern('')).toBe(true);
});
});
// ── DEFAULT_UPLOAD_PATTERN integration ───────────────────────────────────────
describe('DEFAULT_UPLOAD_PATTERN', () => {
const P = DEFAULT_UPLOAD_PATTERN;
it('full metadata: author / series / index. title (year)', () => {
expect(resolveUploadPath(P, FULL, 'epub')).toBe('William Gibson/Sprawl/01. Neuromancer (1984).epub');
});
it('no series: author / title (year)', () => {
expect(resolveUploadPath(P, PARTIAL, 'epub')).toBe('Andy Weir/Project Hail Mary (2021).epub');
});
it('no year: author / series / index. title', () => {
expect(resolveUploadPath(P, { ...FULL, year: '' }, 'epub')).toBe('William Gibson/Sprawl/01. Neuromancer.epub');
});
it('no series and no year: author / title', () => {
expect(resolveUploadPath(P, { ...PARTIAL, year: '' }, 'epub')).toBe('Andy Weir/Project Hail Mary.epub');
});
it('no author: falls back to Unknown Author folder', () => {
expect(resolveUploadPath(P, { ...PARTIAL, authors: '' }, 'epub')).toBe('Unknown Author/Project Hail Mary (2021).epub');
});
it('no author and no metadata: Unknown Author / title', () => {
expect(resolveUploadPath(P, { title: 'Untitled', originalFilename: 'untitled', extension: 'epub' }, 'epub')).toBe('Unknown Author/Untitled.epub');
});
it('multiple authors: uses only the first', () => {
expect(resolveUploadPath(P, MULTI_AUTHOR, 'epub')).toBe('Bruce Sterling/Sprawl/01. Neuromancer (1984).epub');
});
it('decimal series index is preserved', () => {
expect(resolveUploadPath(P, DECIMAL_INDEX, 'epub')).toBe('William Gibson/Sprawl/01.5. Neuromancer (1984).epub');
});
it('series present but no index: series folder without index prefix', () => {
expect(resolveUploadPath(P, { ...FULL, seriesIndex: '' }, 'epub')).toBe(
'William Gibson/Sprawl/Neuromancer (1984).epub',
);
});
it('different file extension is appended correctly', () => {
expect(resolveUploadPath(P, PARTIAL, 'pdf')).toBe('Andy Weir/Project Hail Mary (2021).pdf');
});
it('passes validatePattern', () => {
expect(validatePattern(P)).toBe(true);
});
});
@@ -0,0 +1,67 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { stat } from 'fs/promises';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { bookFiles, bookMetadata, books } from '../../db/schema';
import { MetadataService } from '../metadata/metadata.service';
import { sha256File } from '../scanner/lib/hash';
type Db = NodePgDatabase<typeof schema>;
const METADATA_FORMATS = new Set(['epub', 'mobi', 'azw3', 'azw', 'cbz', 'cbr', 'cb7', 'pdf']);
@Injectable()
export class UploadProcessorService {
private readonly logger = new Logger(UploadProcessorService.name);
constructor(
@Inject(DB) private readonly db: Db,
private readonly metadataService: MetadataService,
) {}
async createBookRecord(
libraryId: number,
libraryFolderId: number,
folderPath: string,
absolutePath: string,
relPath: string,
format: string,
sizeBytes: number,
): Promise<{ bookId: number }> {
const [fileStat, hash] = await Promise.all([stat(absolutePath), sha256File(absolutePath)]);
const [book] = await this.db.insert(books).values({ libraryId, libraryFolderId, folderPath, status: 'present' }).returning();
// Always create an empty metadata row so joins never return null (mirrors scanner behaviour).
await this.db.insert(bookMetadata).values({ bookId: book.id });
await this.db.insert(bookFiles).values({
bookId: book.id,
libraryFolderId,
absolutePath,
relPath,
ino: fileStat.ino,
sizeBytes,
mtime: fileStat.mtime,
hash,
format,
role: 'primary',
});
return { bookId: book.id };
}
/**
* Fires-and-forgets metadata + cover extraction.
* Errors are logged but never surfaced to the caller.
*/
extractMetadataAsync(bookId: number, absolutePath: string, format: string): void {
if (!METADATA_FORMATS.has(format)) return;
this.metadataService
.extractAndSave(bookId, absolutePath, format)
.catch((err: Error) => this.logger.warn(`Metadata extraction failed for uploaded book ${bookId}: ${err.message}`));
}
}
@@ -0,0 +1,67 @@
import { Injectable, Logger, PayloadTooLargeException } from '@nestjs/common';
import { copyFile, mkdir, rename, stat, unlink } from 'fs/promises';
import { createWriteStream } from 'fs';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { Readable } from 'stream';
import { randomUUID } from 'crypto';
// Hard ceiling applied at the multipart level. The service enforces a lower configurable limit.
export const MAX_UPLOAD_BYTES = 500 * 1024 * 1024; // 500 MB
@Injectable()
export class UploadStorageService {
private readonly logger = new Logger(UploadStorageService.name);
/**
* Streams the multipart file to a temp path on disk.
* Returns whether busboy truncated the stream (file exceeded the fileSize limit).
*/
async streamToTemp(source: Readable): Promise<{ tempPath: string; sizeBytes: number; truncated: boolean }> {
const tempPath = join(tmpdir(), `projectx-upload-${randomUUID()}`);
const writeStream = createWriteStream(tempPath);
await new Promise<void>((resolve, reject) => {
source.pipe(writeStream);
writeStream.on('finish', resolve);
writeStream.on('error', reject);
source.on('error', reject);
});
if ((source as Readable & { truncated?: boolean }).truncated) {
await this.cleanup(tempPath);
throw new PayloadTooLargeException(`File exceeds the ${MAX_UPLOAD_BYTES / 1024 / 1024} MB upload limit`);
}
const { size } = await stat(tempPath);
return { tempPath, sizeBytes: size, truncated: false };
}
/**
* Moves the temp file to an already-resolved absolute destination path.
* Creates parent directories as needed.
* Uses rename() and falls back to copy+unlink for cross-device moves.
*/
async moveToPath(tempPath: string, absolutePath: string): Promise<void> {
await mkdir(dirname(absolutePath), { recursive: true });
try {
await rename(tempPath, absolutePath);
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'EXDEV') {
await copyFile(tempPath, absolutePath);
await this.cleanup(tempPath);
} else {
throw err;
}
}
}
async cleanup(tempPath: string): Promise<void> {
await unlink(tempPath).catch((err: NodeJS.ErrnoException) => {
if (err.code !== 'ENOENT') {
this.logger.warn(`Failed to delete temp file ${tempPath}: ${err.message}`);
}
});
}
}
@@ -0,0 +1,37 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { extname } from 'path';
export const SUPPORTED_BOOK_FORMATS = new Set(['epub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2']);
@Injectable()
export class UploadValidatorService {
/**
* Returns the normalized extension if valid; throws otherwise.
* Checks against the global supported set and then any library-level format restriction.
*/
validateFormat(filename: string, libraryAllowedFormats: string[]): string {
const ext = extname(filename).toLowerCase().slice(1);
if (!SUPPORTED_BOOK_FORMATS.has(ext)) {
throw new BadRequestException(`Unsupported file type .${ext}. Allowed types: ${[...SUPPORTED_BOOK_FORMATS].join(', ')}`);
}
if (libraryAllowedFormats.length > 0 && !libraryAllowedFormats.includes(ext)) {
throw new BadRequestException(`This library does not allow .${ext} files`);
}
return ext;
}
/**
* Strips path separators, null bytes, and trims to 255 characters.
* Preserves the original extension.
*/
sanitizeFilename(raw: string): string {
const sanitized = raw
.replace(/[/\\:*?"<>|\0]/g, '_')
.trim()
.slice(0, 255);
return sanitized || 'upload';
}
}
@@ -0,0 +1,42 @@
import { BadRequestException, Controller, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query, Req } from '@nestjs/common';
import type { MultipartFile } from '@fastify/multipart';
import type { FastifyRequest } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { UploadService } from './upload.service';
import { MAX_UPLOAD_BYTES } from './upload-storage.service';
type MultipartRequest = FastifyRequest & {
file: (opts?: object) => Promise<MultipartFile | undefined>;
};
@Controller('libraries')
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Post(':id/upload')
@HttpCode(HttpStatus.CREATED)
@RequirePermission('library_upload')
async uploadBook(
@Param('id', ParseIntPipe) libraryId: number,
@Query('folderId') rawFolderId: string | undefined,
@CurrentUser() user: RequestUser,
@Req() req: MultipartRequest,
) {
// Override the global multipart fileSize limit for book uploads.
// Per-request options are deep-merged with plugin defaults (busboy config),
// so this fileSize takes precedence over the global 20 MB cover limit.
const data = await req.file({ limits: { fileSize: MAX_UPLOAD_BYTES } });
if (!data) throw new BadRequestException('No file provided');
let folderId: number | undefined;
if (rawFolderId !== undefined) {
folderId = parseInt(rawFolderId, 10);
if (isNaN(folderId)) throw new BadRequestException('Invalid folderId');
}
return this.uploadService.upload(libraryId, folderId, data.filename, data.file, user);
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { LibraryModule } from '../library/library.module';
import { MetadataModule } from '../metadata/metadata.module';
import { UploadController } from './upload.controller';
import { UploadProcessorService } from './upload-processor.service';
import { UploadService } from './upload.service';
import { UploadStorageService } from './upload-storage.service';
import { UploadValidatorService } from './upload-validator.service';
@Module({
imports: [AppSettingsModule, LibraryModule, MetadataModule],
controllers: [UploadController],
providers: [UploadService, UploadValidatorService, UploadStorageService, UploadProcessorService],
})
export class UploadModule {}
+190
View File
@@ -0,0 +1,190 @@
import { BadRequestException, ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { access as fsAccess } from 'fs/promises';
import { basename, dirname, extname, join } from 'path';
import { Readable } from 'stream';
import { eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { libraries, libraryFolders } from '../../db/schema';
import type { RequestUser } from '../../common/types/request-user';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { LibraryService } from '../library/library.service';
import { UploadValidatorService } from './upload-validator.service';
import { UploadStorageService } from './upload-storage.service';
import { UploadProcessorService } from './upload-processor.service';
import { resolveUploadPath } from '@projectx/types';
import type { UploadResult } from '@projectx/types';
import { extractEpubMetadata } from '../metadata/lib/epub';
import { extractCbzMetadata, extractCbrMetadata, extractCb7Metadata } from '../metadata/lib/cbz-metadata';
import { parseMobiFile } from '../metadata/lib/mobi-parser';
import { parsePdfFile } from '../metadata/lib/pdf-parser';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class UploadService {
constructor(
@Inject(DB) private readonly db: Db,
private readonly appSettings: AppSettingsService,
private readonly libraryService: LibraryService,
private readonly validator: UploadValidatorService,
private readonly storage: UploadStorageService,
private readonly processor: UploadProcessorService,
) {}
async upload(libraryId: number, folderId: number | undefined, rawFilename: string, fileStream: Readable, user: RequestUser): Promise<UploadResult> {
const isSuperuser = user.roles.some((r) => r.isSuperuser);
const library = await this.findLibraryOrFail(libraryId);
await this.libraryService.verifyUserAccess(user.id, libraryId, isSuperuser);
const folder = await this.resolveFolder(libraryId, folderId);
const filename = this.validator.sanitizeFilename(rawFilename);
const format = this.validator.validateFormat(filename, library.allowedFormats);
const { tempPath, sizeBytes } = await this.storage.streamToTemp(fileStream);
try {
const { absolutePath, folderPath, relPath } = await this.resolveDestination(library, folder.path, tempPath, filename, format);
const exists = await fsAccess(absolutePath)
.then(() => true)
.catch(() => false);
if (exists) {
throw new ConflictException(`A file named "${basename(absolutePath)}" already exists at the target location`);
}
await this.storage.moveToPath(tempPath, absolutePath);
const { bookId } = await this.processor.createBookRecord(libraryId, folder.id, folderPath, absolutePath, relPath, format, sizeBytes);
this.processor.extractMetadataAsync(bookId, absolutePath, format);
return { bookId, filename: basename(absolutePath), format, sizeBytes };
} catch (err) {
await this.storage.cleanup(tempPath);
throw err;
}
}
private async resolveDestination(
library: { fileNamingPattern?: string | null },
libraryFolderPath: string,
tempPath: string,
filename: string,
format: string,
): Promise<{ absolutePath: string; folderPath: string; relPath: string }> {
const pattern = library.fileNamingPattern ?? (await this.appSettings.getUploadPattern());
if (pattern) {
const stem = basename(filename, extname(filename));
const tokens = await this.buildPatternTokens(tempPath, format, stem);
const resolved = resolveUploadPath(pattern, tokens, format);
if (resolved) {
const absolutePath = join(libraryFolderPath, resolved);
const folderPath = dirname(absolutePath);
const relPath = resolved;
return { absolutePath, folderPath, relPath };
}
}
const stem = basename(filename, extname(filename));
const folderPath = join(libraryFolderPath, stem);
const absolutePath = join(folderPath, filename);
const relPath = join(stem, filename);
return { absolutePath, folderPath, relPath };
}
private async buildPatternTokens(tempPath: string, format: string, stem: string): Promise<Record<string, string>> {
const base: Record<string, string> = { originalFilename: stem, extension: format };
try {
let parsed: {
title?: string | null;
subtitle?: string | null;
publisher?: string | null;
publishedYear?: number | null;
language?: string | null;
seriesName?: string | null;
seriesIndex?: number | null;
isbn13?: string | null;
authors: { name: string }[];
} | null = null;
if (format === 'epub') {
parsed = await extractEpubMetadata(tempPath);
} else if (format === 'cbz') {
parsed = await extractCbzMetadata(tempPath);
} else if (format === 'cbr') {
parsed = await extractCbrMetadata(tempPath);
} else if (format === 'cb7') {
parsed = await extractCb7Metadata(tempPath);
} else if (format === 'mobi' || format === 'azw3' || format === 'azw') {
const mobi = await parseMobiFile(tempPath);
if (mobi) {
const year = mobi.publishedDate ? parseInt(mobi.publishedDate.substring(0, 4), 10) || null : null;
parsed = {
title: mobi.title,
publisher: mobi.publisher,
publishedYear: year,
language: mobi.language,
isbn13: mobi.isbn,
seriesName: null,
seriesIndex: null,
authors: mobi.authors.map((name) => ({ name })),
};
}
} else if (format === 'pdf') {
const pdf = await parsePdfFile(tempPath);
if (pdf) {
parsed = { title: pdf.title, publisher: pdf.publisher, authors: pdf.authors, seriesName: null, seriesIndex: null };
}
}
if (!parsed) return base;
if (parsed.title) base['title'] = parsed.title;
if (parsed.subtitle) base['subtitle'] = parsed.subtitle;
if (parsed.publisher) base['publisher'] = parsed.publisher;
if (parsed.language) base['language'] = parsed.language;
if (parsed.isbn13) base['isbn'] = parsed.isbn13;
if (parsed.publishedYear) base['year'] = String(parsed.publishedYear);
if (parsed.seriesName) base['series'] = parsed.seriesName;
if (parsed.seriesIndex != null) {
const whole = Math.floor(parsed.seriesIndex);
const fraction = parsed.seriesIndex - whole;
const padded = String(whole).padStart(2, '0');
base['seriesIndex'] = fraction > 0 ? `${padded}.${String(fraction).split('.')[1]}` : padded;
}
if (parsed.authors.length > 0) {
base['authors'] = parsed.authors.map((a) => a.name).join(', ');
}
} catch {
// Non-fatal: fall back to filename-only tokens
}
return base;
}
private async findLibraryOrFail(libraryId: number) {
const [library] = await this.db.select().from(libraries).where(eq(libraries.id, libraryId)).limit(1);
if (!library) throw new NotFoundException('Library not found');
return library;
}
private async resolveFolder(libraryId: number, folderId?: number) {
if (folderId !== undefined) {
const [folder] = await this.db.select().from(libraryFolders).where(eq(libraryFolders.id, folderId)).limit(1);
if (!folder || folder.libraryId !== libraryId) throw new BadRequestException('Folder does not belong to this library');
return folder;
}
const folders = await this.db.select().from(libraryFolders).where(eq(libraryFolders.libraryId, libraryId));
if (folders.length === 0) throw new BadRequestException('Library has no folders configured');
return folders[0];
}
}