mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat: add configurable download filename patterns
This commit is contained in:
@@ -228,8 +228,7 @@ function openBook() {
|
|||||||
function downloadFile() {
|
function downloadFile() {
|
||||||
if (!primaryFile.value) return
|
if (!primaryFile.value) return
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = `/api/v1/books/files/${primaryFile.value.id}/serve`
|
a.href = `/api/v1/books/files/${primaryFile.value.id}/serve?download=1`
|
||||||
a.download = `book.${primaryFile.value.format ?? 'epub'}`
|
|
||||||
a.click()
|
a.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ function openFile(file: BookDetailFile) {
|
|||||||
|
|
||||||
function downloadFile(file: BookDetailFile) {
|
function downloadFile(file: BookDetailFile) {
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = `/api/v1/books/files/${file.id}/serve`
|
a.href = `/api/v1/books/files/${file.id}/serve?download=1`
|
||||||
a.download = file.filename ?? `book.${file.format ?? 'epub'}`
|
|
||||||
a.click()
|
a.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -260,8 +260,7 @@ function currentZoomValue(): string {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<a
|
<a
|
||||||
:href="`/api/v1/books/files/${fileId}/serve`"
|
:href="`/api/v1/books/files/${fileId}/serve?download=1`"
|
||||||
download
|
|
||||||
class="viewer-btn flex items-center justify-center"
|
class="viewer-btn flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<Download :size="14" />
|
<Download :size="14" />
|
||||||
@@ -346,4 +345,3 @@ function currentZoomValue(): string {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,32 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { Check, ChevronDown, ChevronUp, FileText, Loader2, RotateCcw, Save } from 'lucide-vue-next'
|
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 { useFileNamingPattern } from './composables/useFileNamingPattern'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
globalPattern,
|
globalPattern,
|
||||||
globalError,
|
globalError,
|
||||||
|
downloadPattern,
|
||||||
|
downloadError,
|
||||||
libraries,
|
libraries,
|
||||||
loadingGlobal,
|
loadingGlobal,
|
||||||
savingGlobal,
|
savingGlobal,
|
||||||
|
loadingDownload,
|
||||||
|
savingDownload,
|
||||||
savingLibraryId,
|
savingLibraryId,
|
||||||
fetchGlobalPattern,
|
fetchGlobalPattern,
|
||||||
|
fetchDownloadPattern,
|
||||||
fetchLibraries,
|
fetchLibraries,
|
||||||
onGlobalPatternInput,
|
onGlobalPatternInput,
|
||||||
|
onDownloadPatternInput,
|
||||||
saveGlobalPattern,
|
saveGlobalPattern,
|
||||||
|
saveDownloadPattern,
|
||||||
saveLibraryPattern,
|
saveLibraryPattern,
|
||||||
clearLibraryPattern,
|
clearLibraryPattern,
|
||||||
getEffectivePreview,
|
getEffectivePreview,
|
||||||
|
previewDownloadName,
|
||||||
previewPath,
|
previewPath,
|
||||||
} = useFileNamingPattern()
|
} = useFileNamingPattern()
|
||||||
|
|
||||||
@@ -50,7 +58,7 @@ const EXAMPLES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([fetchGlobalPattern(), fetchLibraries()])
|
await Promise.all([fetchGlobalPattern(), fetchDownloadPattern(), fetchLibraries()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -60,13 +68,13 @@ onMounted(async () => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="settings-title">File Naming</h2>
|
<h2 class="settings-title">File Naming</h2>
|
||||||
<p class="settings-subtitle">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global default -->
|
<!-- Upload global default -->
|
||||||
<div>
|
<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">
|
<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">
|
<p class="text-xs text-muted-foreground leading-relaxed">
|
||||||
Applied to all libraries unless overridden. Leave blank to use the built-in default layout.
|
Applied to all libraries unless overridden. Leave blank to use the built-in default layout.
|
||||||
@@ -101,6 +109,43 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Per-library overrides -->
|
||||||
<div>
|
<div>
|
||||||
<p class="settings-group-label">Library Overrides</p>
|
<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 font-medium text-foreground mb-1.5">Folder-only mode</p>
|
||||||
<p class="text-xs text-muted-foreground">
|
<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
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { api } from '@/lib/api'
|
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 type { Library } from '@projectx/types'
|
||||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||||
|
|
||||||
@@ -14,11 +20,20 @@ export function previewPath(pattern: string): string {
|
|||||||
return resolved.startsWith('/') ? resolved : `/${resolved}`
|
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() {
|
export function useFileNamingPattern() {
|
||||||
const globalPattern = ref('')
|
const globalPattern = ref('')
|
||||||
const globalError = ref('')
|
const globalError = ref('')
|
||||||
const loadingGlobal = ref(false)
|
const loadingGlobal = ref(false)
|
||||||
const savingGlobal = 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 savingLibraryId = ref<number | null>(null)
|
||||||
|
|
||||||
const { libraries, fetchLibraries } = useLibraries()
|
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) {
|
function onGlobalPatternInput(value: string) {
|
||||||
globalPattern.value = value
|
globalPattern.value = value
|
||||||
globalError.value = value && !validatePattern(value) ? 'Pattern contains invalid characters' : ''
|
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() {
|
async function saveGlobalPattern() {
|
||||||
if (globalError.value) return
|
if (globalError.value) return
|
||||||
savingGlobal.value = true
|
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) {
|
async function saveLibraryPattern(library: Library) {
|
||||||
savingLibraryId.value = library.id
|
savingLibraryId.value = library.id
|
||||||
try {
|
try {
|
||||||
@@ -90,17 +142,25 @@ export function useFileNamingPattern() {
|
|||||||
return {
|
return {
|
||||||
globalPattern,
|
globalPattern,
|
||||||
globalError,
|
globalError,
|
||||||
|
downloadPattern,
|
||||||
|
downloadError,
|
||||||
libraries,
|
libraries,
|
||||||
loadingGlobal,
|
loadingGlobal,
|
||||||
savingGlobal,
|
savingGlobal,
|
||||||
|
loadingDownload,
|
||||||
|
savingDownload,
|
||||||
savingLibraryId,
|
savingLibraryId,
|
||||||
fetchGlobalPattern,
|
fetchGlobalPattern,
|
||||||
|
fetchDownloadPattern,
|
||||||
fetchLibraries,
|
fetchLibraries,
|
||||||
onGlobalPatternInput,
|
onGlobalPatternInput,
|
||||||
|
onDownloadPatternInput,
|
||||||
saveGlobalPattern,
|
saveGlobalPattern,
|
||||||
|
saveDownloadPattern,
|
||||||
saveLibraryPattern,
|
saveLibraryPattern,
|
||||||
clearLibraryPattern,
|
clearLibraryPattern,
|
||||||
getEffectivePreview,
|
getEffectivePreview,
|
||||||
previewPath,
|
previewPath,
|
||||||
|
previewDownloadName,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export const DEFAULT_UPLOAD_PATTERN = '<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. >{title}< ({year})>';
|
export const DEFAULT_UPLOAD_PATTERN = '<{authors:first}|Unknown Author>/<{series}/><{seriesIndex}. >{title}< ({year})>';
|
||||||
|
export const DEFAULT_DOWNLOAD_PATTERN = '{originalFilename}';
|
||||||
|
|
||||||
export const EXAMPLE_PATTERN_METADATA: Record<string, string> = {
|
export const EXAMPLE_PATTERN_METADATA: Record<string, string> = {
|
||||||
title: 'Neuromancer',
|
title: 'Neuromancer',
|
||||||
@@ -116,3 +117,24 @@ export function resolveUploadPath(pattern: string, values: Record<string, string
|
|||||||
const hasExt = /\.[a-z0-9]{2,5}$/i.test(lastSegment);
|
const hasExt = /\.[a-z0-9]{2,5}$/i.test(lastSegment);
|
||||||
return hasExt ? resolved : resolved + dotExt;
|
return hasExt ? resolved : resolved + dotExt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a file naming pattern to a filename (no path separators).
|
||||||
|
* - If the resolved pattern contains folder segments, only the last segment is used.
|
||||||
|
* - If the resolved pattern ends with '/', originalFilename is used.
|
||||||
|
* - Extension is appended when missing.
|
||||||
|
*
|
||||||
|
* Returns null if the pattern resolves to an empty filename.
|
||||||
|
*/
|
||||||
|
export function resolveDownloadFilename(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}`;
|
||||||
|
let stem = resolved.endsWith('/') ? values['originalFilename'] ?? '' : (resolved.split('/').filter(Boolean).pop() ?? '');
|
||||||
|
stem = stem.trim();
|
||||||
|
if (!stem) return null;
|
||||||
|
|
||||||
|
const hasExt = /\.[a-z0-9]{2,5}$/i.test(stem);
|
||||||
|
return hasExt ? stem : stem + dotExt;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Public } from '../../common/decorators/public.decorator';
|
|||||||
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
|
||||||
import { AppSettingsService } from './app-settings.service';
|
import { AppSettingsService } from './app-settings.service';
|
||||||
import { UpdateAppSettingDto } from './dto/update-app-setting.dto';
|
import { UpdateAppSettingDto } from './dto/update-app-setting.dto';
|
||||||
|
import { UpdateFilePatternDto } from './dto/update-file-pattern.dto';
|
||||||
import { UpdateOidcConfigDto } from './dto/update-oidc-config.dto';
|
import { UpdateOidcConfigDto } from './dto/update-oidc-config.dto';
|
||||||
import { UpdateUploadPatternDto } from './dto/update-upload-pattern.dto';
|
|
||||||
|
|
||||||
@Controller('app-settings')
|
@Controller('app-settings')
|
||||||
@RequirePermission('manage_app_settings')
|
@RequirePermission('manage_app_settings')
|
||||||
@@ -31,11 +31,23 @@ export class AppSettingsController {
|
|||||||
|
|
||||||
@Put('upload-pattern')
|
@Put('upload-pattern')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
async setUploadPattern(@Body() dto: UpdateUploadPatternDto) {
|
async setUploadPattern(@Body() dto: UpdateFilePatternDto) {
|
||||||
await this.appSettingsService.setUploadPattern(dto.pattern);
|
await this.appSettingsService.setUploadPattern(dto.pattern);
|
||||||
return { pattern: dto.pattern };
|
return { pattern: dto.pattern };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('download-pattern')
|
||||||
|
async getDownloadPattern() {
|
||||||
|
return { pattern: await this.appSettingsService.getDownloadPattern() };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('download-pattern')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
async setDownloadPattern(@Body() dto: UpdateFilePatternDto) {
|
||||||
|
await this.appSettingsService.setDownloadPattern(dto.pattern);
|
||||||
|
return { pattern: dto.pattern };
|
||||||
|
}
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Get('oidc/public')
|
@Get('oidc/public')
|
||||||
async getOidcPublicConfig() {
|
async getOidcPublicConfig() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as schema from '../../db/schema';
|
|||||||
const APP_SETTING_KEYS = {
|
const APP_SETTING_KEYS = {
|
||||||
OIDC_CONFIG: 'oidc_config',
|
OIDC_CONFIG: 'oidc_config',
|
||||||
UPLOAD_FILE_PATTERN: 'upload_file_pattern',
|
UPLOAD_FILE_PATTERN: 'upload_file_pattern',
|
||||||
|
DOWNLOAD_FILE_PATTERN: 'download_file_pattern',
|
||||||
STAGING_AUTO_FETCH_METADATA: 'staging_auto_fetch_metadata',
|
STAGING_AUTO_FETCH_METADATA: 'staging_auto_fetch_metadata',
|
||||||
STAGING_AUTO_FINALIZE_ENABLED: 'staging_auto_finalize_enabled',
|
STAGING_AUTO_FINALIZE_ENABLED: 'staging_auto_finalize_enabled',
|
||||||
STAGING_AUTO_FINALIZE_THRESHOLD: 'staging_auto_finalize_threshold',
|
STAGING_AUTO_FINALIZE_THRESHOLD: 'staging_auto_finalize_threshold',
|
||||||
@@ -19,6 +20,7 @@ const APP_SETTING_KEYS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Db = NodePgDatabase<typeof schema>;
|
type Db = NodePgDatabase<typeof schema>;
|
||||||
|
const DEFAULT_DOWNLOAD_PATTERN = '{originalFilename}';
|
||||||
|
|
||||||
export interface OidcFullConfig {
|
export interface OidcFullConfig {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -99,6 +101,18 @@ export class AppSettingsService {
|
|||||||
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value: pattern } });
|
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value: pattern } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDownloadPattern(): Promise<string> {
|
||||||
|
const row = await this.db.query.appSettings.findFirst({ where: eq(schema.appSettings.key, APP_SETTING_KEYS.DOWNLOAD_FILE_PATTERN) });
|
||||||
|
return row?.value ?? DEFAULT_DOWNLOAD_PATTERN;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDownloadPattern(pattern: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.insert(schema.appSettings)
|
||||||
|
.values({ key: APP_SETTING_KEYS.DOWNLOAD_FILE_PATTERN, value: pattern })
|
||||||
|
.onConflictDoUpdate({ target: schema.appSettings.key, set: { value: pattern } });
|
||||||
|
}
|
||||||
|
|
||||||
async updateOidcConfig(config: Partial<OidcFullConfig>): Promise<OidcFullConfig> {
|
async updateOidcConfig(config: Partial<OidcFullConfig>): Promise<OidcFullConfig> {
|
||||||
const current = await this.getOidcConfig();
|
const current = await this.getOidcConfig();
|
||||||
const merged: OidcFullConfig = {
|
const merged: OidcFullConfig = {
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ function IsValidPattern(options?: ValidationOptions) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateUploadPatternDto {
|
export class UpdateFilePatternDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(500)
|
@MaxLength(500)
|
||||||
@IsValidPattern()
|
@IsValidPattern()
|
||||||
@@ -32,6 +32,34 @@ import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
|||||||
import { SearchBooksDto } from './dto/search-books.dto';
|
import { SearchBooksDto } from './dto/search-books.dto';
|
||||||
import type { BookQuery } from '@projectx/types';
|
import type { BookQuery } from '@projectx/types';
|
||||||
|
|
||||||
|
function stripLoneSurrogates(value: string): string {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
const code = value.charCodeAt(i);
|
||||||
|
if (code >= 0xd800 && code <= 0xdbff) {
|
||||||
|
const next = value.charCodeAt(i + 1);
|
||||||
|
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||||
|
out += value[i] + value[i + 1];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (code >= 0xdc00 && code <= 0xdfff) continue;
|
||||||
|
out += value[i];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeFilenameStar(value: string): string | null {
|
||||||
|
try {
|
||||||
|
const cleaned = stripLoneSurrogates(value);
|
||||||
|
if (!cleaned) return null;
|
||||||
|
return encodeURIComponent(cleaned).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('books')
|
@Controller('books')
|
||||||
export class BookController {
|
export class BookController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -170,12 +198,21 @@ export class BookController {
|
|||||||
@Param('fileId', ParseIntPipe) fileId: number,
|
@Param('fileId', ParseIntPipe) fileId: number,
|
||||||
@CurrentUser() user: RequestUser,
|
@CurrentUser() user: RequestUser,
|
||||||
@Headers('range') rangeHeader: string | undefined,
|
@Headers('range') rangeHeader: string | undefined,
|
||||||
|
@Query('download') download: string | undefined,
|
||||||
@Res() reply: FastifyReply,
|
@Res() reply: FastifyReply,
|
||||||
) {
|
) {
|
||||||
const { path, size, format } = await this.bookService.getFileInfo(fileId, user);
|
const { path, size, format, bookId, originalFilename } = await this.bookService.getFileInfo(fileId, user);
|
||||||
const mimeType = format === 'pdf' ? 'application/pdf' : format === 'cbz' ? 'application/zip' : 'application/epub+zip';
|
const mimeType = format === 'pdf' ? 'application/pdf' : format === 'cbz' ? 'application/zip' : 'application/epub+zip';
|
||||||
|
const isDownload = download === '1' || download === 'true';
|
||||||
|
const filename = isDownload
|
||||||
|
? await this.bookService.resolveDownloadFilename({ bookId, absolutePath: path, format: format === 'unknown' ? null : format })
|
||||||
|
: originalFilename;
|
||||||
|
const asciiFilename = filename.replace(/[^\x20-\x7E]|["\\]/g, '_') || 'download';
|
||||||
|
const encodedFilename = encodeFilenameStar(filename);
|
||||||
|
|
||||||
reply.header('Accept-Ranges', 'bytes');
|
reply.header('Accept-Ranges', 'bytes');
|
||||||
reply.header('Content-Disposition', 'inline');
|
const disposition = `${isDownload ? 'attachment' : 'inline'}; filename="${asciiFilename}"`;
|
||||||
|
reply.header('Content-Disposition', encodedFilename ? `${disposition}; filename*=UTF-8''${encodedFilename}` : disposition);
|
||||||
reply.type(mimeType);
|
reply.type(mimeType);
|
||||||
|
|
||||||
if (rangeHeader) {
|
if (rangeHeader) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||||
import { EmbeddingModule } from '../embedding/embedding.module';
|
import { EmbeddingModule } from '../embedding/embedding.module';
|
||||||
import { FileWriteModule } from '../file-write/file-write.module';
|
import { FileWriteModule } from '../file-write/file-write.module';
|
||||||
import { LibraryModule } from '../library/library.module';
|
import { LibraryModule } from '../library/library.module';
|
||||||
@@ -11,7 +12,7 @@ import { BookRepository } from './book.repository';
|
|||||||
import { BookService } from './book.service';
|
import { BookService } from './book.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => LibraryModule), MetadataModule, EmbeddingModule, MetadataFetchModule, FileWriteModule],
|
imports: [forwardRef(() => LibraryModule), MetadataModule, EmbeddingModule, MetadataFetchModule, FileWriteModule, AppSettingsModule],
|
||||||
controllers: [BookController],
|
controllers: [BookController],
|
||||||
providers: [BookService, BookRepository, BookQueryBuilder],
|
providers: [BookService, BookRepository, BookQueryBuilder],
|
||||||
exports: [BookService, BookRepository, BookQueryBuilder],
|
exports: [BookService, BookRepository, BookQueryBuilder],
|
||||||
|
|||||||
@@ -25,6 +25,18 @@ import {
|
|||||||
} from '../../db/schema';
|
} from '../../db/schema';
|
||||||
|
|
||||||
type Db = NodePgDatabase<typeof schema>;
|
type Db = NodePgDatabase<typeof schema>;
|
||||||
|
type PatternMetadataRow = {
|
||||||
|
bookId: number;
|
||||||
|
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: string[];
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookRepository {
|
export class BookRepository {
|
||||||
@@ -306,6 +318,43 @@ export class BookRepository {
|
|||||||
return this.db.select({ id: books.id, libraryId: books.libraryId }).from(books).where(inArray(books.id, bookIds));
|
return this.db.select({ id: books.id, libraryId: books.libraryId }).from(books).where(inArray(books.id, bookIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findPatternMetadataByBookIds(bookIds: number[]): Promise<PatternMetadataRow[]> {
|
||||||
|
if (bookIds.length === 0) return [];
|
||||||
|
|
||||||
|
const [metaRows, authorRows] = await Promise.all([
|
||||||
|
this.db
|
||||||
|
.select({
|
||||||
|
bookId: books.id,
|
||||||
|
title: bookMetadata.title,
|
||||||
|
subtitle: bookMetadata.subtitle,
|
||||||
|
publisher: bookMetadata.publisher,
|
||||||
|
publishedYear: bookMetadata.publishedYear,
|
||||||
|
language: bookMetadata.language,
|
||||||
|
seriesName: bookMetadata.seriesName,
|
||||||
|
seriesIndex: bookMetadata.seriesIndex,
|
||||||
|
isbn13: bookMetadata.isbn13,
|
||||||
|
})
|
||||||
|
.from(books)
|
||||||
|
.leftJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
|
||||||
|
.where(inArray(books.id, bookIds)),
|
||||||
|
this.db
|
||||||
|
.select({ bookId: bookAuthors.bookId, name: authors.name })
|
||||||
|
.from(bookAuthors)
|
||||||
|
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
|
||||||
|
.where(inArray(bookAuthors.bookId, bookIds))
|
||||||
|
.orderBy(bookAuthors.displayOrder),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const authorsByBookId = new Map<number, string[]>();
|
||||||
|
for (const row of authorRows) {
|
||||||
|
const list = authorsByBookId.get(row.bookId) ?? [];
|
||||||
|
list.push(row.name);
|
||||||
|
authorsByBookId.set(row.bookId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
return metaRows.map((row) => ({ ...row, authors: authorsByBookId.get(row.bookId) ?? [] }));
|
||||||
|
}
|
||||||
|
|
||||||
async findAllIds(): Promise<number[]> {
|
async findAllIds(): Promise<number[]> {
|
||||||
const rows = await this.db.select({ id: books.id }).from(books);
|
const rows = await this.db.select({ id: books.id }).from(books);
|
||||||
return rows.map((r) => r.id);
|
return rows.map((r) => r.id);
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
|
import { BookService } from './book.service';
|
||||||
|
|
||||||
|
function makeUser(): RequestUser {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
username: 'tester',
|
||||||
|
name: 'Tester',
|
||||||
|
email: null,
|
||||||
|
active: true,
|
||||||
|
isDefaultPassword: false,
|
||||||
|
tokenVersion: 1,
|
||||||
|
settings: {},
|
||||||
|
avatarUrl: null,
|
||||||
|
provisioningMethod: 'local',
|
||||||
|
roles: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeService() {
|
||||||
|
const bookRepo = {
|
||||||
|
findPatternMetadataByBookIds: jest.fn(),
|
||||||
|
findLibraryIdsByBookIds: jest.fn(),
|
||||||
|
findPrimaryFilesByBookIds: jest.fn(),
|
||||||
|
findAllFilesByBookIds: jest.fn(),
|
||||||
|
};
|
||||||
|
const libraryService = {
|
||||||
|
verifyUserAccess: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
const config = {
|
||||||
|
get: jest.fn().mockReturnValue('/tmp'),
|
||||||
|
};
|
||||||
|
const appSettings = {
|
||||||
|
getDownloadPattern: jest.fn().mockResolvedValue('{originalFilename}'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new BookService(
|
||||||
|
bookRepo as never,
|
||||||
|
libraryService as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
config as never,
|
||||||
|
appSettings as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, bookRepo, libraryService, appSettings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaRow(bookId: number, fields?: Partial<{ title: string | null; authors: string[] }>) {
|
||||||
|
return {
|
||||||
|
bookId,
|
||||||
|
title: fields?.title ?? null,
|
||||||
|
subtitle: null,
|
||||||
|
publisher: null,
|
||||||
|
publishedYear: null,
|
||||||
|
language: null,
|
||||||
|
seriesName: null,
|
||||||
|
seriesIndex: null,
|
||||||
|
isbn13: null,
|
||||||
|
authors: fields?.authors ?? [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BookService download naming', () => {
|
||||||
|
it('resolves download filename from pattern and metadata', async () => {
|
||||||
|
const { service, appSettings, bookRepo } = makeService();
|
||||||
|
appSettings.getDownloadPattern.mockResolvedValue('<{authors:first} - >{title}');
|
||||||
|
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(10, { title: 'Neuromancer', authors: ['William Gibson'] })]);
|
||||||
|
|
||||||
|
const filename = await service.resolveDownloadFilename({
|
||||||
|
bookId: 10,
|
||||||
|
absolutePath: '/books/original-name.epub',
|
||||||
|
format: 'epub',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filename).toBe('William Gibson - Neuromancer.epub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to sanitized original filename when pattern resolution fails', async () => {
|
||||||
|
const { service, appSettings } = makeService();
|
||||||
|
appSettings.getDownloadPattern.mockRejectedValue(new Error('settings unavailable'));
|
||||||
|
|
||||||
|
const filename = await service.resolveDownloadFilename({
|
||||||
|
bookId: 10,
|
||||||
|
absolutePath: '/books/bad:name?.epub',
|
||||||
|
format: 'epub',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filename).toBe('bad_name_.epub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies pattern to export zip paths and de-duplicates collisions', async () => {
|
||||||
|
const { service, appSettings, bookRepo, libraryService } = makeService();
|
||||||
|
const user = makeUser();
|
||||||
|
|
||||||
|
appSettings.getDownloadPattern.mockResolvedValue('{title}');
|
||||||
|
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([
|
||||||
|
{ id: 1, libraryId: 77 },
|
||||||
|
{ id: 2, libraryId: 77 },
|
||||||
|
]);
|
||||||
|
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([
|
||||||
|
{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub' },
|
||||||
|
{ bookId: 2, absolutePath: '/books/b.epub', format: 'epub' },
|
||||||
|
]);
|
||||||
|
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Duplicate' }), metaRow(2, { title: 'Duplicate' })]);
|
||||||
|
|
||||||
|
const files = await service.getExportFiles([1, 2], user, false);
|
||||||
|
|
||||||
|
expect(libraryService.verifyUserAccess).toHaveBeenCalledWith(user.id, 77, false);
|
||||||
|
expect(files).toEqual([
|
||||||
|
{ absolutePath: '/books/a.epub', zipPath: 'Duplicate.epub' },
|
||||||
|
{ absolutePath: '/books/b.epub', zipPath: 'Duplicate (2).epub' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses all-files query when allFormats is true', async () => {
|
||||||
|
const { service, appSettings, bookRepo } = makeService();
|
||||||
|
const user = makeUser();
|
||||||
|
|
||||||
|
appSettings.getDownloadPattern.mockResolvedValue('{title}');
|
||||||
|
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
|
||||||
|
bookRepo.findAllFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/a.epub', format: 'epub' }]);
|
||||||
|
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'One' })]);
|
||||||
|
|
||||||
|
await service.getExportFiles([1], user, true);
|
||||||
|
|
||||||
|
expect(bookRepo.findAllFilesByBookIds).toHaveBeenCalledWith([1]);
|
||||||
|
expect(bookRepo.findPrimaryFilesByBookIds).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
import { BadRequestException, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common';
|
import { BadRequestException, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { access, readdir, rm, stat } from 'fs/promises';
|
import { access, readdir, rm, stat } from 'fs/promises';
|
||||||
import { basename, join } from 'path';
|
import { basename, extname, join } from 'path';
|
||||||
|
|
||||||
import { MetadataProviderKey } from '@projectx/types';
|
import { MetadataProviderKey, resolveUploadPath } from '@projectx/types';
|
||||||
import type { BookKoboState, BookQuery, BooksPage, MetadataField } from '@projectx/types';
|
import type { BookKoboState, BookQuery, BooksPage, MetadataField } from '@projectx/types';
|
||||||
import { assembleBookCards } from './utils/assemble-book-cards';
|
import { assembleBookCards } from './utils/assemble-book-cards';
|
||||||
import type { RequestUser } from '../../common/types/request-user';
|
import type { RequestUser } from '../../common/types/request-user';
|
||||||
|
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||||
import { BookEmbedderService } from '../embedding/book-embedder.service';
|
import { BookEmbedderService } from '../embedding/book-embedder.service';
|
||||||
import { MetadataService } from '../metadata/metadata.service';
|
import { MetadataService } from '../metadata/metadata.service';
|
||||||
import { LibraryService } from '../library/library.service';
|
import { LibraryService } from '../library/library.service';
|
||||||
@@ -23,6 +24,7 @@ import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
|||||||
export class BookService {
|
export class BookService {
|
||||||
private readonly logger = new Logger(BookService.name);
|
private readonly logger = new Logger(BookService.name);
|
||||||
private readonly booksPath: string;
|
private readonly booksPath: string;
|
||||||
|
private static readonly DEFAULT_DOWNLOAD_PATTERN = '{originalFilename}';
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly bookRepo: BookRepository,
|
private readonly bookRepo: BookRepository,
|
||||||
@@ -31,6 +33,7 @@ export class BookService {
|
|||||||
private readonly metadataService: MetadataService,
|
private readonly metadataService: MetadataService,
|
||||||
private readonly pipeline: MetadataFetchPipeline,
|
private readonly pipeline: MetadataFetchPipeline,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly appSettings: AppSettingsService,
|
||||||
@Optional() private readonly embedder: BookEmbedderService,
|
@Optional() private readonly embedder: BookEmbedderService,
|
||||||
@Optional() private readonly fileWriteService: FileWriteService,
|
@Optional() private readonly fileWriteService: FileWriteService,
|
||||||
) {
|
) {
|
||||||
@@ -117,10 +120,112 @@ export class BookService {
|
|||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getFileInfo(fileId: number, user: RequestUser): Promise<{ path: string; size: number; format: string }> {
|
private formatSeriesIndex(value: number | null): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const whole = Math.floor(value);
|
||||||
|
const fraction = value - whole;
|
||||||
|
const padded = String(whole).padStart(2, '0');
|
||||||
|
return fraction > 0 ? `${padded}.${String(fraction).split('.')[1]}` : padded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeFilenameSegment(raw: string, fallback = 'download'): string {
|
||||||
|
const fallbackSafe = fallback.replace(/[/\\:*?"<>|\0]/g, '_').trim().slice(0, 255) || 'download';
|
||||||
|
const cleaned = raw.replace(/[/\\:*?"<>|\0]/g, '_').trim().slice(0, 255);
|
||||||
|
if (!cleaned || cleaned === '.' || cleaned === '..') return fallbackSafe;
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sanitizeZipPath(rawPath: string, fallbackFilename: string): string {
|
||||||
|
const segments = rawPath
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => this.sanitizeFilenameSegment(segment));
|
||||||
|
if (segments.length === 0) return this.sanitizeFilenameSegment(fallbackFilename);
|
||||||
|
return segments.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private withSuffix(path: string, suffix: number): string {
|
||||||
|
const slash = path.lastIndexOf('/');
|
||||||
|
const dir = slash >= 0 ? path.slice(0, slash + 1) : '';
|
||||||
|
const name = slash >= 0 ? path.slice(slash + 1) : path;
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
if (dot > 0) return `${dir}${name.slice(0, dot)} (${suffix})${name.slice(dot)}`;
|
||||||
|
return `${dir}${name} (${suffix})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeUniqueZipPath(path: string, used: Set<string>): string {
|
||||||
|
if (!used.has(path)) {
|
||||||
|
used.add(path);
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
let suffix = 2;
|
||||||
|
let candidate = this.withSuffix(path, suffix);
|
||||||
|
while (used.has(candidate)) {
|
||||||
|
suffix += 1;
|
||||||
|
candidate = this.withSuffix(path, suffix);
|
||||||
|
}
|
||||||
|
used.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDownloadPatternTokens(
|
||||||
|
absolutePath: string,
|
||||||
|
format: string | null,
|
||||||
|
meta?: Awaited<ReturnType<BookRepository['findPatternMetadataByBookIds']>>[number],
|
||||||
|
): Record<string, string> {
|
||||||
|
const pathExtension = extname(absolutePath).toLowerCase().slice(1);
|
||||||
|
const extension = pathExtension || (format && format !== 'unknown' ? format : 'bin');
|
||||||
|
const stem = basename(absolutePath, extname(absolutePath));
|
||||||
|
const tokens: Record<string, string> = { originalFilename: stem, extension };
|
||||||
|
|
||||||
|
if (!meta) return tokens;
|
||||||
|
if (meta.title) tokens['title'] = meta.title;
|
||||||
|
if (meta.subtitle) tokens['subtitle'] = meta.subtitle;
|
||||||
|
if (meta.publisher) tokens['publisher'] = meta.publisher;
|
||||||
|
if (meta.language) tokens['language'] = meta.language;
|
||||||
|
if (meta.isbn13) tokens['isbn'] = meta.isbn13;
|
||||||
|
if (meta.publishedYear) tokens['year'] = String(meta.publishedYear);
|
||||||
|
if (meta.seriesName) tokens['series'] = meta.seriesName;
|
||||||
|
|
||||||
|
const seriesIndex = this.formatSeriesIndex(meta.seriesIndex);
|
||||||
|
if (seriesIndex) tokens['seriesIndex'] = seriesIndex;
|
||||||
|
if (meta.authors.length > 0) tokens['authors'] = meta.authors.join(', ');
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveDownloadFilenameForFile(file: { bookId: number; absolutePath: string; format: string | null }): Promise<string> {
|
||||||
|
const originalFilename = basename(file.absolutePath);
|
||||||
|
try {
|
||||||
|
const [pattern, metaRows] = await Promise.all([
|
||||||
|
this.appSettings.getDownloadPattern(),
|
||||||
|
this.bookRepo.findPatternMetadataByBookIds([file.bookId]),
|
||||||
|
]);
|
||||||
|
const tokens = this.buildDownloadPatternTokens(file.absolutePath, file.format, metaRows[0]);
|
||||||
|
const resolvedPath = resolveUploadPath(pattern || BookService.DEFAULT_DOWNLOAD_PATTERN, tokens, tokens.extension);
|
||||||
|
const resolvedName = resolvedPath?.split('/').filter(Boolean).pop() ?? null;
|
||||||
|
return this.sanitizeFilenameSegment(resolvedName ?? originalFilename, originalFilename);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Download filename pattern resolution failed for book ${file.bookId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||||
|
);
|
||||||
|
return this.sanitizeFilenameSegment(originalFilename, originalFilename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileInfo(
|
||||||
|
fileId: number,
|
||||||
|
user: RequestUser,
|
||||||
|
): Promise<{ path: string; size: number; format: string; bookId: number; originalFilename: string }> {
|
||||||
const file = await this.verifyFileAccess(fileId, user);
|
const file = await this.verifyFileAccess(fileId, user);
|
||||||
const { size } = await stat(file.absolutePath);
|
const { size } = await stat(file.absolutePath);
|
||||||
return { path: file.absolutePath, size, format: file.format ?? 'unknown' };
|
const originalFilename = basename(file.absolutePath);
|
||||||
|
return { path: file.absolutePath, size, format: file.format ?? 'unknown', bookId: file.bookId, originalFilename };
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveDownloadFilename(file: { bookId: number; absolutePath: string; format: string | null }): Promise<string> {
|
||||||
|
return this.resolveDownloadFilenameForFile(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchAcrossLibraries(q: string, limit: number, user: RequestUser) {
|
async searchAcrossLibraries(q: string, limit: number, user: RequestUser) {
|
||||||
@@ -403,7 +508,22 @@ export class BookService {
|
|||||||
await Promise.all(uniqueLibraryIds.map((libId) => this.libraryService.verifyUserAccess(user.id, libId, isSuperuser)));
|
await Promise.all(uniqueLibraryIds.map((libId) => this.libraryService.verifyUserAccess(user.id, libId, isSuperuser)));
|
||||||
|
|
||||||
const files = allFormats ? await this.bookRepo.findAllFilesByBookIds(bookIds) : await this.bookRepo.findPrimaryFilesByBookIds(bookIds);
|
const files = allFormats ? await this.bookRepo.findAllFilesByBookIds(bookIds) : await this.bookRepo.findPrimaryFilesByBookIds(bookIds);
|
||||||
return files.map((f) => ({ absolutePath: f.absolutePath, zipPath: `${f.bookId}/${basename(f.absolutePath)}` }));
|
const [pattern, metadataRows] = await Promise.all([
|
||||||
|
this.appSettings.getDownloadPattern(),
|
||||||
|
this.bookRepo.findPatternMetadataByBookIds([...new Set(files.map((f) => f.bookId))]),
|
||||||
|
]);
|
||||||
|
const metadataByBookId = new Map(metadataRows.map((row) => [row.bookId, row]));
|
||||||
|
const usedPaths = new Set<string>();
|
||||||
|
|
||||||
|
return files.map((file) => {
|
||||||
|
const tokens = this.buildDownloadPatternTokens(file.absolutePath, file.format, metadataByBookId.get(file.bookId));
|
||||||
|
const resolvedPath = resolveUploadPath(pattern || BookService.DEFAULT_DOWNLOAD_PATTERN, tokens, tokens.extension);
|
||||||
|
const fallbackFilename = basename(file.absolutePath);
|
||||||
|
const rawZipPath = resolvedPath ?? fallbackFilename;
|
||||||
|
const safeZipPath = this.sanitizeZipPath(rawZipPath, fallbackFilename);
|
||||||
|
const zipPath = this.makeUniqueZipPath(safeZipPath, usedPaths);
|
||||||
|
return { absolutePath: file.absolutePath, zipPath };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDetail(id: number, user: RequestUser): Promise<BookDetailDto> {
|
async getDetail(id: number, user: RequestUser): Promise<BookDetailDto> {
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
applyModifier,
|
applyModifier,
|
||||||
|
DEFAULT_DOWNLOAD_PATTERN,
|
||||||
DEFAULT_UPLOAD_PATTERN,
|
DEFAULT_UPLOAD_PATTERN,
|
||||||
EXAMPLE_PATTERN_METADATA,
|
EXAMPLE_PATTERN_METADATA,
|
||||||
replacePlaceholders,
|
replacePlaceholders,
|
||||||
|
resolveDownloadFilename,
|
||||||
resolveUploadPath,
|
resolveUploadPath,
|
||||||
validatePattern,
|
validatePattern,
|
||||||
} from '@projectx/types';
|
} from '@projectx/types';
|
||||||
@@ -334,6 +336,30 @@ describe('resolveUploadPath', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── resolveDownloadFilename ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('resolveDownloadFilename', () => {
|
||||||
|
it('resolves to original filename by default', () => {
|
||||||
|
expect(resolveDownloadFilename(DEFAULT_DOWNLOAD_PATTERN, FULL, 'epub')).toBe('neuromancer.epub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends extension when missing', () => {
|
||||||
|
expect(resolveDownloadFilename('{authors:first} - {title}', FULL, 'epub')).toBe('William Gibson - Neuromancer.epub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only the last segment when folders are present', () => {
|
||||||
|
expect(resolveDownloadFilename('{authors}/{title}', FULL, 'epub')).toBe('Neuromancer.epub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports folder-only patterns by falling back to original filename', () => {
|
||||||
|
expect(resolveDownloadFilename('{authors}/', FULL, 'epub')).toBe('neuromancer.epub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when pattern resolves to an empty name', () => {
|
||||||
|
expect(resolveDownloadFilename('{series}', PARTIAL, 'epub')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ── validatePattern ───────────────────────────────────────────────────────────
|
// ── validatePattern ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('validatePattern', () => {
|
describe('validatePattern', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user