feat: add configurable download filename patterns

This commit is contained in:
neon
2026-03-06 07:22:29 -06:00
parent 9138e0265c
commit aeae0ac8ce
15 changed files with 538 additions and 25 deletions
@@ -228,8 +228,7 @@ function openBook() {
function downloadFile() {
if (!primaryFile.value) return
const a = document.createElement('a')
a.href = `/api/v1/books/files/${primaryFile.value.id}/serve`
a.download = `book.${primaryFile.value.format ?? 'epub'}`
a.href = `/api/v1/books/files/${primaryFile.value.id}/serve?download=1`
a.click()
}
@@ -45,8 +45,7 @@ function openFile(file: BookDetailFile) {
function downloadFile(file: BookDetailFile) {
const a = document.createElement('a')
a.href = `/api/v1/books/files/${file.id}/serve`
a.download = file.filename ?? `book.${file.format ?? 'epub'}`
a.href = `/api/v1/books/files/${file.id}/serve?download=1`
a.click()
}
@@ -260,8 +260,7 @@ function currentZoomValue(): string {
<Tooltip>
<TooltipTrigger as-child>
<a
:href="`/api/v1/books/files/${fileId}/serve`"
download
:href="`/api/v1/books/files/${fileId}/serve?download=1`"
class="viewer-btn flex items-center justify-center"
>
<Download :size="14" />
@@ -346,4 +345,3 @@ function currentZoomValue(): string {
</DropdownMenu>
</div>
</template>
@@ -1,24 +1,32 @@
<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 { DEFAULT_DOWNLOAD_PATTERN, DEFAULT_UPLOAD_PATTERN, PATTERN_TOKENS } from '@projectx/types'
import { useFileNamingPattern } from './composables/useFileNamingPattern'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
const {
globalPattern,
globalError,
downloadPattern,
downloadError,
libraries,
loadingGlobal,
savingGlobal,
loadingDownload,
savingDownload,
savingLibraryId,
fetchGlobalPattern,
fetchDownloadPattern,
fetchLibraries,
onGlobalPatternInput,
onDownloadPatternInput,
saveGlobalPattern,
saveDownloadPattern,
saveLibraryPattern,
clearLibraryPattern,
getEffectivePreview,
previewDownloadName,
previewPath,
} = useFileNamingPattern()
@@ -50,7 +58,7 @@ const EXAMPLES = [
]
onMounted(async () => {
await Promise.all([fetchGlobalPattern(), fetchLibraries()])
await Promise.all([fetchGlobalPattern(), fetchDownloadPattern(), fetchLibraries()])
})
</script>
@@ -60,13 +68,13 @@ onMounted(async () => {
<div>
<h2 class="settings-title">File Naming</h2>
<p class="settings-subtitle">
Control how uploaded files are named and organized on disk. Patterns apply when a file is uploaded.
Control naming patterns for uploads and downloads using the same placeholder syntax.
</p>
</div>
<!-- Global default -->
<!-- Upload global default -->
<div>
<p class="settings-group-label">Default Pattern</p>
<p class="settings-group-label">Upload 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.
@@ -101,6 +109,43 @@ onMounted(async () => {
</div>
</div>
<!-- Download global default -->
<div>
<p class="settings-group-label">Download 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">
Controls suggested filenames for single-file downloads and files inside export ZIPs.
</p>
<div class="space-y-1.5">
<div class="flex gap-2">
<input
:value="downloadPattern"
type="text"
:placeholder="DEFAULT_DOWNLOAD_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="downloadError ? 'border-destructive focus:ring-destructive' : ''"
:disabled="loadingDownload"
@input="onDownloadPatternInput(($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="savingDownload || !!downloadError || loadingDownload"
@click="saveDownloadPattern"
>
<Loader2 v-if="savingDownload" :size="13" class="animate-spin" />
<Save v-else :size="13" />
Save
</button>
</div>
<p v-if="downloadError" class="text-xs text-destructive">{{ downloadError }}</p>
<p class="text-xs text-muted-foreground font-mono">
Preview: <span class="text-foreground">{{ previewDownloadName(downloadPattern) }}</span>
</p>
</div>
</div>
</div>
<!-- Per-library overrides -->
<div>
<p class="settings-group-label">Library Overrides</p>
@@ -229,7 +274,7 @@ onMounted(async () => {
<p class="text-xs font-medium text-foreground mb-1.5">Folder-only mode</p>
<p class="text-xs text-muted-foreground">
End the pattern with <code class="bg-muted px-1 py-0.5 rounded font-mono text-foreground">/</code> to specify a folder only - the
original filename is preserved inside it.
original filename is preserved inside it. This affects upload destinations and ZIP export paths.
</p>
</div>
</div>
@@ -1,7 +1,13 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
import { api } from '@/lib/api'
import { EXAMPLE_PATTERN_METADATA, resolveUploadPath, validatePattern } from '@projectx/types'
import {
DEFAULT_DOWNLOAD_PATTERN,
EXAMPLE_PATTERN_METADATA,
resolveDownloadFilename,
resolveUploadPath,
validatePattern,
} from '@projectx/types'
import type { Library } from '@projectx/types'
import { useLibraries } from '@/features/library/composables/useLibraries'
@@ -14,11 +20,20 @@ export function previewPath(pattern: string): string {
return resolved.startsWith('/') ? resolved : `/${resolved}`
}
export function previewDownloadName(pattern: string): string {
const resolved = resolveDownloadFilename(pattern || DEFAULT_DOWNLOAD_PATTERN, EXAMPLE_PATTERN_METADATA, 'epub')
return resolved || 'neuromancer.epub'
}
export function useFileNamingPattern() {
const globalPattern = ref('')
const globalError = ref('')
const loadingGlobal = ref(false)
const savingGlobal = ref(false)
const downloadPattern = ref('')
const downloadError = ref('')
const loadingDownload = ref(false)
const savingDownload = ref(false)
const savingLibraryId = ref<number | null>(null)
const { libraries, fetchLibraries } = useLibraries()
@@ -36,11 +51,29 @@ export function useFileNamingPattern() {
}
}
async function fetchDownloadPattern() {
loadingDownload.value = true
try {
const res = await api('/api/v1/app-settings/download-pattern')
if (res.ok) {
const data: { pattern: string } = await res.json()
downloadPattern.value = data.pattern
}
} finally {
loadingDownload.value = false
}
}
function onGlobalPatternInput(value: string) {
globalPattern.value = value
globalError.value = value && !validatePattern(value) ? 'Pattern contains invalid characters' : ''
}
function onDownloadPatternInput(value: string) {
downloadPattern.value = value
downloadError.value = value && !validatePattern(value) ? 'Pattern contains invalid characters' : ''
}
async function saveGlobalPattern() {
if (globalError.value) return
savingGlobal.value = true
@@ -60,6 +93,25 @@ export function useFileNamingPattern() {
}
}
async function saveDownloadPattern() {
if (downloadError.value) return
savingDownload.value = true
try {
const res = await api('/api/v1/app-settings/download-pattern', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pattern: downloadPattern.value }),
})
if (res.ok) {
toast.success('Download pattern saved')
} else {
toast.error('Failed to save download pattern')
}
} finally {
savingDownload.value = false
}
}
async function saveLibraryPattern(library: Library) {
savingLibraryId.value = library.id
try {
@@ -90,17 +142,25 @@ export function useFileNamingPattern() {
return {
globalPattern,
globalError,
downloadPattern,
downloadError,
libraries,
loadingGlobal,
savingGlobal,
loadingDownload,
savingDownload,
savingLibraryId,
fetchGlobalPattern,
fetchDownloadPattern,
fetchLibraries,
onGlobalPatternInput,
onDownloadPatternInput,
saveGlobalPattern,
saveDownloadPattern,
saveLibraryPattern,
clearLibraryPattern,
getEffectivePreview,
previewPath,
previewDownloadName,
}
}