feat(library): add bulk rename tool and e2e coverage (#124)

* feat(library): add bulk rename based on file naming settings

Provides a way to rename all books in a library according to the user's
configured file naming template without requiring manual metadata edits.
Includes a streaming endpoint to report progress back to the frontend.

Closes #121

* test(library): add e2e tests for bulk file rename

Introduce a comprehensive E2E test suite for the bulk rename functionality to verify behavior across different library modes (book per file, book per folder) and handle edge cases like naming collisions or special characters.

Additionally fix a TypeScript compilation error in BulkRenameView.vue by replacing `replaceAll` with a regex-based `replace` for broader target compatibility.

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
Chris Mansell
2026-05-28 09:27:03 -06:00
committed by GitHub
parent 8ee6929f55
commit e659111e4e
33 changed files with 4595 additions and 70 deletions
@@ -0,0 +1,189 @@
import archiver from 'archiver';
import { randomUUID } from 'crypto';
import { createWriteStream } from 'fs';
import { mkdir, writeFile } from 'fs/promises';
import { PDFDocument } from 'pdf-lib';
import { dirname, join } from 'path';
export async function createEpubFile(absolutePath: string, title = 'Fixture Title'): Promise<void> {
await mkdir(dirname(absolutePath), { recursive: true });
const uid = `urn:uuid:${randomUUID()}`;
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`;
const opfXml = `<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">${uid}</dc:identifier>
<dc:title>${escapeXml(title)}</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="ch1" href="chapter.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine>
<itemref idref="ch1"/>
</spine>
</package>`;
const chapterXml = `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeXml(title)}</title></head><body><p>test content</p></body></html>`;
await writeZip(absolutePath, [
{ path: 'mimetype', content: 'application/epub+zip', store: true },
{ path: 'META-INF/container.xml', content: containerXml },
{ path: 'OPS/content.opf', content: opfXml },
{ path: 'OPS/chapter.xhtml', content: chapterXml },
]);
}
export async function createPdfFile(absolutePath: string, title = 'Fixture PDF'): Promise<void> {
await mkdir(dirname(absolutePath), { recursive: true });
const pdf = await PDFDocument.create();
const page = pdf.addPage([360, 240]);
page.drawText(title);
pdf.setTitle(title);
const bytes = await pdf.save();
await writeFile(absolutePath, Buffer.from(bytes));
}
export async function createDummyFile(absolutePath: string, content = 'placeholder'): Promise<void> {
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content);
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
interface ZipEntry {
path: string;
content: string | Buffer;
store?: boolean;
}
async function writeZip(outPath: string, entries: ZipEntry[]): Promise<void> {
const output = createWriteStream(outPath);
const archive = archiver('zip', { zlib: { level: 6 } });
await new Promise<void>((resolve, reject) => {
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
archive.pipe(output);
for (const entry of entries) {
archive.append(typeof entry.content === 'string' ? Buffer.from(entry.content, 'utf8') : entry.content, {
name: entry.path,
store: entry.store ?? false,
});
}
void archive.finalize();
});
}
// ── Factory helpers ──────────────────────────────────────────────────────────
export async function buildBookPerFileFlat(libraryRoot: string): Promise<Array<{ relPath: string; stem: string }>> {
const books = [
{ relPath: 'old-book-alpha.epub', stem: 'old-book-alpha' },
{ relPath: 'old-book-beta.epub', stem: 'old-book-beta' },
{ relPath: 'old-book-gamma.epub', stem: 'old-book-gamma' },
{ relPath: 'old-book-delta.epub', stem: 'old-book-delta' },
];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.relPath), book.stem);
}
return books;
}
export async function buildBookPerFileWithSubdirs(libraryRoot: string): Promise<Array<{ relPath: string; stem: string }>> {
const books = [
{ relPath: 'OldAuthorA/title-one.epub', stem: 'title-one' },
{ relPath: 'OldAuthorB/deep/nested/title-two.epub', stem: 'title-two' },
{ relPath: 'title-three-root.epub', stem: 'title-three-root' },
{ relPath: 'OldAuthorC/title-four.pdf', stem: 'title-four' },
];
for (const book of books) {
if (book.relPath.endsWith('.pdf')) {
await createPdfFile(join(libraryRoot, book.relPath), book.stem);
} else {
await createEpubFile(join(libraryRoot, book.relPath), book.stem);
}
}
return books;
}
export async function buildBookPerFolderFlat(libraryRoot: string): Promise<Array<{ relPath: string; folderName: string }>> {
const books = [
{ relPath: 'FolderA/book.epub', folderName: 'FolderA' },
{ relPath: 'FolderB/book.epub', folderName: 'FolderB' },
{ relPath: 'FolderC/book.epub', folderName: 'FolderC' },
];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.relPath), book.folderName);
}
return books;
}
export async function buildBookPerFolderNested(libraryRoot: string): Promise<Array<{ relPath: string; folderName: string }>> {
const books = [
{ relPath: 'AuthorX/SeriesY/vol01/book.epub', folderName: 'vol01' },
{ relPath: 'AuthorX/standalone/book.epub', folderName: 'standalone' },
{ relPath: 'AuthorZ/TitleZ/TitleZ.epub', folderName: 'TitleZ' },
];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.relPath), book.folderName);
}
return books;
}
export async function buildBookPerFolderMultiFile(
libraryRoot: string,
): Promise<Array<{ primaryRelPath: string; extraRelPaths: string[]; folderName: string }>> {
const books = [
{
primaryRelPath: 'MultiFileBook/book.epub',
extraRelPaths: ['MultiFileBook/book.pdf', 'MultiFileBook/cover.jpg'],
folderName: 'MultiFileBook',
},
];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.primaryRelPath), book.folderName);
await createPdfFile(join(libraryRoot, book.extraRelPaths[0]), `${book.folderName}-pdf`);
await createDummyFile(join(libraryRoot, book.extraRelPaths[1]), 'JPEG_PLACEHOLDER');
}
return books;
}
export async function buildCollisionPair(libraryRoot: string): Promise<Array<{ relPath: string }>> {
const books = [{ relPath: 'collision-src-a.epub' }, { relPath: 'collision-src-b.epub' }];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.relPath), book.relPath.replace('.epub', ''));
}
return books;
}
export async function buildSpecialCharBook(libraryRoot: string): Promise<string> {
const relPath = 'special-chars-src.epub';
await createEpubFile(join(libraryRoot, relPath), 'placeholder');
return relPath;
}
@@ -0,0 +1,407 @@
import { randomUUID } from 'crypto';
import { hash } from 'bcryptjs';
import fastifyCookie from '@fastify/cookie';
import { Test } from '@nestjs/testing';
import { ValidationPipe } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { mkdir } from 'fs/promises';
import { asc, eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DEFAULT_FORMAT_PRIORITY } from '@bookorbit/types';
import type { BulkRenamePreviewPage, BulkRenameProgressEvent, BulkRenameStatus } from '@bookorbit/types';
import { AppModule } from '../../../src/app.module';
import { DB } from '../../../src/db';
import * as schema from '../../../src/db/schema';
import { bookFiles, books, libraries, libraryFolders, scanJobs } from '../../../src/db/schema';
import { GlobalExceptionFilter } from '../../../src/common/filters/http-exception.filter';
export type Db = NodePgDatabase<typeof schema>;
const ADMIN_SETUP_DTO = {
username: 'file-rename-e2e-admin',
name: 'File Rename E2E Admin',
email: 'file-rename-e2e-admin@example.com',
password: 'FileRenameE2E123!',
};
interface EnvSnapshot {
appDataPath: string | undefined;
fileWriteDebounceMs: string | undefined;
booksPath: string | undefined;
}
export interface FileRenameE2EContext {
app: NestFastifyApplication;
db: Db;
adminToken: string;
booksRoot: string;
envSnapshot: EnvSnapshot;
}
export interface CreatedLibrary {
libraryId: number;
libraryFolderId: number;
folderPath: string;
}
export interface LocatedBook {
bookId: number;
primaryFileId: number | null;
absolutePath: string;
relPath: string | null;
folderPath: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function createFileRenameE2EContext(booksRoot: string): Promise<FileRenameE2EContext> {
await mkdir(booksRoot, { recursive: true });
const envSnapshot: EnvSnapshot = {
appDataPath: process.env.APP_DATA_PATH,
fileWriteDebounceMs: process.env.FILE_WRITE_DEBOUNCE_MS,
booksPath: process.env.BOOKS_PATH,
};
process.env.APP_DATA_PATH = booksRoot;
process.env.BOOKS_PATH = booksRoot;
// Large debounce prevents auto-renames from racing with bulk-rename test steps
process.env.FILE_WRITE_DEBOUNCE_MS = '300000';
const moduleFixture = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
app.useGlobalFilters(new GlobalExceptionFilter());
await app.register(fastifyCookie as never);
await app.init();
await app.getHttpAdapter().getInstance().ready();
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app, db);
return { app, db, adminToken, booksRoot, envSnapshot };
}
export async function closeFileRenameE2EContext(ctx: FileRenameE2EContext): Promise<void> {
await ctx.app.close();
restoreEnv(ctx.envSnapshot);
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.appDataPath !== undefined) process.env.APP_DATA_PATH = snapshot.appDataPath;
else delete process.env.APP_DATA_PATH;
if (snapshot.fileWriteDebounceMs !== undefined) process.env.FILE_WRITE_DEBOUNCE_MS = snapshot.fileWriteDebounceMs;
else delete process.env.FILE_WRITE_DEBOUNCE_MS;
if (snapshot.booksPath !== undefined) process.env.BOOKS_PATH = snapshot.booksPath;
else delete process.env.BOOKS_PATH;
}
export async function createLibrary(
ctx: FileRenameE2EContext,
options: {
mode?: 'book_per_file' | 'book_per_folder';
name?: string;
fileRenameEnabled?: boolean;
fileNamingPattern?: string | null;
} = {},
): Promise<CreatedLibrary> {
const folderPath = `${ctx.booksRoot}/library-${randomUUID()}`;
await mkdir(folderPath, { recursive: true });
const [library] = await ctx.db
.insert(libraries)
.values({
name: options.name ?? `file-rename-${randomUUID()}`,
icon: '📚',
watch: false,
organizationMode: options.mode ?? 'book_per_file',
allowedFormats: [],
excludePatterns: [],
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
fileWriteEnabled: false,
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWritePdfEnabled: false,
fileWriteCbxEnabled: false,
fileRenameEnabled: options.fileRenameEnabled ?? true,
fileNamingPattern: options.fileNamingPattern ?? null,
})
.returning({ id: libraries.id });
const [libraryFolder] = await ctx.db
.insert(libraryFolders)
.values({ libraryId: library.id, path: folderPath })
.returning({ id: libraryFolders.id });
return { libraryId: library.id, libraryFolderId: libraryFolder.id, folderPath };
}
export async function triggerAndWaitForScan(ctx: FileRenameE2EContext, libraryId: number, timeoutMs = 30_000): Promise<void> {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
headers: authHeader(ctx.adminToken),
});
if (response.statusCode !== 202) {
throw new Error(`Scan endpoint returned ${response.statusCode}: ${response.body}`);
}
const body = response.json() as { jobId?: number };
if (!body.jobId) throw new Error(`Scan returned no jobId: ${response.body}`);
await waitForScanJob(ctx.db, body.jobId, timeoutMs);
}
async function waitForScanJob(db: Db, jobId: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const [job] = await db.select().from(scanJobs).where(eq(scanJobs.id, jobId)).limit(1);
if (!job) throw new Error(`Scan job ${jobId} not found`);
if (job.status === 'completed') return;
if (job.status === 'failed') throw new Error(`Scan job failed: ${job.errorMessage ?? 'unknown'}`);
await sleep(100);
}
throw new Error(`Timed out waiting for scan job ${jobId}`);
}
export async function findBookByRelPath(ctx: FileRenameE2EContext, libraryId: number, relPath: string): Promise<LocatedBook> {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
const [row] = await ctx.db
.select({
bookId: books.id,
primaryFileId: books.primaryFileId,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
folderPath: books.folderPath,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(bookFiles.relPath, relPath))
.limit(1);
if (row && row.absolutePath.includes(libraryId.toString().padStart(1))) {
return row;
}
// fallback: search by just relPath without lib filter for simplicity
const [anyRow] = await ctx.db
.select({
bookId: books.id,
primaryFileId: books.primaryFileId,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
folderPath: books.folderPath,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(bookFiles.relPath, relPath))
.limit(1);
if (anyRow) return anyRow;
await sleep(100);
}
throw new Error(`Book with relPath "${relPath}" not found after waiting`);
}
export async function findAllBooksInLibrary(ctx: FileRenameE2EContext, libraryId: number): Promise<LocatedBook[]> {
const rows = await ctx.db
.select({
bookId: books.id,
primaryFileId: books.primaryFileId,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
folderPath: books.folderPath,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(books.libraryId, libraryId))
.orderBy(asc(books.id));
return rows;
}
export function authHeader(token: string): Record<string, string> {
return { authorization: `Bearer ${token}` };
}
export async function setBookMetadata(
ctx: FileRenameE2EContext,
bookId: number,
metadata: {
title?: string | null;
subtitle?: string | null;
publisher?: string | null;
publishedYear?: number | null;
seriesName?: string | null;
seriesIndex?: number | null;
language?: string | null;
isbn13?: string | null;
authors?: string[];
},
): Promise<void> {
const response = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${bookId}/metadata`,
headers: authHeader(ctx.adminToken),
payload: {
...(metadata.title !== undefined && { title: metadata.title }),
...(metadata.subtitle !== undefined && { subtitle: metadata.subtitle }),
...(metadata.publisher !== undefined && { publisher: metadata.publisher }),
...(metadata.publishedYear !== undefined && { publishedYear: metadata.publishedYear }),
...(metadata.seriesName !== undefined && { seriesName: metadata.seriesName }),
...(metadata.seriesIndex !== undefined && { seriesIndex: metadata.seriesIndex }),
...(metadata.language !== undefined && { language: metadata.language }),
...(metadata.isbn13 !== undefined && { isbn13: metadata.isbn13 }),
...(metadata.authors !== undefined && { authors: metadata.authors }),
},
});
if (response.statusCode !== 200) {
throw new Error(`PATCH metadata returned ${response.statusCode}: ${response.body}`);
}
}
export async function getBulkRenamePreview(
ctx: FileRenameE2EContext,
libraryId: number,
options: { page?: number; pageSize?: number; status?: BulkRenameStatus } = {},
): Promise<BulkRenamePreviewPage> {
const params = new URLSearchParams();
params.set('page', String(options.page ?? 1));
params.set('pageSize', String(options.pageSize ?? 100));
if (options.status) params.set('status', options.status);
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/libraries/${libraryId}/bulk-rename/preview?${params.toString()}`,
headers: authHeader(ctx.adminToken),
});
if (response.statusCode !== 200) {
throw new Error(`Preview returned ${response.statusCode}: ${response.body}`);
}
return response.json() as BulkRenamePreviewPage;
}
export async function getBulkRenameStatus(ctx: FileRenameE2EContext, libraryId: number): Promise<{ running: boolean }> {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/libraries/${libraryId}/bulk-rename/status`,
headers: authHeader(ctx.adminToken),
});
if (response.statusCode !== 200) {
throw new Error(`Status returned ${response.statusCode}: ${response.body}`);
}
return response.json() as { running: boolean };
}
export interface BulkRenameExecuteResult {
statusCode: number;
events: BulkRenameProgressEvent[];
rawBody: string;
error?: string;
}
export async function executeBulkRename(ctx: FileRenameE2EContext, libraryId: number): Promise<BulkRenameExecuteResult> {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${libraryId}/bulk-rename/execute`,
headers: authHeader(ctx.adminToken),
});
const rawBody = response.body;
const events: BulkRenameProgressEvent[] = [];
if (response.statusCode === 200) {
for (const line of rawBody.split('\n')) {
if (line.startsWith('data: ')) {
try {
events.push(JSON.parse(line.slice(6)) as BulkRenameProgressEvent);
} catch {
// skip malformed lines
}
}
}
}
return { statusCode: response.statusCode, events, rawBody };
}
export async function refreshPreviewCache(ctx: FileRenameE2EContext, libraryId: number): Promise<void> {
// Wait a moment to ensure the cache TTL doesn't interfere (cache is 60s)
// We invalidate by patching library settings to force a fresh compute
await ctx.db.update(libraries).set({ updatedAt: new Date() }).where(eq(libraries.id, libraryId));
}
async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<string> {
const response = await app.inject({
method: 'POST',
url: '/api/v1/auth/setup',
payload: ADMIN_SETUP_DTO,
});
if (response.statusCode === 409) {
const loginResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username: ADMIN_SETUP_DTO.username, password: ADMIN_SETUP_DTO.password },
});
if (loginResponse.statusCode === 200) {
const body = loginResponse.json() as { accessToken?: string };
if (body.accessToken) return body.accessToken;
}
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `file-rename-admin-${suffix}`;
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 12);
await db.insert(schema.users).values({
username: fallbackUsername,
name: 'File Rename E2E Admin (fallback)',
email: `${fallbackUsername}@example.com`,
passwordHash,
isSuperuser: true,
isDefaultPassword: false,
provisioningMethod: 'local',
});
const fallbackResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username: fallbackUsername, password: ADMIN_SETUP_DTO.password },
});
if (fallbackResponse.statusCode !== 200) {
throw new Error(`Fallback admin login failed: ${fallbackResponse.statusCode} ${fallbackResponse.body}`);
}
const body = fallbackResponse.json() as { accessToken?: string };
if (!body.accessToken) throw new Error('Fallback login returned no accessToken');
return body.accessToken;
}
if (response.statusCode !== 201) {
throw new Error(`Setup returned ${response.statusCode}: ${response.body}`);
}
const body = response.json() as { accessToken?: string };
if (!body.accessToken) throw new Error('Setup returned no accessToken');
return body.accessToken;
}
File diff suppressed because it is too large Load Diff