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() { 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,
} }
} }
+22
View File
@@ -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 = {
@@ -18,7 +18,7 @@ function IsValidPattern(options?: ValidationOptions) {
}; };
} }
export class UpdateUploadPatternDto { export class UpdateFilePatternDto {
@IsString() @IsString()
@MaxLength(500) @MaxLength(500)
@IsValidPattern() @IsValidPattern()
+39 -2
View File
@@ -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) {
+2 -1
View File
@@ -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();
});
});
+125 -5
View File
@@ -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', () => {