mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat: file upload with configurable naming patterns
This commit is contained in:
@@ -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"><...></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"><primary|fallback></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,
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface Library {
|
||||
excludePatterns: string[];
|
||||
markAsFinishedSecondsRemaining?: number | null;
|
||||
markAsFinishedPercentComplete?: number | null;
|
||||
fileNamingPattern?: string | null;
|
||||
folders: LibraryFolder[];
|
||||
bookCount?: number;
|
||||
createdAt: string;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type UploadResult = {
|
||||
bookId: number;
|
||||
filename: string;
|
||||
format: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user