mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(books): export single-book zip with pattern-based filename
Use the user's configured download pattern to name single-book zip archives instead of defaulting to a generic 'books.zip'. Update the frontend reader cover card to download multi-track audiobooks as zip files directly.
This commit is contained in:
@@ -73,8 +73,10 @@ vi.mock('@/composables/useDisplaySettings', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockDownloadFile = vi.fn<() => void>()
|
||||
const mockExportBooks = vi.fn<() => void>()
|
||||
type ExportScope = 'primary' | 'all' | 'audio'
|
||||
|
||||
const mockDownloadFile = vi.fn<(fileId: number) => void>()
|
||||
const mockExportBooks = vi.fn<(bookIds: number[], allFormats: boolean, scopeOverride?: ExportScope) => void>()
|
||||
vi.mock('@/features/book/composables/useBookDownload', () => ({
|
||||
useBookDownload: () => ({ downloadFile: mockDownloadFile, exportBooks: mockExportBooks }),
|
||||
}))
|
||||
@@ -580,6 +582,49 @@ describe('BookCoverCard', () => {
|
||||
const wrapper = mountCard({ selectionMode: true })
|
||||
expect(wrapper.find('[data-testid="dropdown-menu"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('downloads a pure multi-track audiobook as an audio ZIP', async () => {
|
||||
const book = makeBook({
|
||||
id: 42,
|
||||
files: [makeFile({ id: 10, format: 'mp3', role: 'primary' }), makeFile({ id: 11, format: 'mp3', role: 'secondary' })],
|
||||
})
|
||||
const wrapper = mountCard({ book })
|
||||
const downloadItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Download')
|
||||
|
||||
expect(downloadItem).toBeDefined()
|
||||
await downloadItem!.trigger('click')
|
||||
|
||||
expect(mockExportBooks).toHaveBeenCalledWith([42], false, 'audio')
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downloads the audio entry in a mixed multi-track audiobook as a ZIP while keeping ebook files single-file', async () => {
|
||||
const book = makeBook({
|
||||
id: 77,
|
||||
files: [
|
||||
makeFile({ id: 20, format: 'mp3', role: 'primary' }),
|
||||
makeFile({ id: 21, format: 'mp3', role: 'secondary' }),
|
||||
makeFile({ id: 22, format: 'epub', role: 'secondary' }),
|
||||
],
|
||||
})
|
||||
const wrapper = mountCard({ book })
|
||||
const audioZipItem = wrapper.findAll('[data-testid="dropdown-item"]').find((item) => item.text().trim() === 'Audiobook (ZIP)')
|
||||
|
||||
expect(audioZipItem).toBeDefined()
|
||||
await audioZipItem!.trigger('click')
|
||||
expect(mockExportBooks).toHaveBeenCalledWith([77], false, 'audio')
|
||||
expect(mockDownloadFile).not.toHaveBeenCalled()
|
||||
|
||||
mockExportBooks.mockClear()
|
||||
mockDownloadFile.mockClear()
|
||||
|
||||
const epubItems = wrapper.findAll('[data-testid="dropdown-item"]').filter((item) => item.text().trim() === 'EPUB')
|
||||
expect(epubItems).toHaveLength(2)
|
||||
await epubItems[1]!.trigger('click')
|
||||
|
||||
expect(mockDownloadFile).toHaveBeenCalledWith(22)
|
||||
expect(mockExportBooks).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// ── cover aspect ratio ────────────────────────────────────────────────────
|
||||
|
||||
@@ -334,7 +334,15 @@ const showBelowCoverLabelArea = computed(() => props.showLabel && cardInfoMode.v
|
||||
|
||||
const { downloadFile, exportBooks } = useBookDownload()
|
||||
|
||||
function isAudioFile(file: BookFileRef) {
|
||||
return !!file.format && FORMAT_TO_GROUP[file.format] === 'audio'
|
||||
}
|
||||
|
||||
function handleDownloadFile(file: BookFileRef) {
|
||||
if (isMultiTrackAudio.value && isAudioFile(file)) {
|
||||
void exportBooks([props.book.id], false, 'audio')
|
||||
return
|
||||
}
|
||||
void downloadFile(file.id)
|
||||
}
|
||||
|
||||
@@ -614,7 +622,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="handleDownloadFile(file)">
|
||||
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
|
||||
<span v-if="isMultiTrackAudio && isAudioFile(file)">Audiobook (ZIP)</span>
|
||||
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -748,7 +756,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem v-for="file in openableFiles" :key="file.id" @click="handleDownloadFile(file)">
|
||||
<span v-if="isMultiTrackAudio && FORMAT_TO_GROUP[file.format!] === 'audio'">Audiobook</span>
|
||||
<span v-if="isMultiTrackAudio && isAudioFile(file)">Audiobook (ZIP)</span>
|
||||
<span v-else>{{ file.format?.toUpperCase() ?? '?' }}</span>
|
||||
<span v-if="file.role === 'primary' && !isMultiTrackAudio" class="ml-auto pl-4 text-[10px] text-primary/70">Primary</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('useBookDownload', () => {
|
||||
expect(anchor.href).toContain('/api/v1/books/export/download?')
|
||||
expect(anchor.href).toContain('bookIds=1%2C2')
|
||||
expect(anchor.href).toContain('scope=primary')
|
||||
expect(anchor.download).toBe('')
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
clickSpy.mockRestore()
|
||||
@@ -68,6 +69,7 @@ describe('useBookDownload', () => {
|
||||
|
||||
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.href).toContain('scope=all')
|
||||
expect(anchor.download).toBe('')
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
clickSpy.mockRestore()
|
||||
@@ -85,6 +87,7 @@ describe('useBookDownload', () => {
|
||||
|
||||
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
|
||||
expect(anchor.href).toContain('scope=audio')
|
||||
expect(anchor.download).toBe('')
|
||||
expect(clickSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
clickSpy.mockRestore()
|
||||
|
||||
@@ -39,7 +39,7 @@ export function useBookDownload() {
|
||||
scope,
|
||||
})
|
||||
toast.dismiss(toastId)
|
||||
triggerBrowserDownload(`/api/v1/books/export/download?${params.toString()}`, 'books.zip')
|
||||
triggerBrowserDownload(`/api/v1/books/export/download?${params.toString()}`)
|
||||
} catch {
|
||||
toast.dismiss(toastId)
|
||||
toast.error('Export failed')
|
||||
|
||||
@@ -414,12 +414,16 @@ describe('BookController', () => {
|
||||
projectedBytes: 30,
|
||||
bookCount: 2,
|
||||
scope: 'primary',
|
||||
archiveFilename: 'Selected Books.zip',
|
||||
});
|
||||
|
||||
await controller.exportBooks({ bookIds: [1, 2], allFormats: false }, makeUser(), reply);
|
||||
|
||||
expect(raw.setHeader).toHaveBeenCalledWith('Content-Type', 'application/zip');
|
||||
expect(raw.setHeader).toHaveBeenCalledWith('Content-Disposition', 'attachment; filename="books.zip"');
|
||||
expect(raw.setHeader).toHaveBeenCalledWith(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="Selected Books.zip"; filename*=UTF-8''Selected%20Books.zip`,
|
||||
);
|
||||
|
||||
const zipArchiveMock = ZipArchive as unknown as vi.Mock;
|
||||
expect(zipArchiveMock).toHaveBeenCalledWith({ zlib: { level: 0 } });
|
||||
|
||||
@@ -281,7 +281,7 @@ export class BookController {
|
||||
projectedBytes = plan.projectedBytes;
|
||||
|
||||
reply.raw.setHeader('Content-Type', 'application/zip');
|
||||
reply.raw.setHeader('Content-Disposition', 'attachment; filename="books.zip"');
|
||||
reply.raw.setHeader('Content-Disposition', contentDispositionHeader('attachment', plan.archiveFilename || 'books.zip', 'books.zip'));
|
||||
archive.pipe(reply.raw);
|
||||
for (const file of plan.files) {
|
||||
archive.file(file.absolutePath, { name: file.zipPath });
|
||||
|
||||
@@ -461,6 +461,55 @@ describe('BookService', () => {
|
||||
{ absolutePath: '/books/a.epub', zipPath: 'Duplicate.epub', sizeBytes: 100 },
|
||||
{ absolutePath: '/books/b.epub', zipPath: 'Duplicate (2).epub', sizeBytes: 200 },
|
||||
]);
|
||||
expect(plan.archiveFilename).toBe('books.zip');
|
||||
});
|
||||
|
||||
it('uses the download pattern for single-book export archive filenames', async () => {
|
||||
const { service, appSettings, bookRepo } = makeService();
|
||||
const user = makeUser();
|
||||
|
||||
appSettings.getDownloadPattern.mockResolvedValue('{authors:first} - {title}');
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
|
||||
bookRepo.findAllFilesByBookIds.mockResolvedValue([
|
||||
{ bookId: 1, absolutePath: '/books/track-01.mp3', format: 'mp3', sizeBytes: 5, sortOrder: 0 },
|
||||
{ bookId: 1, absolutePath: '/books/track-02.mp3', format: 'mp3', sizeBytes: 6, sortOrder: 1 },
|
||||
]);
|
||||
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Dune', authors: ['Frank Herbert'] })]);
|
||||
|
||||
const plan = await service.getExportFiles([1], user, 'audio');
|
||||
|
||||
expect(plan.archiveFilename).toBe('Frank Herbert - Dune.zip');
|
||||
});
|
||||
|
||||
it('uses the book title as originalFilename for single-book audiobook archive filenames', async () => {
|
||||
const { service, appSettings, bookRepo } = makeService();
|
||||
const user = makeUser();
|
||||
|
||||
appSettings.getDownloadPattern.mockResolvedValue('{originalFilename}');
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
|
||||
bookRepo.findAllFilesByBookIds.mockResolvedValue([
|
||||
{ bookId: 1, absolutePath: '/books/CH01 THE DARK LORD ASCENDING.mp3', format: 'mp3', sizeBytes: 5, sortOrder: 0 },
|
||||
{ bookId: 1, absolutePath: '/books/CH02 IN MEMORIAM.mp3', format: 'mp3', sizeBytes: 6, sortOrder: 1 },
|
||||
]);
|
||||
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Harry Potter and the Deathly Hallows' })]);
|
||||
|
||||
const plan = await service.getExportFiles([1], user, 'audio');
|
||||
|
||||
expect(plan.archiveFilename).toBe('Harry Potter and the Deathly Hallows.zip');
|
||||
});
|
||||
|
||||
it('falls back to the original stem when a single-book archive pattern cannot resolve', async () => {
|
||||
const { service, appSettings, bookRepo } = makeService();
|
||||
const user = makeUser();
|
||||
|
||||
appSettings.getDownloadPattern.mockResolvedValue('{series}');
|
||||
bookRepo.findLibraryIdsByBookIds.mockResolvedValue([{ id: 1, libraryId: 77 }]);
|
||||
bookRepo.findPrimaryFilesByBookIds.mockResolvedValue([{ bookId: 1, absolutePath: '/books/source.epub', format: 'epub', sizeBytes: 100 }]);
|
||||
bookRepo.findPatternMetadataByBookIds.mockResolvedValue([metaRow(1, { title: 'Standalone' })]);
|
||||
|
||||
const plan = await service.getExportFiles([1], user, 'primary');
|
||||
|
||||
expect(plan.archiveFilename).toBe('source.zip');
|
||||
});
|
||||
|
||||
it('sanitizes unsafe path segments in generated zip paths', async () => {
|
||||
|
||||
@@ -145,6 +145,7 @@ export type ExportPlan = {
|
||||
projectedBytes: number;
|
||||
bookCount: number;
|
||||
scope: ExportScope;
|
||||
archiveFilename: string;
|
||||
};
|
||||
|
||||
const METADATA_EXPORT_SCHEMA_VERSION = 1 as const;
|
||||
@@ -1219,6 +1220,30 @@ export class BookService {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private resolveExportArchiveFilename(
|
||||
bookCount: number,
|
||||
orderedFiles: ExportCandidateFile[],
|
||||
metadataByBookId: Map<number, Awaited<ReturnType<BookRepository['findPatternMetadataByBookIds']>>[number]>,
|
||||
pattern: string,
|
||||
): string {
|
||||
if (bookCount !== 1) return 'books.zip';
|
||||
|
||||
const firstFile = orderedFiles[0];
|
||||
if (!firstFile) return 'books.zip';
|
||||
|
||||
const originalStem = basename(firstFile.absolutePath, extname(firstFile.absolutePath)).trim() || 'book';
|
||||
const fallback = this.sanitizeFilenameSegment(`${originalStem}.zip`, 'book.zip');
|
||||
const meta = metadataByBookId.get(firstFile.bookId);
|
||||
const tokens = {
|
||||
...this.buildDownloadPatternTokens(firstFile.absolutePath, firstFile.format, meta),
|
||||
originalFilename: meta?.title?.trim() || originalStem,
|
||||
extension: 'zip',
|
||||
};
|
||||
const resolvedPath = resolveUploadPath(pattern || DEFAULT_DOWNLOAD_PATTERN, tokens, 'zip');
|
||||
const resolvedName = resolvedPath?.split('/').filter(Boolean).pop() ?? null;
|
||||
return this.sanitizeFilenameSegment(resolvedName ?? fallback, fallback);
|
||||
}
|
||||
|
||||
private buildDownloadPatternTokens(
|
||||
absolutePath: string,
|
||||
format: string | null,
|
||||
@@ -2738,6 +2763,7 @@ export class BookService {
|
||||
this.bookRepo.findPatternMetadataByBookIds([...new Set(orderedFiles.map((f) => f.bookId))]),
|
||||
]);
|
||||
const metadataByBookId = new Map(metadataRows.map((row) => [row.bookId, row]));
|
||||
const archiveFilename = this.resolveExportArchiveFilename(uniqueBookIds.length, orderedFiles, metadataByBookId, pattern);
|
||||
const usedPaths = new Set<string>();
|
||||
let projectedBytes = 0;
|
||||
|
||||
@@ -2766,6 +2792,7 @@ export class BookService {
|
||||
projectedBytes,
|
||||
bookCount: uniqueBookIds.length,
|
||||
scope,
|
||||
archiveFilename,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorClass = err instanceof Error ? err.name : 'Error';
|
||||
|
||||
Reference in New Issue
Block a user