feat(library): rename files on metadata update (#76)

Add file rename feature that renames physical book files on disk when
naming-relevant metadata fields are updated (title, authors, series,
seriesIndex, publishedYear).

Closes #69
This commit is contained in:
neon
2026-05-21 11:15:22 -06:00
committed by GitHub
parent b50c820804
commit 75d9161962
26 changed files with 11426 additions and 28 deletions
@@ -1,5 +1,6 @@
<script setup lang="ts">
defineProps<{
const props = defineProps<{
fileRenameEnabled: boolean
fileWriteEnabled: boolean
fileWriteWriteCover: boolean
fileWriteEpubEnabled: boolean
@@ -11,6 +12,7 @@ defineProps<{
}>()
const emit = defineEmits<{
'update:fileRenameEnabled': [value: boolean]
'update:fileWriteEnabled': [value: boolean]
'update:fileWriteWriteCover': [value: boolean]
'update:fileWriteEpubEnabled': [value: boolean]
@@ -21,6 +23,30 @@ const emit = defineEmits<{
'update:fileWriteCbxMaxFileSizeMb': [value: number]
}>()
function handleFileRenameToggle() {
emit('update:fileRenameEnabled', !props.fileRenameEnabled)
}
function handleFileWriteToggle() {
emit('update:fileWriteEnabled', !props.fileWriteEnabled)
}
function handleWriteCoverToggle() {
emit('update:fileWriteWriteCover', !props.fileWriteWriteCover)
}
function handleEpubToggle() {
emit('update:fileWriteEpubEnabled', !props.fileWriteEpubEnabled)
}
function handlePdfToggle() {
emit('update:fileWritePdfEnabled', !props.fileWritePdfEnabled)
}
function handleCbxToggle() {
emit('update:fileWriteCbxEnabled', !props.fileWriteCbxEnabled)
}
function onMaxSizeInput(field: 'epub' | 'pdf' | 'cbx', e: Event) {
const val = parseInt((e.target as HTMLInputElement).value, 10)
if (!isNaN(val) && val >= 1) {
@@ -33,9 +59,11 @@ function onMaxSizeInput(field: 'epub' | 'pdf' | 'cbx', e: Event) {
function onEpubMaxSizeInput(e: Event) {
onMaxSizeInput('epub', e)
}
function onPdfMaxSizeInput(e: Event) {
onMaxSizeInput('pdf', e)
}
function onCbxMaxSizeInput(e: Event) {
onMaxSizeInput('cbx', e)
}
@@ -43,7 +71,27 @@ function onCbxMaxSizeInput(e: Event) {
<template>
<div class="px-6 py-6 space-y-6">
<!-- Master toggle -->
<div>
<div class="flex items-center justify-between mb-1">
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground/80">Rename files on metadata update</p>
<button
class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none"
:class="fileRenameEnabled ? 'bg-primary' : 'bg-muted-foreground/30'"
role="switch"
:aria-checked="fileRenameEnabled"
@click="handleFileRenameToggle"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
:class="fileRenameEnabled ? 'translate-x-4' : 'translate-x-0'"
/>
</button>
</div>
<p class="text-xs text-muted-foreground">
When enabled, updating title, author, series, or year will rename the physical file using the library naming pattern.
</p>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<p class="text-[11px] font-semibold uppercase tracking-widest text-foreground/80">Write metadata to files</p>
@@ -52,7 +100,7 @@ function onCbxMaxSizeInput(e: Event) {
:class="fileWriteEnabled ? 'bg-primary' : 'bg-muted-foreground/30'"
role="switch"
:aria-checked="fileWriteEnabled"
@click="emit('update:fileWriteEnabled', !fileWriteEnabled)"
@click="handleFileWriteToggle"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
@@ -64,7 +112,6 @@ function onCbxMaxSizeInput(e: Event) {
</div>
<template v-if="fileWriteEnabled">
<!-- Write cover -->
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium text-foreground">Include cover image</p>
@@ -75,7 +122,7 @@ function onCbxMaxSizeInput(e: Event) {
:class="fileWriteWriteCover ? 'bg-primary' : 'bg-muted-foreground/30'"
role="switch"
:aria-checked="fileWriteWriteCover"
@click="emit('update:fileWriteWriteCover', !fileWriteWriteCover)"
@click="handleWriteCoverToggle"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
@@ -86,7 +133,6 @@ function onCbxMaxSizeInput(e: Event) {
<div class="border-t border-border" />
<!-- EPUB -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
@@ -98,7 +144,7 @@ function onCbxMaxSizeInput(e: Event) {
:class="fileWriteEpubEnabled ? 'bg-primary' : 'bg-muted-foreground/30'"
role="switch"
:aria-checked="fileWriteEpubEnabled"
@click="emit('update:fileWriteEpubEnabled', !fileWriteEpubEnabled)"
@click="handleEpubToggle"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
@@ -119,7 +165,6 @@ function onCbxMaxSizeInput(e: Event) {
</div>
</div>
<!-- PDF -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
@@ -131,7 +176,7 @@ function onCbxMaxSizeInput(e: Event) {
:class="fileWritePdfEnabled ? 'bg-primary' : 'bg-muted-foreground/30'"
role="switch"
:aria-checked="fileWritePdfEnabled"
@click="emit('update:fileWritePdfEnabled', !fileWritePdfEnabled)"
@click="handlePdfToggle"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
@@ -152,7 +197,6 @@ function onCbxMaxSizeInput(e: Event) {
</div>
</div>
<!-- CBX -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<div>
@@ -164,7 +208,7 @@ function onCbxMaxSizeInput(e: Event) {
:class="fileWriteCbxEnabled ? 'bg-primary' : 'bg-muted-foreground/30'"
role="switch"
:aria-checked="fileWriteCbxEnabled"
@click="emit('update:fileWriteCbxEnabled', !fileWriteCbxEnabled)"
@click="handleCbxToggle"
>
<span
class="pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"
@@ -136,6 +136,7 @@ const sectionProps = computed(() => ({
formatPriority: form.formatPriority,
},
fileWrite: {
fileRenameEnabled: form.fileRenameEnabled,
fileWriteEnabled: form.fileWriteEnabled,
fileWriteWriteCover: form.fileWriteWriteCover,
fileWriteEpubEnabled: form.fileWriteEpubEnabled,
@@ -172,6 +173,7 @@ function onSectionEvent(id: SectionId, event: string, value: unknown) {
else if (event === 'update:markAsFinishedPercentComplete') form.markAsFinishedPercentComplete = value as number
else if (event === 'update:watch') form.watch = value as boolean
else if (event === 'update:autoScanCronExpression') form.autoScanCronExpression = value as string | null
else if (event === 'update:fileRenameEnabled') form.fileRenameEnabled = value as boolean
else if (event === 'update:fileWriteEnabled') form.fileWriteEnabled = value as boolean
else if (event === 'update:fileWriteWriteCover') form.fileWriteWriteCover = value as boolean
else if (event === 'update:fileWriteEpubEnabled') form.fileWriteEpubEnabled = value as boolean
@@ -276,6 +278,7 @@ function onSectionEvent(id: SectionId, event: string, value: unknown) {
@update:markAsFinishedPercentComplete="onSectionEvent(activeId, 'update:markAsFinishedPercentComplete', $event)"
@update:watch="onSectionEvent(activeId, 'update:watch', $event)"
@update:autoScanCronExpression="onSectionEvent(activeId, 'update:autoScanCronExpression', $event)"
@update:fileRenameEnabled="onSectionEvent(activeId, 'update:fileRenameEnabled', $event)"
@update:fileWriteEnabled="onSectionEvent(activeId, 'update:fileWriteEnabled', $event)"
@update:fileWriteWriteCover="onSectionEvent(activeId, 'update:fileWriteWriteCover', $event)"
@update:fileWriteEpubEnabled="onSectionEvent(activeId, 'update:fileWriteEpubEnabled', $event)"
@@ -411,6 +414,7 @@ function onSectionEvent(id: SectionId, event: string, value: unknown) {
@update:markAsFinishedPercentComplete="onSectionEvent(activeId, 'update:markAsFinishedPercentComplete', $event)"
@update:watch="onSectionEvent(activeId, 'update:watch', $event)"
@update:autoScanCronExpression="onSectionEvent(activeId, 'update:autoScanCronExpression', $event)"
@update:fileRenameEnabled="onSectionEvent(activeId, 'update:fileRenameEnabled', $event)"
@update:fileWriteEnabled="onSectionEvent(activeId, 'update:fileWriteEnabled', $event)"
@update:fileWriteWriteCover="onSectionEvent(activeId, 'update:fileWriteWriteCover', $event)"
@update:fileWriteEpubEnabled="onSectionEvent(activeId, 'update:fileWriteEpubEnabled', $event)"
@@ -0,0 +1,71 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import LibraryCreatorFileWrite from '../LibraryCreatorFileWrite.vue'
describe('LibraryCreatorFileWrite', () => {
function mountComponent(props: Record<string, unknown> = {}) {
return mount(LibraryCreatorFileWrite, {
props: {
fileRenameEnabled: false,
fileWriteEnabled: false,
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWriteEpubMaxFileSizeMb: 100,
fileWritePdfEnabled: false,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
fileWriteCbxMaxFileSizeMb: 500,
...props,
},
})
}
it('emits rename toggle updates and hides file-write detail controls when disabled', async () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Rename files on metadata update')
expect(wrapper.text()).not.toContain('Include cover image')
const renameSwitch = wrapper.findAll('[role="switch"]')[0]
expect(renameSwitch).toBeDefined()
await renameSwitch!.trigger('click')
expect(wrapper.emitted('update:fileRenameEnabled')).toEqual([[true]])
})
it('emits file-write toggles and max-size updates when advanced controls are visible', async () => {
const wrapper = mountComponent({
fileRenameEnabled: true,
fileWriteEnabled: true,
fileWriteWriteCover: true,
fileWriteEpubEnabled: true,
fileWriteEpubMaxFileSizeMb: 10,
fileWritePdfEnabled: true,
fileWritePdfMaxFileSizeMb: 20,
fileWriteCbxEnabled: true,
fileWriteCbxMaxFileSizeMb: 30,
})
const switches = wrapper.findAll('[role="switch"]')
expect(switches).toHaveLength(6)
await switches[1]!.trigger('click')
await switches[2]!.trigger('click')
await switches[3]!.trigger('click')
await switches[4]!.trigger('click')
await switches[5]!.trigger('click')
const inputs = wrapper.findAll('input[type="number"]')
expect(inputs).toHaveLength(3)
await inputs[0]!.setValue('15')
await inputs[1]!.setValue('25')
await inputs[2]!.setValue('35')
expect(wrapper.emitted('update:fileWriteEnabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteWriteCover')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteEpubEnabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWritePdfEnabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteCbxEnabled')).toEqual([[false]])
expect(wrapper.emitted('update:fileWriteEpubMaxFileSizeMb')).toEqual([[15]])
expect(wrapper.emitted('update:fileWritePdfMaxFileSizeMb')).toEqual([[25]])
expect(wrapper.emitted('update:fileWriteCbxMaxFileSizeMb')).toEqual([[35]])
})
})
@@ -23,4 +23,41 @@ describe('useLibraryCreator', () => {
expect(creator.error.value).toBe('Choose an icon')
expect(apiMock).not.toHaveBeenCalled()
})
it('hydrates fileRenameEnabled when editing a library', async () => {
const { useLibraryCreator } = await import('../useLibraryCreator')
const creator = useLibraryCreator()
creator.initEdit({
id: 1,
name: 'Main Library',
icon: 'BookOpen',
displayOrder: 0,
coverAspectRatio: '2/3',
watch: false,
autoScanCronExpression: null,
metadataPrecedence: ['embedded'],
formatPriority: ['epub'],
allowedFormats: ['epub'],
organizationMode: 'book_per_folder',
excludePatterns: [],
readingThreshold: 0.25,
markAsFinishedPercentComplete: 98,
fileNamingPattern: null,
fileWriteEnabled: false,
fileWriteWriteCover: true,
fileWriteEpubEnabled: true,
fileWriteEpubMaxFileSizeMb: 100,
fileWritePdfEnabled: true,
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
fileWriteCbxMaxFileSizeMb: 500,
fileRenameEnabled: true,
folders: [{ id: 1, path: '/books', createdAt: '2026-01-01T00:00:00.000Z' }],
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
})
expect(creator.form.fileRenameEnabled).toBe(true)
})
})
@@ -36,6 +36,7 @@ function blankForm() {
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
fileWriteCbxMaxFileSizeMb: 500,
fileRenameEnabled: false,
}
}
@@ -80,6 +81,7 @@ export function useLibraryCreator() {
form.fileWritePdfMaxFileSizeMb = library.fileWritePdfMaxFileSizeMb
form.fileWriteCbxEnabled = library.fileWriteCbxEnabled
form.fileWriteCbxMaxFileSizeMb = library.fileWriteCbxMaxFileSizeMb
form.fileRenameEnabled = library.fileRenameEnabled
mode.value = 'edit'
editingLibraryId.value = library.id
prescanResult.value = null
@@ -30,6 +30,7 @@ const FAILED_TYPES = new Set([
'email_failed',
'migration_failed',
'file_write_back_failed',
'file_rename_failed',
])
const WARNING_TYPES = new Set(['books_unavailable'])
@@ -52,6 +53,8 @@ const ICON_MAP: Record<string, typeof FolderSync> = {
migration_failed: ArrowRightLeft,
file_write_back_completed: FileDown,
file_write_back_failed: FileDown,
file_rename_completed: FileDown,
file_rename_failed: FileDown,
}
const icon = computed(() => ICON_MAP[props.notification.type] ?? FolderSync)
+8
View File
@@ -19,3 +19,11 @@ export type WriteLogEntry = {
durationMs: number | null;
errorMessage: string | null;
};
export interface FileRenameResult {
status: "success" | "skipped" | "failed";
reason?: string;
oldPath?: string;
newPath?: string;
durationMs: number;
}
+1
View File
@@ -70,6 +70,7 @@ export interface Library {
fileWritePdfMaxFileSizeMb: number;
fileWriteCbxEnabled: boolean;
fileWriteCbxMaxFileSizeMb: number;
fileRenameEnabled: boolean;
folders: LibraryFolder[];
bookCount?: number;
createdAt: string;
+4
View File
@@ -16,6 +16,8 @@ export const NotificationType = {
MigrationFailed: "migration_failed",
FileWriteBackCompleted: "file_write_back_completed",
FileWriteBackFailed: "file_write_back_failed",
FileRenameCompleted: "file_rename_completed",
FileRenameFailed: "file_rename_failed",
AchievementUnlocked: "achievement_unlocked",
} as const;
@@ -30,6 +32,7 @@ export const NOTIFICATION_CATEGORIES = {
koboSync: [NotificationType.KoboSyncCompleted],
migration: [NotificationType.MigrationCompleted, NotificationType.MigrationFailed],
fileWriteBack: [NotificationType.FileWriteBackCompleted, NotificationType.FileWriteBackFailed],
fileRename: [NotificationType.FileRenameCompleted, NotificationType.FileRenameFailed],
achievements: [NotificationType.AchievementUnlocked],
} as const;
@@ -44,6 +47,7 @@ export const NOTIFICATION_CATEGORY_LABELS: Record<NotificationCategory, string>
koboSync: "Kobo Sync",
migration: "Data Migration",
fileWriteBack: "File Write-back",
fileRename: "File Rename",
achievements: "Achievements",
};
@@ -0,0 +1 @@
ALTER TABLE "libraries" ADD COLUMN "file_rename_enabled" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -43,6 +43,13 @@
"when": 1779204354944,
"tag": "0005_add_hardcover_tables",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1779381509015,
"tag": "0006_add_file_rename_enabled",
"breakpoints": true
}
]
}
}
+1
View File
@@ -55,6 +55,7 @@ export const libraries = pgTable(
fileWritePdfMaxFileSizeMb: integer('file_write_pdf_max_file_size_mb').notNull().default(100),
fileWriteCbxEnabled: boolean('file_write_cbx_enabled').notNull().default(false),
fileWriteCbxMaxFileSizeMb: integer('file_write_cbx_max_file_size_mb').notNull().default(500),
fileRenameEnabled: boolean('file_rename_enabled').notNull().default(false),
// File naming pattern for uploads (null = use global default)
fileNamingPattern: varchar('file_naming_pattern', { length: 500 }),
+28 -9
View File
@@ -145,6 +145,9 @@ function makeService() {
const fileWriteService = {
scheduleWrite: vi.fn(),
};
const fileRenameService = {
scheduleRename: vi.fn(),
};
const achievementEvents = {
emit: vi.fn(),
};
@@ -202,6 +205,7 @@ function makeService() {
bookMetadataLockService as never,
embedder as never,
fileWriteService as never,
fileRenameService as never,
achievementEvents as never,
);
@@ -218,6 +222,7 @@ function makeService() {
userBookStatusService,
embedder,
fileWriteService,
fileRenameService,
achievementEvents,
narratorService,
comicMetadataService,
@@ -1082,8 +1087,8 @@ describe('BookService', () => {
expect(metadataService.downloadAndSaveCover).not.toHaveBeenCalled();
});
it('updateMetadata writes scalar fields, collections, schedules file write, and triggers embedding', async () => {
const { service, bookRepo, metadataService, embedder, fileWriteService } = makeService();
it('updateMetadata writes scalar fields, collections, schedules file write, rename, and triggers embedding', async () => {
const { service, bookRepo, metadataService, embedder, fileWriteService, fileRenameService } = makeService();
const user = makeUser();
const verifySpy = vi.spyOn(service, 'verifyBookAccess').mockResolvedValue(undefined);
const detailSpy = vi.spyOn(service, 'getDetail').mockResolvedValue({ id: 5 } as never);
@@ -1123,6 +1128,7 @@ describe('BookService', () => {
expect(metadataService.replaceTags).toHaveBeenCalledWith(5, ['favorite'], { executor: expect.anything() });
expect(metadataService.emitAuthorsReplaced).toHaveBeenCalledWith(5, []);
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(5, 'auto', user.id);
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(5, user.id);
expect(embedder.embedBook).toHaveBeenCalledWith(5);
expect(detailSpy).toHaveBeenCalledWith(5, user);
});
@@ -2216,7 +2222,7 @@ describe('BookService', () => {
describe('bulk metadata actions', () => {
it('bulkSetRating updates ratings and queues file writes and score recalculation', async () => {
const { service, bookRepo, fileWriteService, scoreService } = makeService();
const { service, bookRepo, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser({ id: 42 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
@@ -2225,12 +2231,14 @@ describe('BookService', () => {
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3, 5], 4, 42);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 3, 'auto', 42);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 5, 'auto', 42);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(1, 3, 42);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(2, 5, 42);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 3);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(2, 5);
});
it('bulkSetRating skips books where rating is metadata-locked', async () => {
const { service, bookRepo, fileWriteService, scoreService, bookMetadataLockService } = makeService();
const { service, bookRepo, fileWriteService, fileRenameService, scoreService, bookMetadataLockService } = makeService();
const user = makeUser({ id: 42 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getBookIdsWithLockedField.mockResolvedValue(new Set([5]));
@@ -2241,6 +2249,8 @@ describe('BookService', () => {
expect(bookRepo.bulkSetRating).toHaveBeenCalledWith([3], 4, 42);
expect(fileWriteService.scheduleWrite).toHaveBeenCalledTimes(1);
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(3, 'auto', 42);
expect(fileRenameService.scheduleRename).toHaveBeenCalledTimes(1);
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(3, 42);
expect(scoreService.calculateAndSave).toHaveBeenCalledTimes(1);
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(3);
});
@@ -2260,7 +2270,7 @@ describe('BookService', () => {
});
it('bulkSetMetadata updates a single metadata field and queues follow-up work', async () => {
const { service, bookRepo, fileWriteService, scoreService } = makeService();
const { service, bookRepo, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser({ id: 11 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
@@ -2272,12 +2282,14 @@ describe('BookService', () => {
);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 7, 'auto', 11);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 9, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(1, 7, 11);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(2, 9, 11);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 7);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(2, 9);
});
it('bulkSetMetadata skips books where the field is metadata-locked', async () => {
const { service, bookRepo, fileWriteService, scoreService, bookMetadataLockService } = makeService();
const { service, bookRepo, fileWriteService, fileRenameService, scoreService, bookMetadataLockService } = makeService();
const user = makeUser({ id: 11 });
vi.spyOn(service, 'verifyLibraryAccessForBookIds').mockResolvedValue(undefined);
bookMetadataLockService.getBookIdsWithLockedField.mockResolvedValue(new Set([9]));
@@ -2288,12 +2300,14 @@ describe('BookService', () => {
expect(bookRepo.bulkUpdateMetadataFields).toHaveBeenCalledWith([7], expect.objectContaining({ language: 'fr', updatedAt: expect.any(Date) }));
expect(fileWriteService.scheduleWrite).toHaveBeenCalledTimes(1);
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(7, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenCalledTimes(1);
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(7, 11);
expect(scoreService.calculateAndSave).toHaveBeenCalledTimes(1);
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(7);
});
it('bulkSetMetadata replaces relation fields in a transaction', async () => {
const { service, bookRepo, metadataService, fileWriteService, scoreService } = makeService();
const { service, bookRepo, metadataService, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser({ id: 11 });
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn(), update: vi.fn() };
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
@@ -2305,12 +2319,14 @@ describe('BookService', () => {
expect(metadataService.replaceTags).toHaveBeenNthCalledWith(2, 9, ['favorite', 'reading'], { executor: tx });
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(1, 7, 'auto', 11);
expect(fileWriteService.scheduleWrite).toHaveBeenNthCalledWith(2, 9, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(1, 7, 11);
expect(fileRenameService.scheduleRename).toHaveBeenNthCalledWith(2, 9, 11);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(1, 7);
expect(scoreService.calculateAndSave).toHaveBeenNthCalledWith(2, 9);
});
it('bulkSetMetadata replaces narrators for editable books only', async () => {
const { service, bookRepo, narratorService, fileWriteService, scoreService, bookMetadataLockService } = makeService();
const { service, bookRepo, narratorService, fileWriteService, fileRenameService, scoreService, bookMetadataLockService } = makeService();
const user = makeUser({ id: 11 });
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn(), update: vi.fn() };
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
@@ -2323,12 +2339,14 @@ describe('BookService', () => {
expect(narratorService.replaceForBook).toHaveBeenCalledWith(7, ['Narrator A'], { executor: tx });
expect(fileWriteService.scheduleWrite).toHaveBeenCalledTimes(1);
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(7, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenCalledTimes(1);
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(7, 11);
expect(scoreService.calculateAndSave).toHaveBeenCalledTimes(1);
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(7);
});
it('bulkUpdateTags performs all tag changes in one transaction and queues follow-up work', async () => {
const { service, bookRepo, metadataService, fileWriteService, scoreService } = makeService();
const { service, bookRepo, metadataService, fileWriteService, fileRenameService, scoreService } = makeService();
const user = makeUser({ id: 11 });
const tx = { select: vi.fn(), delete: vi.fn(), insert: vi.fn() };
bookRepo.withTransaction.mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx));
@@ -2340,6 +2358,7 @@ describe('BookService', () => {
expect(bookRepo.findTagsByBookIds).toHaveBeenCalledWith([7], tx);
expect(metadataService.replaceTags).toHaveBeenCalledWith(7, ['existing', 'new'], { executor: tx });
expect(fileWriteService.scheduleWrite).toHaveBeenCalledWith(7, 'auto', 11);
expect(fileRenameService.scheduleRename).toHaveBeenCalledWith(7, 11);
expect(scoreService.calculateAndSave).toHaveBeenCalledWith(7);
});
+7
View File
@@ -52,6 +52,7 @@ import { LibraryService } from '../library/library.service';
import { books } from '../../db/schema';
import { MetadataFetchPipeline, ResolvedMetadataFields } from '../metadata-fetch/metadata-fetch-pipeline';
import type { MetadataSearchParams } from '../metadata-fetch/providers/metadata-search-params';
import { FileRenameService, RENAME_RELEVANT_FIELDS } from '../file-write/file-rename.service';
import { FileWriteService } from '../file-write/file-write.service';
import { NarratorService } from '../narrator/narrator.service';
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
@@ -192,6 +193,7 @@ export class BookService {
private readonly bookMetadataLockService: BookMetadataLockService,
@Optional() private readonly embedder: BookEmbedderService,
@Optional() private readonly fileWriteService: FileWriteService,
@Optional() private readonly fileRenameService: FileRenameService,
@Optional() private readonly achievementEvents: AchievementEventsService,
) {
this.appDataPath = this.config.get<string>('storage.appDataPath')!;
@@ -1153,6 +1155,10 @@ export class BookService {
this.embedder?.embedBook(id).catch((err: Error) => this.logger.warn(`Embedding failed for book ${id}: ${err.message}`));
this.fileWriteService?.scheduleWrite(id, 'auto', user.id);
const hasRenameRelevantField = Array.from(RENAME_RELEVANT_FIELDS).some((field) => (dto as Record<string, unknown>)[field] !== undefined);
if (hasRenameRelevantField) {
this.fileRenameService?.scheduleRename(id, user.id);
}
this.scoreService.calculateAndSave(id).catch((err: Error) => this.logger.warn(`Score calculation failed for book ${id}: ${err.message}`));
const detail = await this.getDetail(id, user);
this.logger.log(
@@ -2183,6 +2189,7 @@ export class BookService {
private triggerPostMetadataUpdateEffects(bookIds: number[], userId: number): void {
for (const bookId of bookIds) {
this.fileWriteService?.scheduleWrite(bookId, 'auto', userId);
this.fileRenameService?.scheduleRename(bookId, userId);
void this.scoreService
.calculateAndSave(bookId)
.catch((err: Error) => this.logger.warn(`Score calculation failed for book ${bookId}: ${err.message}`));
@@ -0,0 +1,158 @@
import { FileRenameRepository } from './file-rename.repository';
describe('FileRenameRepository', () => {
function chain<T>(result: T) {
return {
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue(result),
orderBy: vi.fn().mockResolvedValue(result),
};
}
it('findBookRenameData maps book, library, and author rename metadata', async () => {
const row = {
fileId: 10,
absolutePath: '/library/old-folder/Old Title.epub',
relPath: 'old-folder/Old Title.epub',
format: 'epub',
role: 'primary',
libraryFolderId: 20,
libraryFolderPath: '/library',
libraryId: 30,
organizationMode: 'book_per_folder',
fileRenameEnabled: true,
fileNamingPattern: '{authors}/{title}/{title}',
bookFolderPath: '/library/old-folder',
title: 'Dune',
subtitle: 'Book One',
publisher: 'Ace',
language: 'en',
isbn13: '9780441172719',
publishedYear: 1965,
seriesName: 'Dune',
seriesIndex: 1,
};
const db = {
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([row]),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockResolvedValue([{ name: 'Frank Herbert' }, { name: 'Brian Herbert' }]),
}),
};
const repo = new FileRenameRepository(db as never);
await expect(repo.findBookRenameData(7)).resolves.toEqual({
file: {
id: 10,
absolutePath: '/library/old-folder/Old Title.epub',
relPath: 'old-folder/Old Title.epub',
format: 'epub',
role: 'primary',
},
libraryId: 30,
libraryFolderId: 20,
libraryFolderPath: '/library',
organizationMode: 'book_per_folder',
fileRenameEnabled: true,
fileNamingPattern: '{authors}/{title}/{title}',
bookFolderPath: '/library/old-folder',
metadata: {
title: 'Dune',
subtitle: 'Book One',
publisher: 'Ace',
language: 'en',
isbn13: '9780441172719',
publishedYear: 1965,
seriesName: 'Dune',
seriesIndex: 1,
},
authors: ['Frank Herbert', 'Brian Herbert'],
});
});
it('applyFileRename updates file and book paths in one transaction', async () => {
const setCalls: unknown[] = [];
const tx = {
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation((values: unknown) => {
setCalls.push(values);
return { where: vi.fn().mockResolvedValue(undefined) };
}),
})),
};
const db = {
transaction: vi.fn().mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx)),
};
const repo = new FileRenameRepository(db as never);
await repo.applyFileRename(5, 10, '/library/new-folder/Dune.epub', 'new-folder/Dune.epub', '/library/new-folder/Dune.epub');
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(setCalls).toEqual([
{ absolutePath: '/library/new-folder/Dune.epub', relPath: 'new-folder/Dune.epub' },
{ folderPath: '/library/new-folder/Dune.epub' },
]);
});
it('applyFolderRename updates every file path and the stored book folder', async () => {
const setCalls: unknown[] = [];
const tx = {
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation((values: unknown) => {
setCalls.push(values);
return { where: vi.fn().mockResolvedValue(undefined) };
}),
})),
};
const db = {
transaction: vi.fn().mockImplementation(async (callback: (value: unknown) => Promise<unknown>) => callback(tx)),
};
const repo = new FileRenameRepository(db as never);
await repo.applyFolderRename(
5,
[
{ id: 10, absolutePath: '/library/Frank Herbert/Dune/Dune.epub', relPath: 'Frank Herbert/Dune/Dune.epub' },
{ id: 11, absolutePath: '/library/Frank Herbert/Dune/cover.jpg', relPath: 'Frank Herbert/Dune/cover.jpg' },
],
'/library/Frank Herbert/Dune',
);
expect(db.transaction).toHaveBeenCalledTimes(1);
expect(setCalls).toEqual([
{ absolutePath: '/library/Frank Herbert/Dune/Dune.epub', relPath: 'Frank Herbert/Dune/Dune.epub' },
{ absolutePath: '/library/Frank Herbert/Dune/cover.jpg', relPath: 'Frank Herbert/Dune/cover.jpg' },
{ folderPath: '/library/Frank Herbert/Dune' },
]);
});
it('checkPathTakenByOtherBook only flags collisions from other books', async () => {
const db = {
select: vi
.fn()
.mockReturnValueOnce(chain([{ bookId: 8 }]))
.mockReturnValueOnce(chain([{ bookId: 5 }]))
.mockReturnValueOnce(chain([])),
};
const repo = new FileRenameRepository(db as never);
await expect(repo.checkPathTakenByOtherBook('/library/Dune.epub', 5)).resolves.toBe(true);
await expect(repo.checkPathTakenByOtherBook('/library/Dune.epub', 5)).resolves.toBe(false);
await expect(repo.checkPathTakenByOtherBook('/library/Dune.epub', 5)).resolves.toBe(false);
});
});
@@ -0,0 +1,165 @@
import { Inject, Injectable } from '@nestjs/common';
import { asc, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
import { authors, bookAuthors, bookFiles, bookMetadata, books, libraries, libraryFolders } from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
export interface BookRenameData {
file: {
id: number;
absolutePath: string;
relPath: string | null;
format: string | null;
role: string;
};
libraryId: number;
libraryFolderId: number;
libraryFolderPath: string;
organizationMode: string;
fileRenameEnabled: boolean;
fileNamingPattern: string | null;
bookFolderPath: string;
metadata: {
title: string | null;
subtitle: string | null;
publisher: string | null;
language: string | null;
isbn13: string | null;
publishedYear: number | null;
seriesName: string | null;
seriesIndex: number | null;
};
authors: string[];
}
export interface BookFilePathUpdate {
id: number;
absolutePath: string;
relPath: string | null;
}
@Injectable()
export class FileRenameRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findBookRenameData(bookId: number): Promise<BookRenameData | null> {
const [row] = await this.db
.select({
fileId: bookFiles.id,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
format: bookFiles.format,
role: bookFiles.role,
libraryFolderId: bookFiles.libraryFolderId,
libraryFolderPath: libraryFolders.path,
libraryId: books.libraryId,
organizationMode: libraries.organizationMode,
fileRenameEnabled: libraries.fileRenameEnabled,
fileNamingPattern: libraries.fileNamingPattern,
bookFolderPath: books.folderPath,
title: bookMetadata.title,
subtitle: bookMetadata.subtitle,
publisher: bookMetadata.publisher,
language: bookMetadata.language,
isbn13: bookMetadata.isbn13,
publishedYear: bookMetadata.publishedYear,
seriesName: bookMetadata.seriesName,
seriesIndex: bookMetadata.seriesIndex,
})
.from(books)
.innerJoin(bookMetadata, eq(bookMetadata.bookId, books.id))
.innerJoin(bookFiles, eq(bookFiles.id, books.primaryFileId))
.innerJoin(libraryFolders, eq(libraryFolders.id, bookFiles.libraryFolderId))
.innerJoin(libraries, eq(libraries.id, books.libraryId))
.where(eq(books.id, bookId));
if (!row) return null;
const authorRows = await this.db
.select({ name: authors.name })
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(eq(bookAuthors.bookId, bookId))
.orderBy(asc(bookAuthors.displayOrder));
return {
file: {
id: row.fileId,
absolutePath: row.absolutePath,
relPath: row.relPath,
format: row.format,
role: row.role,
},
libraryId: row.libraryId,
libraryFolderId: row.libraryFolderId,
libraryFolderPath: row.libraryFolderPath,
organizationMode: row.organizationMode,
fileRenameEnabled: row.fileRenameEnabled,
fileNamingPattern: row.fileNamingPattern,
bookFolderPath: row.bookFolderPath,
metadata: {
title: row.title,
subtitle: row.subtitle,
publisher: row.publisher,
language: row.language,
isbn13: row.isbn13,
publishedYear: row.publishedYear,
seriesName: row.seriesName,
seriesIndex: row.seriesIndex,
},
authors: authorRows.map((author) => author.name),
};
}
async findAllBookFiles(bookId: number) {
return this.db
.select({
id: bookFiles.id,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
role: bookFiles.role,
})
.from(bookFiles)
.where(eq(bookFiles.bookId, bookId))
.orderBy(asc(bookFiles.id));
}
async updateBookFilePath(fileId: number, absolutePath: string, relPath: string | null): Promise<void> {
await this.db.update(bookFiles).set({ absolutePath, relPath }).where(eq(bookFiles.id, fileId));
}
async updateBookFolderPath(bookId: number, folderPath: string): Promise<void> {
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) {
await tx.update(bookFiles).set({ absolutePath: update.absolutePath, relPath: update.relPath }).where(eq(bookFiles.id, update.id));
}
await tx.update(books).set({ folderPath }).where(eq(books.id, bookId));
});
}
async checkPathTakenByOtherBook(absolutePath: string, currentBookId: number): Promise<boolean> {
const rows = await this.db
.select({ bookId: books.id })
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(bookFiles.absolutePath, absolutePath))
.limit(1);
return rows.length > 0 && rows[0].bookId !== currentBookId;
}
}
@@ -0,0 +1,463 @@
import { ConfigService } from '@nestjs/config';
import { NotificationType } from '@bookorbit/types';
import type { MockedFunction } from 'vitest';
import { access, mkdir, readdir, rename as fsRename, rmdir } from 'fs/promises';
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
return {
...actual,
access: vi.fn(),
mkdir: vi.fn(),
readdir: vi.fn(),
rename: vi.fn(),
rmdir: vi.fn(),
};
});
import type { BookRenameData } from './file-rename.repository';
import { FileRenameService } from './file-rename.service';
const mockAccess = access as MockedFunction<typeof access>;
const mockMkdir = mkdir as MockedFunction<typeof mkdir>;
const mockReaddir = readdir as MockedFunction<typeof readdir>;
const mockRename = fsRename as MockedFunction<typeof fsRename>;
const mockRmdir = rmdir as MockedFunction<typeof rmdir>;
type RenameDataOverrides = Partial<BookRenameData> & {
file?: Partial<BookRenameData['file']>;
metadata?: Partial<BookRenameData['metadata']>;
};
describe('FileRenameService', () => {
function makeRenameData(overrides: RenameDataOverrides = {}): BookRenameData {
const base: BookRenameData = {
file: {
id: 10,
absolutePath: '/library/old-folder/Old Title.epub',
relPath: 'old-folder/Old Title.epub',
format: 'epub',
role: 'primary',
},
libraryId: 1,
libraryFolderId: 2,
libraryFolderPath: '/library',
organizationMode: 'book_per_file',
fileRenameEnabled: true,
fileNamingPattern: '{authors}/{title}',
bookFolderPath: '/library/old-folder/Old Title.epub',
metadata: {
title: 'Dune',
subtitle: null,
publisher: 'Ace',
language: 'en',
isbn13: '9780441172719',
publishedYear: 1965,
seriesName: null,
seriesIndex: null,
},
authors: ['Frank Herbert'],
};
return {
...base,
...overrides,
file: { ...base.file, ...overrides.file },
metadata: { ...base.metadata, ...overrides.metadata },
authors: overrides.authors ?? base.authors,
};
}
function makeService(configValues: Record<string, unknown> = {}) {
const renameRepo = {
findBookRenameData: vi.fn(),
checkPathTakenByOtherBook: vi.fn().mockResolvedValue(false),
applyFileRename: vi.fn().mockResolvedValue(undefined),
applyFolderRename: vi.fn().mockResolvedValue(undefined),
findAllBookFiles: vi.fn().mockResolvedValue([]),
};
const lockService = {
withLock: vi.fn().mockImplementation(async (_path: string, fn: () => Promise<unknown>) => fn()),
};
const appSettings = {
getUploadPattern: vi.fn().mockResolvedValue('{authors}/{title}'),
getUploadPatternBookPerFolder: vi.fn().mockResolvedValue('{authors}/{title}/{title}'),
};
const notificationService = {
notify: vi.fn().mockResolvedValue(undefined),
};
const config = {
get: vi.fn().mockImplementation((key: string) => configValues[key]),
} as unknown as ConfigService;
const service = new FileRenameService(renameRepo as never, lockService as never, appSettings as never, notificationService as never, config);
return { service, renameRepo, lockService, appSettings, notificationService };
}
beforeEach(() => {
vi.clearAllMocks();
vi.useRealTimers();
mockAccess.mockReset();
mockMkdir.mockReset();
mockReaddir.mockReset();
mockRename.mockReset();
mockRmdir.mockReset();
mockAccess.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }) as never);
mockMkdir.mockResolvedValue(undefined as never);
mockReaddir.mockResolvedValue(['still-here'] as never);
mockRename.mockResolvedValue(undefined as never);
mockRmdir.mockResolvedValue(undefined as never);
});
afterEach(() => {
vi.useRealTimers();
});
it('debounces scheduled renames per book and keeps the latest user id', async () => {
vi.useFakeTimers();
const { service } = makeService({ 'fileWrite.debounceMs': 25 });
const performRename = vi.spyOn(service, 'performRename').mockResolvedValue({
status: 'skipped',
reason: 'disabled',
durationMs: 0,
});
service.scheduleRename(7, 1);
service.scheduleRename(7, 2);
await vi.advanceTimersByTimeAsync(24);
expect(performRename).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1);
await service.drainScheduledRenamesForTests();
expect(performRename).toHaveBeenCalledTimes(1);
expect(performRename).toHaveBeenCalledWith(7, 2);
});
it('skips when file renaming is disabled for the library', async () => {
const { service, renameRepo, notificationService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(makeRenameData({ fileRenameEnabled: false }));
await expect(service.performRename(5, 12)).resolves.toEqual(expect.objectContaining({ status: 'skipped', reason: 'disabled' }));
expect(notificationService.notify).not.toHaveBeenCalled();
});
it('skips and notifies when another book already owns the target path', async () => {
const { service, renameRepo, notificationService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
absolutePath: '/library/Old Title.epub',
relPath: 'Old Title.epub',
},
metadata: { title: 'New Title' },
bookFolderPath: '/library/Old Title.epub',
}),
);
renameRepo.checkPathTakenByOtherBook.mockResolvedValue(true);
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'skipped',
reason: 'collision',
oldPath: '/library/Old Title.epub',
newPath: '/library/Frank Herbert/New Title.epub',
}),
);
expect(notificationService.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: NotificationType.FileRenameFailed,
scope: { kind: 'user', userId: 12 },
meta: { bookId: 5 },
}),
);
});
it('renames a single file after updating database paths first', async () => {
const { service, renameRepo, lockService, notificationService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
absolutePath: '/library/Old Title.epub',
relPath: 'Old Title.epub',
},
bookFolderPath: '/library/Old Title.epub',
}),
);
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'success',
oldPath: '/library/Old Title.epub',
newPath: '/library/Frank Herbert/Dune.epub',
}),
);
expect(renameRepo.applyFileRename).toHaveBeenCalledWith(
5,
10,
'/library/Frank Herbert/Dune.epub',
'Frank Herbert/Dune.epub',
'/library/Frank Herbert/Dune.epub',
);
expect(renameRepo.applyFileRename.mock.invocationCallOrder[0]).toBeLessThan(mockRename.mock.invocationCallOrder[0]);
expect(mockMkdir).toHaveBeenCalledWith('/library/Frank Herbert', { recursive: true });
expect(lockService.withLock).toHaveBeenCalledWith('/library/Old Title.epub', expect.any(Function));
expect(mockRename).toHaveBeenCalledWith('/library/Old Title.epub', '/library/Frank Herbert/Dune.epub');
expect(notificationService.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: NotificationType.FileRenameCompleted,
meta: {
bookId: 5,
oldPath: '/library/Old Title.epub',
newPath: '/library/Frank Herbert/Dune.epub',
},
}),
);
});
it('renames the folder, primary file, and companion files in book-per-folder mode', async () => {
const { service, renameRepo, lockService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: '{authors}/{title}/{title}',
file: {
absolutePath: '/library/old-folder/Old Title.epub',
relPath: 'old-folder/Old Title.epub',
},
bookFolderPath: '/library/old-folder',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{
id: 10,
absolutePath: '/library/old-folder/Old Title.epub',
relPath: 'old-folder/Old Title.epub',
role: 'primary',
},
{
id: 11,
absolutePath: '/library/old-folder/cover.jpg',
relPath: 'old-folder/cover.jpg',
role: 'cover',
},
]);
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'success',
oldPath: '/library/old-folder/Old Title.epub',
newPath: '/library/Frank Herbert/Dune/Dune.epub',
}),
);
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
5,
[
{
id: 10,
absolutePath: '/library/Frank Herbert/Dune/Dune.epub',
relPath: 'Frank Herbert/Dune/Dune.epub',
},
{
id: 11,
absolutePath: '/library/Frank Herbert/Dune/cover.jpg',
relPath: 'Frank Herbert/Dune/cover.jpg',
},
],
'/library/Frank Herbert/Dune',
);
expect(mockRename).toHaveBeenNthCalledWith(1, '/library/old-folder', '/library/Frank Herbert/Dune');
expect(mockRename).toHaveBeenNthCalledWith(2, '/library/Frank Herbert/Dune/Old Title.epub', '/library/Frank Herbert/Dune/Dune.epub');
expect(lockService.withLock).toHaveBeenNthCalledWith(1, '/library/old-folder', expect.any(Function));
expect(lockService.withLock).toHaveBeenNthCalledWith(2, '/library/Frank Herbert/Dune/Old Title.epub', expect.any(Function));
});
it('rolls back database paths and reports failure when the filesystem rename fails', async () => {
const { service, renameRepo, notificationService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
absolutePath: '/library/Old Title.epub',
relPath: 'Old Title.epub',
},
bookFolderPath: '/library/Old Title.epub',
}),
);
mockRename.mockRejectedValueOnce(new Error('rename blew up'));
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'failed',
reason: 'rename blew up',
oldPath: '/library/Old Title.epub',
newPath: '/library/Frank Herbert/Dune.epub',
}),
);
expect(renameRepo.applyFileRename).toHaveBeenNthCalledWith(
1,
5,
10,
'/library/Frank Herbert/Dune.epub',
'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(notificationService.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: NotificationType.FileRenameFailed,
message: 'rename blew up',
}),
);
});
it('skips when book data is not found', async () => {
const { service, renameRepo, notificationService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(null);
const result = await service.performRename(99, 1);
expect(result).toEqual(expect.objectContaining({ status: 'skipped', reason: 'book not found' }));
expect(notificationService.notify).not.toHaveBeenCalled();
});
it('skips when computed path is identical to current path', async () => {
const { service, renameRepo, notificationService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
absolutePath: '/library/Frank Herbert/Dune.epub',
relPath: 'Frank Herbert/Dune.epub',
},
bookFolderPath: '/library/Frank Herbert/Dune.epub',
}),
);
const result = await service.performRename(5, 12);
expect(result).toEqual(expect.objectContaining({ status: 'skipped', reason: 'path unchanged' }));
expect(notificationService.notify).not.toHaveBeenCalled();
});
it('falls back to global upload pattern when library has no fileNamingPattern', async () => {
const { service, renameRepo, appSettings } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
fileNamingPattern: null,
file: {
absolutePath: '/library/Old Title.epub',
relPath: 'Old Title.epub',
},
bookFolderPath: '/library/Old Title.epub',
}),
);
await service.performRename(5, 12);
expect(appSettings.getUploadPattern).toHaveBeenCalled();
expect(mockRename).toHaveBeenCalledWith('/library/Old Title.epub', '/library/Frank Herbert/Dune.epub');
});
it('falls back to global book-per-folder pattern for book_per_folder libraries without fileNamingPattern', async () => {
const { service, renameRepo, appSettings } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: null,
file: {
absolutePath: '/library/old-folder/Old Title.epub',
relPath: 'old-folder/Old Title.epub',
},
bookFolderPath: '/library/old-folder',
}),
);
await service.performRename(5, 12);
expect(appSettings.getUploadPatternBookPerFolder).toHaveBeenCalled();
});
it('skips and notifies when target path already exists on disk', async () => {
const { service, renameRepo, notificationService } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
absolutePath: '/library/Old Title.epub',
relPath: 'Old Title.epub',
},
bookFolderPath: '/library/Old Title.epub',
}),
);
mockAccess.mockResolvedValue(undefined as never);
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'skipped',
reason: 'target path already exists on disk',
oldPath: '/library/Old Title.epub',
newPath: '/library/Frank Herbert/Dune.epub',
}),
);
expect(notificationService.notify).toHaveBeenCalledWith(expect.objectContaining({ type: NotificationType.FileRenameFailed }));
expect(mockRename).not.toHaveBeenCalled();
});
it('renames only the file (not folder) in book_per_folder mode when folder path is unchanged', async () => {
const { service, renameRepo } = makeService();
// Pattern resolves to the same folder but a different filename
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: 'same-folder/{title}',
file: {
absolutePath: '/library/same-folder/Old Title.epub',
relPath: 'same-folder/Old Title.epub',
},
bookFolderPath: '/library/same-folder',
}),
);
const result = await service.performRename(5, 12);
expect(result).toEqual(expect.objectContaining({ status: 'success' }));
expect(renameRepo.applyFileRename).toHaveBeenCalled();
expect(renameRepo.applyFolderRename).not.toHaveBeenCalled();
expect(mockRename).toHaveBeenCalledWith('/library/same-folder/Old Title.epub', '/library/same-folder/Dune.epub');
});
it('removes old empty directory after file-only rename moves to a new folder', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
file: {
absolutePath: '/library/Old Title.epub',
relPath: 'Old Title.epub',
},
bookFolderPath: '/library/Old Title.epub',
}),
);
mockReaddir.mockResolvedValue([] as never);
await service.performRename(5, 12);
expect(mockRmdir).toHaveBeenCalledWith('/library');
});
it('clears all timers on module destroy', () => {
const { service } = makeService();
service.scheduleRename(1, 10);
service.scheduleRename(2, 10);
expect(() => service.onModuleDestroy()).not.toThrow();
});
});
@@ -0,0 +1,365 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { access, mkdir, readdir, rename as fsRename, rmdir } from 'fs/promises';
import { basename, dirname, extname, join, relative } from 'path';
import type { FileRenameResult } from '@bookorbit/types';
import { NotificationType, resolveUploadPath } from '@bookorbit/types';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { AppSettingsService } from '../app-settings/app-settings.service';
import { NotificationService } from '../notification/notification.service';
import type { BookFilePathUpdate, BookRenameData } from './file-rename.repository';
import { FileRenameRepository } from './file-rename.repository';
import { FileLockService } from './file-lock.service';
const FILE_RENAME_EVENT = 'file.rename';
const FILE_RENAME_ROLLBACK_EVENT = 'file.rename_rollback';
const DEFAULT_RENAME_DEBOUNCE_MS = 3_000;
export const RENAME_RELEVANT_FIELDS = new Set(['title', 'authors', 'seriesName', 'seriesIndex', 'publishedYear'] as const);
@Injectable()
export class FileRenameService implements OnModuleDestroy {
private readonly logger = new Logger(FileRenameService.name);
private readonly debounceMs: number;
private readonly debounceMap = new Map<number, NodeJS.Timeout>();
private readonly scheduledRenameRuns = new Set<Promise<unknown>>();
constructor(
private readonly renameRepo: FileRenameRepository,
private readonly lockService: FileLockService,
private readonly appSettings: AppSettingsService,
private readonly notificationService: NotificationService,
private readonly config: ConfigService,
) {
this.debounceMs = resolvePositiveInteger(this.config.get('fileWrite.debounceMs'), DEFAULT_RENAME_DEBOUNCE_MS);
}
scheduleRename(bookId: number, userId: number): void {
const existing = this.debounceMap.get(bookId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
this.debounceMap.delete(bookId);
const run = this.performRename(bookId, userId)
.catch((err: Error) =>
this.logger.warn(
`[${FILE_RENAME_EVENT}] [fail] bookId=${bookId} userId=${userId} errorClass=${err.name} error="${sanitizeLogValue(err.message)}" - scheduled rename failed`,
),
)
.finally(() => {
this.scheduledRenameRuns.delete(run);
});
this.scheduledRenameRuns.add(run);
}, this.debounceMs);
this.debounceMap.set(bookId, timer);
}
onModuleDestroy(): void {
for (const timer of this.debounceMap.values()) clearTimeout(timer);
this.debounceMap.clear();
}
async drainScheduledRenamesForTests(): Promise<void> {
while (this.scheduledRenameRuns.size > 0) {
await Promise.allSettled([...this.scheduledRenameRuns]);
}
}
async performRename(bookId: number, userId: number): Promise<FileRenameResult> {
const startedAt = Date.now();
this.logger.log(`[${FILE_RENAME_EVENT}] [start] bookId=${bookId} userId=${userId} - file rename started`);
const data = await this.renameRepo.findBookRenameData(bookId);
if (!data) {
return this.logAndReturn(bookId, startedAt, { status: 'skipped', reason: 'book not found' });
}
if (!data.fileRenameEnabled) {
return this.logAndReturn(bookId, startedAt, { status: 'skipped', reason: 'disabled' });
}
const pattern =
data.fileNamingPattern ??
(data.organizationMode === 'book_per_folder'
? await this.appSettings.getUploadPatternBookPerFolder()
: await this.appSettings.getUploadPattern());
if (!pattern) {
return this.logAndReturn(bookId, startedAt, { status: 'skipped', reason: 'no pattern' });
}
const format = (data.file.format ?? extname(data.file.absolutePath).slice(1)).toLowerCase();
const originalStem = basename(data.file.absolutePath, extname(data.file.absolutePath));
const tokens = buildTokens(data.metadata, data.authors, originalStem, format);
const resolvedRelPath = resolveUploadPath(pattern, tokens, format);
if (!resolvedRelPath) {
return this.logAndReturn(bookId, startedAt, { status: 'skipped', reason: 'pattern resolved to empty' });
}
const currentAbsolutePath = data.file.absolutePath;
const newAbsolutePath = join(data.libraryFolderPath, resolvedRelPath);
const currentFolderPath = data.bookFolderPath;
const newFolderPath = dirname(newAbsolutePath);
const isBookPerFolder = data.organizationMode === 'book_per_folder';
if (newAbsolutePath === currentAbsolutePath) {
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.');
return { status: 'skipped', reason: 'collision', oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
}
const filesystemTargetPath = isBookPerFolder && newFolderPath !== currentFolderPath ? newFolderPath : newAbsolutePath;
if (await this.pathExists(filesystemTargetPath)) {
const reason =
isBookPerFolder && newFolderPath !== currentFolderPath ? '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}.`);
return { status: 'skipped', reason, oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
}
try {
if (isBookPerFolder && newFolderPath !== currentFolderPath) {
await this.renameBookWithFolder(bookId, data, currentFolderPath, newFolderPath, newAbsolutePath);
} else {
const nextBookFolderPath = isBookPerFolder ? currentFolderPath : newAbsolutePath;
await this.renameBookFileOnly(bookId, data, currentAbsolutePath, newAbsolutePath, nextBookFolderPath);
}
} catch (err) {
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.warn(
`[${FILE_RENAME_EVENT}] [fail] bookId=${bookId} userId=${userId} durationMs=${Date.now() - startedAt} errorClass=${errorClass} error="${errorMessage}" - file rename failed`,
);
await this.notifyFailure(userId, bookId, err instanceof Error ? err.message : String(err));
return { status: 'failed', reason: errorMessage, oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
}
this.logger.log(
`[${FILE_RENAME_EVENT}] [end] bookId=${bookId} userId=${userId} durationMs=${Date.now() - startedAt} status=success oldPath="${sanitizeLogValue(currentAbsolutePath)}" newPath="${sanitizeLogValue(newAbsolutePath)}" - file rename completed`,
);
await this.notifySuccess(userId, bookId, currentAbsolutePath, newAbsolutePath);
return { status: 'success', oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
}
private async renameBookFileOnly(
bookId: number,
data: BookRenameData,
oldPath: string,
newPath: 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 renameBookWithFolder(
bookId: number,
data: BookRenameData,
oldFolderPath: string,
newFolderPath: string,
newPrimaryAbsolutePath: string,
): Promise<void> {
const allFiles = await this.renameRepo.findAllBookFiles(bookId);
const movedPrimaryAbsolutePath = join(newFolderPath, relative(oldFolderPath, data.file.absolutePath));
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 oldUpdates = allFiles.map((file) => ({ id: file.id, absolutePath: file.absolutePath, relPath: file.relPath }) satisfies BookFilePathUpdate);
await this.renameRepo.applyFolderRename(bookId, newUpdates, newFolderPath);
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));
}
} catch (error) {
await this.rollbackFolderRename(bookId, oldUpdates, oldFolderPath, error);
if (folderRenamed) {
await this.rollbackFolderMove(bookId, newFolderPath, oldFolderPath, error);
}
throw error;
}
await this.tryRemoveEmptyDir(dirname(oldFolderPath));
}
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);
} catch (rollbackError) {
this.logRollbackFailure(bookId, originalError, rollbackError);
}
}
private async rollbackFolderMove(bookId: number, newFolderPath: string, oldFolderPath: string, originalError: unknown): Promise<void> {
try {
await fsRename(newFolderPath, oldFolderPath);
} catch (rollbackError) {
this.logRollbackFailure(bookId, originalError, rollbackError);
}
}
private logRollbackFailure(bookId: number, originalError: unknown, rollbackError: unknown): void {
const originalErrorClass = originalError instanceof Error ? originalError.name : 'Error';
const originalErrorMessage = sanitizeLogValue(originalError instanceof Error ? originalError.message : String(originalError));
const rollbackErrorClass = rollbackError instanceof Error ? rollbackError.name : 'Error';
const rollbackErrorMessage = sanitizeLogValue(rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
this.logger.warn(
`[${FILE_RENAME_ROLLBACK_EVENT}] [fail] bookId=${bookId} originalErrorClass=${originalErrorClass} originalError="${originalErrorMessage}" rollbackErrorClass=${rollbackErrorClass} rollbackError="${rollbackErrorMessage}" - file rename rollback failed`,
);
}
private async pathExists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}
private async tryRemoveEmptyDir(dirPath: string): Promise<void> {
try {
const entries = await readdir(dirPath);
if (entries.length === 0) {
await rmdir(dirPath);
}
} catch {
// Best effort.
}
}
private logAndReturn(bookId: number, startedAt: number, result: Omit<FileRenameResult, 'durationMs'>): FileRenameResult {
const durationMs = Date.now() - startedAt;
const full: FileRenameResult = { ...result, durationMs };
const reasonPart = full.reason ? ` reason="${sanitizeLogValue(full.reason)}"` : '';
this.logger.debug(
`[${FILE_RENAME_EVENT}] [end] bookId=${bookId} durationMs=${durationMs} status=${full.status}${reasonPart} - file rename completed`,
);
return full;
}
private async notifySuccess(userId: number, bookId: number, oldPath: string, newPath: string): Promise<void> {
await this.notificationService
.notify({
type: NotificationType.FileRenameCompleted,
title: 'File renamed',
message: `Renamed to: ${basename(newPath)}`,
scope: { kind: 'user', userId },
meta: { bookId, oldPath, newPath },
})
.catch(() => {});
}
private async notifyFailure(userId: number, bookId: number, reason: string): Promise<void> {
await this.notificationService
.notify({
type: NotificationType.FileRenameFailed,
title: 'File rename failed',
message: reason.slice(0, 200),
scope: { kind: 'user', userId },
meta: { bookId },
})
.catch(() => {});
}
}
function buildTokens(
metadata: {
title: string | null;
subtitle: string | null;
publisher: string | null;
language: string | null;
isbn13: string | null;
publishedYear: number | null;
seriesName: string | null;
seriesIndex: number | null;
},
authors: string[],
originalStem: string,
format: string,
): Record<string, string> {
const tokens: Record<string, string> = { originalFilename: originalStem, extension: format };
if (metadata.title) tokens['title'] = metadata.title;
if (metadata.subtitle) tokens['subtitle'] = metadata.subtitle;
if (metadata.publisher) tokens['publisher'] = metadata.publisher;
if (metadata.language) tokens['language'] = metadata.language;
if (metadata.isbn13) tokens['isbn'] = metadata.isbn13;
if (metadata.publishedYear) tokens['year'] = String(metadata.publishedYear);
if (metadata.seriesName) tokens['series'] = metadata.seriesName;
const seriesIndex = formatSeriesIndex(metadata.seriesIndex);
if (seriesIndex) tokens['seriesIndex'] = seriesIndex;
if (authors.length > 0) tokens['authors'] = authors.join(', ');
return tokens;
}
function 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;
}
function resolvePositiveInteger(value: unknown, fallback: number): number {
const numeric = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numeric) || numeric < 1) return fallback;
return Math.floor(numeric);
}
@@ -1,6 +1,9 @@
import { MODULE_METADATA } from '@nestjs/common/constants';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { FileLockService } from './file-lock.service';
import { FileRenameRepository } from './file-rename.repository';
import { FileRenameService } from './file-rename.service';
import { FileWriteModule } from './file-write.module';
import { FileWriteRepository } from './file-write.repository';
import { FileWriteService } from './file-write.service';
@@ -15,11 +18,15 @@ describe('FileWriteModule', () => {
it('registers expected providers, exports, and writer factory token', () => {
const providers = Reflect.getMetadata(MODULE_METADATA.PROVIDERS, FileWriteModule);
const exportsMeta = Reflect.getMetadata(MODULE_METADATA.EXPORTS, FileWriteModule);
const importsMeta = Reflect.getMetadata(MODULE_METADATA.IMPORTS, FileWriteModule);
expect(importsMeta).toEqual(expect.arrayContaining([AppSettingsModule]));
expect(providers).toEqual(
expect.arrayContaining([
FileWriteService,
FileWriteRepository,
FileRenameRepository,
FileRenameService,
FileLockService,
EpubFormatWriter,
PdfFormatWriter,
@@ -29,7 +36,7 @@ describe('FileWriteModule', () => {
]),
);
expect(exportsMeta).toEqual(expect.arrayContaining([FileWriteService, FileWriteRepository]));
expect(exportsMeta).toEqual(expect.arrayContaining([FileWriteService, FileWriteRepository, FileRenameService]));
const writerProvider = providers.find((p: { provide?: unknown }) => p?.provide === FORMAT_WRITERS) as {
useFactory: (...args: unknown[]) => unknown;
@@ -1,21 +1,26 @@
import { Module, forwardRef } from '@nestjs/common';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { NotificationModule } from '../notification/notification.module';
import { FileLockService } from './file-lock.service';
import { FileRenameRepository } from './file-rename.repository';
import { FileRenameService } from './file-rename.service';
import { FileWriteRepository } from './file-write.repository';
import { FileWriteService } from './file-write.service';
import { FormatWriterRegistry } from './format-writer.registry';
import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer';
import { CbzFormatWriter } from './formats/cbx/cbz-format-writer';
import { EpubFormatWriter } from './formats/epub/epub-format-writer';
import { PdfFormatWriter } from './formats/pdf/pdf-format-writer';
import { CbzFormatWriter } from './formats/cbx/cbz-format-writer';
import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer';
import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
@Module({
imports: [forwardRef(() => NotificationModule)],
imports: [forwardRef(() => NotificationModule), AppSettingsModule],
providers: [
FileWriteService,
FileWriteRepository,
FileRenameRepository,
FileRenameService,
FileLockService,
EpubFormatWriter,
PdfFormatWriter,
@@ -28,6 +33,6 @@ import { Cb7FormatWriter } from './formats/cbx/cb7-format-writer';
},
FormatWriterRegistry,
],
exports: [FileWriteService, FileWriteRepository],
exports: [FileWriteService, FileWriteRepository, FileRenameService],
})
export class FileWriteModule {}
@@ -154,4 +154,8 @@ export class CreateLibraryDto {
@Min(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MIN)
@Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX)
fileWriteCbxMaxFileSizeMb?: number;
@IsOptional()
@IsBoolean()
fileRenameEnabled?: boolean;
}
@@ -155,4 +155,8 @@ export class UpdateLibraryDto {
@Min(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MIN)
@Max(LIBRARY_FILE_WRITE_MAX_SIZE_MB_MAX)
fileWriteCbxMaxFileSizeMb?: number;
@IsOptional()
@IsBoolean()
fileRenameEnabled?: boolean;
}
@@ -98,12 +98,14 @@ describe('Library DTO validation', () => {
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, fileWriteEpubEnabled: false }))).toBe(false);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, fileWritePdfEnabled: false }))).toBe(false);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, fileWriteCbxEnabled: true }))).toBe(false);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, fileRenameEnabled: true }))).toBe(false);
});
it('CreateLibraryDto rejects non-boolean file write flags', async () => {
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, fileWriteEnabled: 'yes' }))).toBe(true);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, fileWriteWriteCover: 1 }))).toBe(true);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, fileWriteEpubEnabled: 'true' }))).toBe(true);
expect(await hasErrors(plainToInstance(CreateLibraryDto, { ...base, fileRenameEnabled: 'true' }))).toBe(true);
});
it('CreateLibraryDto validates fileWriteEpubMaxFileSizeMb bounds', async () => {
@@ -142,7 +144,7 @@ describe('Library DTO validation', () => {
expect(await hasErrors(plainToInstance(UpdateLibraryDto, { fileWriteCbxMaxFileSizeMb: 500 }))).toBe(false);
});
it('UpdateLibraryDto accepts all 8 file write fields simultaneously', async () => {
it('UpdateLibraryDto accepts all 9 file write fields simultaneously', async () => {
const dto = plainToInstance(UpdateLibraryDto, {
fileWriteEnabled: true,
fileWriteWriteCover: false,
@@ -152,6 +154,7 @@ describe('Library DTO validation', () => {
fileWritePdfMaxFileSizeMb: 50,
fileWriteCbxEnabled: true,
fileWriteCbxMaxFileSizeMb: 1000,
fileRenameEnabled: true,
});
expect(await hasErrors(dto)).toBe(false);
});
@@ -178,6 +178,7 @@ describe('LibraryService', () => {
fileWritePdfMaxFileSizeMb: 100,
fileWriteCbxEnabled: false,
fileWriteCbxMaxFileSizeMb: 500,
fileRenameEnabled: false,
}),
);
});
@@ -133,6 +133,7 @@ export class LibraryService {
fileWritePdfMaxFileSizeMb: dto.fileWritePdfMaxFileSizeMb ?? 100,
fileWriteCbxEnabled: dto.fileWriteCbxEnabled ?? false,
fileWriteCbxMaxFileSizeMb: dto.fileWriteCbxMaxFileSizeMb ?? 500,
fileRenameEnabled: dto.fileRenameEnabled ?? false,
});
const folders = await Promise.all(dto.folders.map((path) => this.libraryRepo.insertFolder({ libraryId: library.id, path })));