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() {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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> = {
|
||||
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);
|
||||
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 { AppSettingsService } from './app-settings.service';
|
||||
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 { UpdateUploadPatternDto } from './dto/update-upload-pattern.dto';
|
||||
|
||||
@Controller('app-settings')
|
||||
@RequirePermission('manage_app_settings')
|
||||
@@ -31,11 +31,23 @@ export class AppSettingsController {
|
||||
|
||||
@Put('upload-pattern')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async setUploadPattern(@Body() dto: UpdateUploadPatternDto) {
|
||||
async setUploadPattern(@Body() dto: UpdateFilePatternDto) {
|
||||
await this.appSettingsService.setUploadPattern(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()
|
||||
@Get('oidc/public')
|
||||
async getOidcPublicConfig() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as schema from '../../db/schema';
|
||||
const APP_SETTING_KEYS = {
|
||||
OIDC_CONFIG: 'oidc_config',
|
||||
UPLOAD_FILE_PATTERN: 'upload_file_pattern',
|
||||
DOWNLOAD_FILE_PATTERN: 'download_file_pattern',
|
||||
STAGING_AUTO_FETCH_METADATA: 'staging_auto_fetch_metadata',
|
||||
STAGING_AUTO_FINALIZE_ENABLED: 'staging_auto_finalize_enabled',
|
||||
STAGING_AUTO_FINALIZE_THRESHOLD: 'staging_auto_finalize_threshold',
|
||||
@@ -19,6 +20,7 @@ const APP_SETTING_KEYS = {
|
||||
} as const;
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
const DEFAULT_DOWNLOAD_PATTERN = '{originalFilename}';
|
||||
|
||||
export interface OidcFullConfig {
|
||||
enabled: boolean;
|
||||
@@ -99,6 +101,18 @@ export class AppSettingsService {
|
||||
.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> {
|
||||
const current = await this.getOidcConfig();
|
||||
const merged: OidcFullConfig = {
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ function IsValidPattern(options?: ValidationOptions) {
|
||||
};
|
||||
}
|
||||
|
||||
export class UpdateUploadPatternDto {
|
||||
export class UpdateFilePatternDto {
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
@IsValidPattern()
|
||||
@@ -32,6 +32,34 @@ import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
||||
import { SearchBooksDto } from './dto/search-books.dto';
|
||||
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')
|
||||
export class BookController {
|
||||
constructor(
|
||||
@@ -170,12 +198,21 @@ export class BookController {
|
||||
@Param('fileId', ParseIntPipe) fileId: number,
|
||||
@CurrentUser() user: RequestUser,
|
||||
@Headers('range') rangeHeader: string | undefined,
|
||||
@Query('download') download: string | undefined,
|
||||
@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 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('Content-Disposition', 'inline');
|
||||
const disposition = `${isDownload ? 'attachment' : 'inline'}; filename="${asciiFilename}"`;
|
||||
reply.header('Content-Disposition', encodedFilename ? `${disposition}; filename*=UTF-8''${encodedFilename}` : disposition);
|
||||
reply.type(mimeType);
|
||||
|
||||
if (rangeHeader) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
|
||||
import { AppSettingsModule } from '../app-settings/app-settings.module';
|
||||
import { EmbeddingModule } from '../embedding/embedding.module';
|
||||
import { FileWriteModule } from '../file-write/file-write.module';
|
||||
import { LibraryModule } from '../library/library.module';
|
||||
@@ -11,7 +12,7 @@ import { BookRepository } from './book.repository';
|
||||
import { BookService } from './book.service';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => LibraryModule), MetadataModule, EmbeddingModule, MetadataFetchModule, FileWriteModule],
|
||||
imports: [forwardRef(() => LibraryModule), MetadataModule, EmbeddingModule, MetadataFetchModule, FileWriteModule, AppSettingsModule],
|
||||
controllers: [BookController],
|
||||
providers: [BookService, BookRepository, BookQueryBuilder],
|
||||
exports: [BookService, BookRepository, BookQueryBuilder],
|
||||
|
||||
@@ -25,6 +25,18 @@ import {
|
||||
} from '../../db/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()
|
||||
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));
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const rows = await this.db.select({ id: books.id }).from(books);
|
||||
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 { ConfigService } from '@nestjs/config';
|
||||
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 { assembleBookCards } from './utils/assemble-book-cards';
|
||||
import type { RequestUser } from '../../common/types/request-user';
|
||||
import { AppSettingsService } from '../app-settings/app-settings.service';
|
||||
import { BookEmbedderService } from '../embedding/book-embedder.service';
|
||||
import { MetadataService } from '../metadata/metadata.service';
|
||||
import { LibraryService } from '../library/library.service';
|
||||
@@ -23,6 +24,7 @@ import { UpdateBookMetadataDto } from './dto/update-book-metadata.dto';
|
||||
export class BookService {
|
||||
private readonly logger = new Logger(BookService.name);
|
||||
private readonly booksPath: string;
|
||||
private static readonly DEFAULT_DOWNLOAD_PATTERN = '{originalFilename}';
|
||||
|
||||
constructor(
|
||||
private readonly bookRepo: BookRepository,
|
||||
@@ -31,6 +33,7 @@ export class BookService {
|
||||
private readonly metadataService: MetadataService,
|
||||
private readonly pipeline: MetadataFetchPipeline,
|
||||
private readonly config: ConfigService,
|
||||
private readonly appSettings: AppSettingsService,
|
||||
@Optional() private readonly embedder: BookEmbedderService,
|
||||
@Optional() private readonly fileWriteService: FileWriteService,
|
||||
) {
|
||||
@@ -117,10 +120,112 @@ export class BookService {
|
||||
.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 { 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) {
|
||||
@@ -403,7 +508,22 @@ export class BookService {
|
||||
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);
|
||||
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> {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
applyModifier,
|
||||
DEFAULT_DOWNLOAD_PATTERN,
|
||||
DEFAULT_UPLOAD_PATTERN,
|
||||
EXAMPLE_PATTERN_METADATA,
|
||||
replacePlaceholders,
|
||||
resolveDownloadFilename,
|
||||
resolveUploadPath,
|
||||
validatePattern,
|
||||
} 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 ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('validatePattern', () => {
|
||||
|
||||
Reference in New Issue
Block a user