fix(library): rename all book files during metadata write

Ensure all files associated with a book are renamed or moved when the
primary file is renamed. Previously, the rename service only computed
new target paths for the primary file, abandoning companion files (e.g.,
covers, sidecars) or failing to rename them properly.

Compute new target paths for all content and secondary files, manage
batch rollbacks on failure, and handle internal naming collisions. Clean
up unused single-file rename repository and service methods.

Closes #265
This commit is contained in:
neon
2026-06-08 05:14:43 +00:00
parent d8e84aa531
commit f54752568f
3 changed files with 365 additions and 121 deletions
@@ -136,13 +136,6 @@ export class FileRenameRepository {
await this.db.update(books).set({ folderPath }).where(eq(books.id, bookId));
}
async applyFileRename(bookId: number, fileId: number, absolutePath: string, relPath: string | null, folderPath: string): Promise<void> {
await this.db.transaction(async (tx) => {
await tx.update(bookFiles).set({ absolutePath, relPath }).where(eq(bookFiles.id, fileId));
await tx.update(books).set({ folderPath }).where(eq(books.id, bookId));
});
}
async applyFolderRename(bookId: number, updates: BookFilePathUpdate[], folderPath: string): Promise<void> {
await this.db.transaction(async (tx) => {
for (const update of updates) {
@@ -73,9 +73,20 @@ describe('FileRenameService', () => {
const renameRepo = {
findBookRenameData: vi.fn(),
checkPathTakenByOtherBook: vi.fn().mockResolvedValue(false),
applyFileRename: vi.fn().mockResolvedValue(undefined),
applyFolderRename: vi.fn().mockResolvedValue(undefined),
findAllBookFiles: vi.fn().mockResolvedValue([]),
findAllBookFiles: vi.fn().mockImplementation(async () => {
const data = await renameRepo.findBookRenameData();
if (!data) return [];
return [
{
id: data.file.id,
absolutePath: data.file.absolutePath,
relPath: data.file.relPath,
role: data.file.role ?? 'content',
format: data.file.format,
},
];
}),
};
const lockService = {
withLock: vi.fn().mockImplementation(async (_path: string, fn: () => Promise<unknown>) => fn()),
@@ -200,14 +211,18 @@ describe('FileRenameService', () => {
newPath: '/library/Frank Herbert/Dune.epub',
}),
);
expect(renameRepo.applyFileRename).toHaveBeenCalledWith(
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
5,
10,
'/library/Frank Herbert/Dune.epub',
'Frank Herbert/Dune.epub',
[
{
id: 10,
absolutePath: '/library/Frank Herbert/Dune.epub',
relPath: 'Frank Herbert/Dune.epub',
},
],
'/library/Frank Herbert/Dune.epub',
);
expect(renameRepo.applyFileRename.mock.invocationCallOrder[0]).toBeLessThan(mockRename.mock.invocationCallOrder[0]);
expect(renameRepo.applyFolderRename.mock.invocationCallOrder[0]).toBeLessThan(mockRename.mock.invocationCallOrder[0]);
expect(mockMkdir).toHaveBeenCalledWith('/library/Frank Herbert', { recursive: true });
expect(lockService.withLock).toHaveBeenNthCalledWith(1, bookOperationLockKey(5), expect.any(Function));
expect(lockService.withLock).toHaveBeenCalledWith('/library/Old Title.epub', expect.any(Function));
@@ -356,15 +371,30 @@ describe('FileRenameService', () => {
newPath: '/library/Frank Herbert/Dune.epub',
}),
);
expect(renameRepo.applyFileRename).toHaveBeenNthCalledWith(
expect(renameRepo.applyFolderRename).toHaveBeenNthCalledWith(
1,
5,
10,
'/library/Frank Herbert/Dune.epub',
'Frank Herbert/Dune.epub',
[
{
id: 10,
absolutePath: '/library/Frank Herbert/Dune.epub',
relPath: 'Frank Herbert/Dune.epub',
},
],
'/library/Frank Herbert/Dune.epub',
);
expect(renameRepo.applyFileRename).toHaveBeenNthCalledWith(2, 5, 10, '/library/Old Title.epub', 'Old Title.epub', '/library/Old Title.epub');
expect(renameRepo.applyFolderRename).toHaveBeenNthCalledWith(
2,
5,
[
{
id: 10,
absolutePath: '/library/Old Title.epub',
relPath: 'Old Title.epub',
},
],
'/library/Old Title.epub',
);
expect(notificationService.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: NotificationType.FileRenameFailed,
@@ -484,8 +514,17 @@ describe('FileRenameService', () => {
const result = await service.performRename(5, 12);
expect(result).toEqual(expect.objectContaining({ status: 'success' }));
expect(renameRepo.applyFileRename).toHaveBeenCalled();
expect(renameRepo.applyFolderRename).not.toHaveBeenCalled();
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
5,
[
{
id: 10,
absolutePath: '/library/same-folder/Dune.epub',
relPath: 'same-folder/Dune.epub',
},
],
'/library/same-folder',
);
expect(mockRename).toHaveBeenCalledWith('/library/same-folder/Old Title.epub', '/library/same-folder/Dune.epub');
});
@@ -712,7 +751,7 @@ describe('FileRenameService', () => {
bookFolderPath: '/library/Old Title.epub',
}),
);
renameRepo.applyFileRename.mockResolvedValue(undefined);
renameRepo.applyFolderRename.mockResolvedValue(undefined);
const result = await service.performRename(5, 12, true);
@@ -738,7 +777,7 @@ describe('FileRenameService', () => {
bookFolderPath: '/library/Old Title.epub',
}),
);
renameRepo.applyFileRename.mockResolvedValue(undefined);
renameRepo.applyFolderRename.mockResolvedValue(undefined);
const result = await service.performRename(5, 12, false, true);
@@ -763,6 +802,132 @@ describe('FileRenameService', () => {
});
});
it('rolls back inner file move when an error occurs during folder rename inner files move', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: '{authors}/{title}/{title}',
file: { absolutePath: '/library/old/Old Title.epub', relPath: 'old/Old Title.epub' },
bookFolderPath: '/library/old',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/old/Old Title.epub', relPath: 'old/Old Title.epub', role: 'primary' },
{ id: 11, absolutePath: '/library/old/cover.jpg', relPath: 'old/cover.jpg', role: 'cover' },
]);
mockRename.mockImplementation((from) => {
if (from === '/library/old') return Promise.resolve(undefined);
return Promise.reject(new Error('inner file move failed'));
});
await expect(service.performRename(5, 12)).resolves.toEqual(expect.objectContaining({ status: 'failed', reason: 'inner file move failed' }));
expect(mockRename).toHaveBeenCalledWith('/library/Frank Herbert/Dune', '/library/old');
});
it('rolls back individual file moves when renameBookFilesOnly fails', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_file',
fileNamingPattern: '{authors}/{title}',
file: { absolutePath: '/library/old.epub', relPath: 'old.epub' },
bookFolderPath: '/library/old.epub',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/old.epub', relPath: 'old.epub', role: 'primary' },
{ id: 11, absolutePath: '/library/cover.jpg', relPath: 'cover.jpg', role: 'cover' },
]);
mockRename.mockImplementation((from) => {
if (from === '/library/cover.jpg') return Promise.reject(new Error('file move failed'));
return Promise.resolve(undefined);
});
await expect(service.performRename(5, 12)).resolves.toEqual(expect.objectContaining({ status: 'failed', reason: 'file move failed' }));
expect(mockRename).toHaveBeenCalledWith('/library/Frank Herbert/Dune.epub', '/library/old.epub');
});
it('skips and notifies when internal collision occurs and fails to resolve', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_file',
fileNamingPattern: '{authors}/{title}',
file: { absolutePath: '/library/old.epub', relPath: 'old.epub' },
bookFolderPath: '/library/old.epub',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/old.epub', relPath: 'old.epub', role: 'primary', format: 'epub' },
{ id: 11, absolutePath: '/library/old2.epub', relPath: 'old2.epub', role: 'content', format: 'epub' },
]);
const result = await service.performRename(5, 12);
expect(result.status).toBe('success');
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
5,
expect.arrayContaining([
expect.objectContaining({ absolutePath: '/library/Frank Herbert/Dune.epub' }),
expect.objectContaining({ absolutePath: '/library/Frank Herbert/old2.epub' }),
]),
'/library/Frank Herbert/Dune.epub',
);
});
it('skips when companion file path is taken by another book', async () => {
const { service, renameRepo, notificationService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_file',
fileNamingPattern: '{authors}/{title}',
file: { absolutePath: '/library/old.epub', relPath: 'old.epub' },
bookFolderPath: '/library/old.epub',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/old.epub', relPath: 'old.epub', role: 'primary' },
{ id: 11, absolutePath: '/library/cover.jpg', relPath: 'cover.jpg', role: 'cover' },
]);
renameRepo.checkPathTakenByOtherBook.mockImplementation((path: string) => {
return Promise.resolve(path === '/library/Frank Herbert/cover.jpg');
});
const result = await service.performRename(5, 12);
expect(result.status).toBe('skipped');
expect(result.reason).toBe('collision');
expect(notificationService.notify).toHaveBeenCalled();
});
it('skips when companion file target path already exists on disk', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_file',
fileNamingPattern: '{authors}/{title}',
file: { absolutePath: '/library/old.epub', relPath: 'old.epub' },
bookFolderPath: '/library/old.epub',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/old.epub', relPath: 'old.epub', role: 'primary' },
{ id: 11, absolutePath: '/library/cover.jpg', relPath: 'cover.jpg', role: 'cover' },
]);
mockAccess.mockImplementation((path: any) => {
if (path.toString() === '/library/Frank Herbert/cover.jpg') return Promise.resolve(undefined);
return Promise.reject(Object.assign(new Error('missing'), { code: 'ENOENT' }));
});
const result = await service.performRename(5, 12);
expect(result.status).toBe('skipped');
expect(result.reason).toBe('target path already exists on disk');
});
describe('cancelPendingRename', () => {
it('clears the debounce timer so the rename does not fire', async () => {
vi.useFakeTimers();
@@ -119,40 +119,138 @@ export class FileRenameService implements OnModuleDestroy {
const newFolderPath = dirname(newAbsolutePath);
const isBookPerFolder = data.organizationMode === 'book_per_folder';
if (newAbsolutePath === currentAbsolutePath) {
const allFiles = await this.renameRepo.findAllBookFiles(bookId);
const fileTargets = new Map<number, string>();
const usedPaths = new Set<string>();
const bookHasOwnFolder = isBookPerFolder && currentFolderPath !== currentAbsolutePath;
let hasInternalCollision = false;
for (const file of allFiles) {
if (file.id === data.file.id) {
fileTargets.set(file.id, newAbsolutePath);
usedPaths.add(newAbsolutePath.toLowerCase());
} else if (file.role === 'content') {
const fileExt = extname(file.absolutePath);
const fileFormat = (file.format ?? fileExt.slice(1)).toLowerCase();
const fileOriginalStem = basename(file.absolutePath, fileExt);
const fileTokens = buildTokens(data.metadata, data.authors, fileOriginalStem, fileFormat);
const fileResolvedRelPath = resolveUploadPath(pattern, fileTokens, fileFormat, { sanitizeForCrossPlatform });
let targetAbs: string;
if (fileResolvedRelPath) {
const resolvedAbs = join(data.libraryFolderPath, fileResolvedRelPath);
if (isBookPerFolder) {
const relToOldFolder = bookHasOwnFolder ? relative(currentFolderPath, file.absolutePath) : basename(file.absolutePath);
const oldSubDir = dirname(relToOldFolder);
targetAbs = join(newFolderPath, oldSubDir, basename(resolvedAbs));
} else {
targetAbs = resolvedAbs;
}
} else {
const relToOldFolder = bookHasOwnFolder ? relative(currentFolderPath, file.absolutePath) : basename(file.absolutePath);
targetAbs = join(isBookPerFolder ? newFolderPath : dirname(newAbsolutePath), relToOldFolder);
}
if (usedPaths.has(targetAbs.toLowerCase())) {
hasInternalCollision = true;
}
usedPaths.add(targetAbs.toLowerCase());
fileTargets.set(file.id, targetAbs);
} else {
const relToOldFolder = bookHasOwnFolder ? relative(currentFolderPath, file.absolutePath) : basename(file.absolutePath);
const targetAbs = join(isBookPerFolder ? newFolderPath : dirname(newAbsolutePath), relToOldFolder);
if (usedPaths.has(targetAbs.toLowerCase())) {
hasInternalCollision = true;
}
usedPaths.add(targetAbs.toLowerCase());
fileTargets.set(file.id, targetAbs);
}
}
if (hasInternalCollision) {
fileTargets.clear();
fileTargets.set(data.file.id, newAbsolutePath);
for (const file of allFiles) {
if (file.id !== data.file.id) {
const relToOldFolder = bookHasOwnFolder ? relative(currentFolderPath, file.absolutePath) : basename(file.absolutePath);
const targetDir = isBookPerFolder ? newFolderPath : dirname(newAbsolutePath);
fileTargets.set(file.id, join(targetDir, relToOldFolder));
}
}
}
let pathUnchanged = newAbsolutePath === currentAbsolutePath;
if (pathUnchanged) {
for (const file of allFiles) {
if (fileTargets.get(file.id) && fileTargets.get(file.id) !== file.absolutePath) {
pathUnchanged = false;
break;
}
}
} else {
pathUnchanged = false;
}
if (pathUnchanged) {
return this.logAndReturn(bookId, startedAt, { status: 'skipped', reason: 'path unchanged' });
}
const pathTaken = await this.renameRepo.checkPathTakenByOtherBook(newAbsolutePath, bookId);
if (pathTaken) {
this.logger.warn(
`[${FILE_RENAME_EVENT}] [end] bookId=${bookId} userId=${userId} durationMs=${Date.now() - startedAt} status=skipped reason="collision" newPath="${sanitizeLogValue(newAbsolutePath)}" - rename skipped: path already taken`,
);
await this.notifyFailure(userId, bookId, 'File rename skipped: target path already taken by another book.', suppressNotification);
return { status: 'skipped', reason: 'collision', oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
}
const bookHasOwnFolder = isBookPerFolder && currentFolderPath !== currentAbsolutePath;
const nestedFolderMove = bookHasOwnFolder && newFolderPath !== currentFolderPath && this.foldersAreNested(currentFolderPath, newFolderPath);
const renamingFolder = bookHasOwnFolder && newFolderPath !== currentFolderPath && !nestedFolderMove;
const filesystemTargetPath = renamingFolder ? newFolderPath : newAbsolutePath;
if (await this.pathExists(filesystemTargetPath)) {
const reason = renamingFolder ? 'target folder already exists on disk' : 'target path already exists on disk';
this.logger.warn(
`[${FILE_RENAME_EVENT}] [end] bookId=${bookId} userId=${userId} durationMs=${Date.now() - startedAt} status=skipped reason="${sanitizeLogValue(reason)}" newPath="${sanitizeLogValue(newAbsolutePath)}" - rename skipped: target already exists on disk`,
);
await this.notifyFailure(userId, bookId, `File rename skipped: ${reason}.`, suppressNotification);
return { status: 'skipped', reason, oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
if (renamingFolder) {
if (await this.pathExists(newFolderPath)) {
const reason = 'target folder already exists on disk';
this.logger.warn(
`[${FILE_RENAME_EVENT}] [end] bookId=${bookId} userId=${userId} durationMs=${Date.now() - startedAt} status=skipped reason="${sanitizeLogValue(reason)}" newPath="${sanitizeLogValue(newAbsolutePath)}" - rename skipped: target already exists on disk`,
);
await this.notifyFailure(userId, bookId, `File rename skipped: ${reason}.`, suppressNotification);
return { status: 'skipped', reason, oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
}
} else {
for (const file of allFiles) {
const targetPath = fileTargets.get(file.id)!;
if (targetPath !== file.absolutePath) {
if (await this.pathExists(targetPath)) {
const reason = 'target path already exists on disk';
this.logger.warn(
`[${FILE_RENAME_EVENT}] [end] bookId=${bookId} userId=${userId} durationMs=${Date.now() - startedAt} status=skipped reason="${sanitizeLogValue(reason)}" newPath="${sanitizeLogValue(newAbsolutePath)}" - rename skipped: target already exists on disk`,
);
await this.notifyFailure(userId, bookId, `File rename skipped: ${reason}.`, suppressNotification);
return { status: 'skipped', reason, oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
}
}
}
}
for (const file of allFiles) {
const targetPath = fileTargets.get(file.id)!;
if (targetPath !== file.absolutePath) {
const pathTaken = await this.renameRepo.checkPathTakenByOtherBook(targetPath, bookId);
if (pathTaken) {
this.logger.warn(
`[${FILE_RENAME_EVENT}] [end] bookId=${bookId} userId=${userId} durationMs=${Date.now() - startedAt} status=skipped reason="collision" newPath="${sanitizeLogValue(targetPath)}" - rename skipped: path already taken`,
);
await this.notifyFailure(userId, bookId, 'File rename skipped: target path already taken by another book.', suppressNotification);
return {
status: 'skipped',
reason: 'collision',
oldPath: currentAbsolutePath,
newPath: newAbsolutePath,
durationMs: Date.now() - startedAt,
};
}
}
}
try {
if (bookHasOwnFolder && newFolderPath !== currentFolderPath) {
await this.renameBookWithFolder(bookId, data, currentFolderPath, newFolderPath, newAbsolutePath);
} else if (isBookPerFolder && newFolderPath !== currentFolderPath) {
await this.renameFlatBookToFolder(bookId, data, newFolderPath, newAbsolutePath);
await this.renameBookWithFolder(bookId, data, currentFolderPath, newFolderPath, fileTargets);
} else {
const nextBookFolderPath = isBookPerFolder ? newFolderPath : newAbsolutePath;
await this.renameBookFileOnly(bookId, data, currentAbsolutePath, newAbsolutePath, nextBookFolderPath);
await this.renameBookFilesOnly(bookId, data, fileTargets, nextBookFolderPath);
}
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
@@ -172,57 +270,33 @@ export class FileRenameService implements OnModuleDestroy {
return { status: 'success', oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
}
private async renameBookFileOnly(
private async renameBookFilesOnly(
bookId: number,
data: BookRenameData,
oldPath: string,
newPath: string,
fileTargets: Map<number, string>,
nextBookFolderPath: string,
): Promise<void> {
const oldDir = dirname(oldPath);
const newDir = dirname(newPath);
const oldRelPath = data.file.relPath;
const newRelPath = relative(data.libraryFolderPath, newPath);
await this.renameRepo.applyFileRename(bookId, data.file.id, newPath, newRelPath, nextBookFolderPath);
try {
await mkdir(newDir, { recursive: true });
await this.lockService.withLock(oldPath, () => fsRename(oldPath, newPath));
} catch (error) {
await this.rollbackFileRename(bookId, data.file.id, oldPath, oldRelPath, data.bookFolderPath, error);
throw error;
}
if (oldDir !== newDir) {
await this.tryRemoveEmptyDir(oldDir);
}
}
private async renameFlatBookToFolder(bookId: number, data: BookRenameData, newFolderPath: string, newPrimaryAbsolutePath: string): Promise<void> {
const allFiles = await this.renameRepo.findAllBookFiles(bookId);
const targetFor = (file: { id: number; absolutePath: string }): string =>
file.id === data.file.id ? newPrimaryAbsolutePath : join(newFolderPath, basename(file.absolutePath));
const newUpdates: BookFilePathUpdate[] = allFiles.map((file) => {
const absolutePath = targetFor(file);
return { id: file.id, absolutePath, relPath: relative(data.libraryFolderPath, absolutePath) };
const updates: BookFilePathUpdate[] = allFiles.map((file) => {
const newPath = fileTargets.get(file.id)!;
return {
id: file.id,
absolutePath: newPath,
relPath: relative(data.libraryFolderPath, newPath),
};
});
const oldUpdates: BookFilePathUpdate[] = allFiles.map((file) => ({
id: file.id,
absolutePath: file.absolutePath,
relPath: file.relPath,
}));
await this.renameRepo.applyFolderRename(bookId, newUpdates, newFolderPath);
await this.renameRepo.applyFolderRename(bookId, updates, nextBookFolderPath);
const moved: Array<{ from: string; to: string }> = [];
try {
await mkdir(newFolderPath, { recursive: true });
for (const file of allFiles) {
const newAbsolutePath = targetFor(file);
await this.lockService.withLock(file.absolutePath, () => fsRename(file.absolutePath, newAbsolutePath));
moved.push({ from: file.absolutePath, to: newAbsolutePath });
const newPath = fileTargets.get(file.id)!;
if (newPath !== file.absolutePath) {
await mkdir(dirname(newPath), { recursive: true });
await this.lockService.withLock(file.absolutePath, () => fsRename(file.absolutePath, newPath));
moved.push({ from: file.absolutePath, to: newPath });
}
}
} catch (error) {
for (const { from, to } of [...moved].reverse()) {
@@ -232,9 +306,22 @@ export class FileRenameService implements OnModuleDestroy {
this.logRollbackFailure(bookId, error, rollbackError);
}
}
const oldUpdates = allFiles.map((file) => ({
id: file.id,
absolutePath: file.absolutePath,
relPath: file.relPath,
}));
await this.rollbackFolderRename(bookId, oldUpdates, data.bookFolderPath, error);
throw error;
}
for (const file of allFiles) {
const oldDir = dirname(file.absolutePath);
const newDir = dirname(fileTargets.get(file.id)!);
if (oldDir !== newDir) {
await this.tryRemoveEmptyDir(oldDir);
}
}
}
private async renameBookWithFolder(
@@ -242,33 +329,47 @@ export class FileRenameService implements OnModuleDestroy {
data: BookRenameData,
oldFolderPath: string,
newFolderPath: string,
newPrimaryAbsolutePath: string,
fileTargets: Map<number, string>,
): Promise<void> {
const allFiles = await this.renameRepo.findAllBookFiles(bookId);
const newUpdates = allFiles.map((file) => {
const relToOldFolder = relative(oldFolderPath, file.absolutePath);
const absolutePath = file.id === data.file.id ? newPrimaryAbsolutePath : join(newFolderPath, relToOldFolder);
return {
id: file.id,
absolutePath,
relPath: relative(data.libraryFolderPath, absolutePath),
} satisfies BookFilePathUpdate;
});
const newUpdates = allFiles.map((file) => ({
id: file.id,
absolutePath: fileTargets.get(file.id)!,
relPath: relative(data.libraryFolderPath, fileTargets.get(file.id)!),
}));
const oldUpdates = allFiles.map((file) => ({ id: file.id, absolutePath: file.absolutePath, relPath: file.relPath }) satisfies BookFilePathUpdate);
await this.renameRepo.applyFolderRename(bookId, newUpdates, newFolderPath);
if (this.foldersAreNested(oldFolderPath, newFolderPath)) {
await this.moveBookFilesIndividually(bookId, allFiles, data, oldFolderPath, newFolderPath, newPrimaryAbsolutePath, oldUpdates);
await this.moveBookFilesIndividually(bookId, allFiles, oldFolderPath, newFolderPath, fileTargets, oldUpdates);
} else {
const movedPrimaryAbsolutePath = join(newFolderPath, relative(oldFolderPath, data.file.absolutePath));
let folderRenamed = false;
try {
await mkdir(dirname(newFolderPath), { recursive: true });
await this.lockService.withLock(oldFolderPath, () => fsRename(oldFolderPath, newFolderPath));
folderRenamed = true;
if (movedPrimaryAbsolutePath !== newPrimaryAbsolutePath) {
await this.lockService.withLock(movedPrimaryAbsolutePath, () => fsRename(movedPrimaryAbsolutePath, newPrimaryAbsolutePath));
const movedFilesInside: Array<{ from: string; to: string }> = [];
try {
for (const file of allFiles) {
const currentPathAfterFolderRename = join(newFolderPath, relative(oldFolderPath, file.absolutePath));
const intendedPath = fileTargets.get(file.id)!;
if (currentPathAfterFolderRename !== intendedPath) {
await mkdir(dirname(intendedPath), { recursive: true });
await this.lockService.withLock(currentPathAfterFolderRename, () => fsRename(currentPathAfterFolderRename, intendedPath));
movedFilesInside.push({ from: currentPathAfterFolderRename, to: intendedPath });
}
}
} catch (innerError) {
for (const { from, to } of [...movedFilesInside].reverse()) {
try {
await fsRename(to, from);
} catch {
/* ignore */
}
}
throw innerError;
}
} catch (error) {
await this.rollbackFolderRename(bookId, oldUpdates, oldFolderPath, error);
@@ -286,10 +387,9 @@ export class FileRenameService implements OnModuleDestroy {
private async moveBookFilesIndividually(
bookId: number,
allFiles: Awaited<ReturnType<FileRenameRepository['findAllBookFiles']>>,
data: BookRenameData,
oldFolderPath: string,
newFolderPath: string,
newPrimaryAbsolutePath: string,
fileTargets: Map<number, string>,
oldUpdates: BookFilePathUpdate[],
): Promise<void> {
const movedFiles: Array<{ from: string; to: string }> = [];
@@ -298,16 +398,17 @@ export class FileRenameService implements OnModuleDestroy {
await mkdir(newFolderPath, { recursive: true });
for (const file of allFiles) {
const relToOldFolder = relative(oldFolderPath, file.absolutePath);
const newFilePath = file.id === data.file.id ? newPrimaryAbsolutePath : join(newFolderPath, relToOldFolder);
const newFilePath = fileTargets.get(file.id)!;
const newFileDir = dirname(newFilePath);
if (newFileDir !== newFolderPath) {
await mkdir(newFileDir, { recursive: true });
}
await this.lockService.withLock(file.absolutePath, () => fsRename(file.absolutePath, newFilePath));
movedFiles.push({ from: file.absolutePath, to: newFilePath });
if (file.absolutePath !== newFilePath) {
await this.lockService.withLock(file.absolutePath, () => fsRename(file.absolutePath, newFilePath));
movedFiles.push({ from: file.absolutePath, to: newFilePath });
}
}
} catch (error) {
for (const { from, to } of [...movedFiles].reverse()) {
@@ -329,21 +430,6 @@ export class FileRenameService implements OnModuleDestroy {
return normB.startsWith(normA) || normA.startsWith(normB);
}
private async rollbackFileRename(
bookId: number,
fileId: number,
oldPath: string,
oldRelPath: string | null,
oldBookFolderPath: string,
originalError: unknown,
): Promise<void> {
try {
await this.renameRepo.applyFileRename(bookId, fileId, oldPath, oldRelPath, oldBookFolderPath);
} catch (rollbackError) {
this.logRollbackFailure(bookId, originalError, rollbackError);
}
}
private async rollbackFolderRename(bookId: number, oldUpdates: BookFilePathUpdate[], oldFolderPath: string, originalError: unknown): Promise<void> {
try {
await this.renameRepo.applyFolderRename(bookId, oldUpdates, oldFolderPath);