mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface Library {
|
||||
fileWritePdfMaxFileSizeMb: number;
|
||||
fileWriteCbxEnabled: boolean;
|
||||
fileWriteCbxMaxFileSizeMb: number;
|
||||
fileRenameEnabled: boolean;
|
||||
folders: LibraryFolder[];
|
||||
bookCount?: number;
|
||||
createdAt: string;
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 })));
|
||||
|
||||
Reference in New Issue
Block a user