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

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

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

Closes #121

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

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

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

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
Chris Mansell
2026-05-28 09:27:03 -06:00
committed by GitHub
parent 8ee6929f55
commit e659111e4e
33 changed files with 4595 additions and 70 deletions
@@ -0,0 +1,33 @@
import { api } from '@/lib/api'
import type { BulkRenamePreviewPage, BulkRenameStatus } from '@bookorbit/types'
function toQuery(params: Record<string, string | number | undefined>): string {
const entries = Object.entries(params).filter(([, v]) => v !== undefined)
if (entries.length === 0) return ''
return '?' + new URLSearchParams(entries.map(([k, v]) => [k, String(v)])).toString()
}
export async function fetchBulkRenamePreview(
libraryId: number,
page: number,
pageSize: number,
status?: BulkRenameStatus,
): Promise<BulkRenamePreviewPage> {
const query = toQuery({ page, pageSize, status })
const res = await api(`/api/v1/libraries/${libraryId}/bulk-rename/preview${query}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export async function fetchBulkRenameStatus(libraryId: number): Promise<{ running: boolean }> {
const res = await api(`/api/v1/libraries/${libraryId}/bulk-rename/status`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
export function executeBulkRename(libraryId: number, signal?: AbortSignal): Promise<Response> {
return api(`/api/v1/libraries/${libraryId}/bulk-rename/execute`, {
method: 'POST',
signal,
})
}
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { AlertTriangle } from 'lucide-vue-next'
defineProps<{
open: boolean
renameCount: number
}>()
const emit = defineEmits<{
confirm: []
cancel: []
}>()
function handleConfirm(): void {
emit('confirm')
}
function handleCancel(): void {
emit('cancel')
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="handleCancel">
<div class="bg-card border rounded-lg shadow-lg w-full max-w-md mx-4 p-6">
<div class="flex items-start gap-3 mb-4">
<div class="flex-shrink-0 w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
<AlertTriangle class="w-5 h-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">Confirm Bulk Rename</h3>
<p class="mt-1 text-sm text-muted-foreground">
This will rename <strong>{{ renameCount }}</strong> book{{ renameCount === 1 ? '' : 's' }} on disk. Files with collisions or errors will
be skipped. This action cannot be undone.
</p>
</div>
</div>
<div class="flex justify-end gap-2">
<button class="px-4 py-2 text-sm font-medium rounded-md border hover:bg-muted transition-colors" @click="handleCancel">Cancel</button>
<button
class="px-4 py-2 text-sm font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
@click="handleConfirm"
>
Rename {{ renameCount }} book{{ renameCount === 1 ? '' : 's' }}
</button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { BulkRenameStatus } from '@bookorbit/types'
const props = defineProps<{ status: BulkRenameStatus }>()
const config = computed(() => {
switch (props.status) {
case 'will_rename':
return { label: 'Will Rename', classes: 'border-primary/40 bg-primary/15 text-primary' }
case 'unchanged':
return { label: 'Unchanged', classes: 'border-border/70 bg-muted text-muted-foreground' }
case 'collision':
return { label: 'Collision', classes: 'border-amber-500/40 bg-amber-500/15 text-amber-500' }
case 'no_pattern':
return { label: 'No Pattern', classes: 'border-border/70 bg-muted text-muted-foreground' }
case 'error':
return { label: 'Error', classes: 'border-destructive/40 bg-destructive/15 text-destructive' }
default:
return { label: props.status, classes: 'border-border/70 bg-muted text-muted-foreground' }
}
})
</script>
<template>
<span
class="inline-flex h-6 shrink-0 items-center whitespace-nowrap rounded-full border px-2.5 text-xs font-medium leading-none"
:class="config.classes"
>
{{ config.label }}
</span>
</template>
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import BulkRenameConfirmDialog from '../BulkRenameConfirmDialog.vue'
describe('BulkRenameConfirmDialog', () => {
function mountDialog(props: { open: boolean; renameCount: number }) {
return mount(BulkRenameConfirmDialog, {
props,
global: {
stubs: { Teleport: true },
},
})
}
it('renders nothing when open is false', () => {
const wrapper = mountDialog({ open: false, renameCount: 5 })
expect(wrapper.find('.fixed').exists()).toBe(false)
})
it('renders dialog when open is true', () => {
const wrapper = mountDialog({ open: true, renameCount: 5 })
expect(wrapper.find('.fixed').exists()).toBe(true)
expect(wrapper.text()).toContain('Confirm Bulk Rename')
})
it('shows the rename count in the message', () => {
const wrapper = mountDialog({ open: true, renameCount: 42 })
expect(wrapper.text()).toContain('42')
expect(wrapper.text()).toContain('books')
})
it('shows singular for 1 book', () => {
const wrapper = mountDialog({ open: true, renameCount: 1 })
expect(wrapper.text()).toContain('1 book')
expect(wrapper.text()).not.toContain('1 books')
})
it('emits confirm when confirm button is clicked', async () => {
const wrapper = mountDialog({ open: true, renameCount: 5 })
const buttons = wrapper.findAll('button')
const confirmButton = buttons.find((b) => b.text().includes('Rename'))!
await confirmButton.trigger('click')
expect(wrapper.emitted('confirm')).toHaveLength(1)
})
it('emits cancel when cancel button is clicked', async () => {
const wrapper = mountDialog({ open: true, renameCount: 5 })
const cancelButton = wrapper.findAll('button').find((b) => b.text() === 'Cancel')!
await cancelButton.trigger('click')
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
})
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import BulkRenameStatusBadge from '../BulkRenameStatusBadge.vue'
import type { BulkRenameStatus } from '@bookorbit/types'
describe('BulkRenameStatusBadge', () => {
const cases: { status: BulkRenameStatus; expectedLabel: string; expectedClass: string }[] = [
{ status: 'will_rename', expectedLabel: 'Will Rename', expectedClass: 'bg-primary/15' },
{ status: 'unchanged', expectedLabel: 'Unchanged', expectedClass: 'bg-muted' },
{ status: 'collision', expectedLabel: 'Collision', expectedClass: 'bg-amber-500/15' },
{ status: 'no_pattern', expectedLabel: 'No Pattern', expectedClass: 'bg-muted' },
{ status: 'error', expectedLabel: 'Error', expectedClass: 'bg-destructive/15' },
]
for (const { status, expectedLabel, expectedClass } of cases) {
it(`renders "${expectedLabel}" for status "${status}"`, () => {
const wrapper = mount(BulkRenameStatusBadge, { props: { status } })
expect(wrapper.text()).toBe(expectedLabel)
expect(wrapper.find('span').classes()).toContain(expectedClass)
})
}
})
@@ -0,0 +1,549 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useElementSize } from '@vueuse/core'
import { ChevronLeft, ChevronRight, ExternalLink, Loader2, Play, RefreshCw, SlidersHorizontal, X } from 'lucide-vue-next'
import type { BulkRenameStatus, Library } from '@bookorbit/types'
import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { storage } from '@/services/storage'
import { useBulkRename } from '../../composables/useBulkRename'
import BulkRenameStatusBadge from '../components/BulkRenameStatusBadge.vue'
import BulkRenameConfirmDialog from '../components/BulkRenameConfirmDialog.vue'
const { libraries, fetchLibraries } = useLibraries()
const bulk = useBulkRename()
const showConfirmDialog = ref(false)
const librarySelectRef = ref<HTMLSelectElement | null>(null)
const tableViewportRef = ref<HTMLElement | null>(null)
const { width: tableViewportWidth } = useElementSize(tableViewportRef)
const showFullPaths = ref<boolean>(storage.get<boolean>('tools.bulkRename.showFullPaths', false))
type BulkRenameColumnVisibility = {
title: boolean
currentPath: boolean
newPath: boolean
}
const defaultColumnVisibility: BulkRenameColumnVisibility = {
title: true,
currentPath: true,
newPath: true,
}
const columnVisibility = ref<BulkRenameColumnVisibility>(
storage.get<BulkRenameColumnVisibility>('tools.bulkRename.columnVisibility', defaultColumnVisibility),
)
type ColumnOption = { key: keyof BulkRenameColumnVisibility; label: string }
const columnOptions: ColumnOption[] = [
{ key: 'title', label: 'Title' },
{ key: 'currentPath', label: 'Current Path' },
{ key: 'newPath', label: 'New Path' },
]
const eligibleLibraries = computed(() => libraries.value.filter((lib: Library) => lib.fileRenameEnabled))
const selectedLibrary = computed(() => eligibleLibraries.value.find((lib: Library) => lib.id === bulk.selectedLibraryId.value) ?? null)
const selectedLibraryRoots = computed(() => selectedLibrary.value?.folders.map((folder) => folder.path) ?? [])
const statusOptions: { value: BulkRenameStatus | undefined; label: string }[] = [
{ value: undefined, label: 'All' },
{ value: 'will_rename', label: 'Will Rename' },
{ value: 'unchanged', label: 'Unchanged' },
{ value: 'collision', label: 'Collision' },
{ value: 'no_pattern', label: 'No Pattern' },
{ value: 'error', label: 'Error' },
]
const hasPreview = computed(() => bulk.previewItems.value.length > 0 || bulk.previewTotal.value > 0)
const showTitleColumn = computed(() => columnVisibility.value.title)
const showCurrentPathColumn = computed(() => columnVisibility.value.currentPath)
const showNewPathColumn = computed(() => columnVisibility.value.newPath)
const tablePathMaxLength = computed(() => {
const visiblePathColumns = Number(showCurrentPathColumn.value) + Number(showNewPathColumn.value)
if (visiblePathColumns <= 0) return 72
// Approximate available text width in each path column.
const tableWidth = tableViewportWidth.value
const indexColumnPx = 72
const statusColumnPx = 152
const titleColumnPx = showTitleColumn.value ? 320 : 0
const framePaddingPx = 16
const pathCellPaddingPx = 32
const charWidthPx = 7.1
const availableForPaths = Math.max(tableWidth - indexColumnPx - statusColumnPx - titleColumnPx - framePaddingPx, 0)
const perPathColumnPx = availableForPaths / visiblePathColumns
const textPx = Math.max(perPathColumnPx - pathCellPaddingPx, 0)
const maxChars = Math.floor(textPx / charWidthPx)
return Math.min(220, Math.max(24, maxChars))
})
onMounted(async () => {
await fetchLibraries()
})
function handleLibraryChange(event: Event): void {
const target = event.target as HTMLSelectElement
const id = parseInt(target.value, 10)
if (!isNaN(id)) {
bulk.selectLibrary(id)
bulk.loadPreview()
}
}
function handleSetStatusFilter(value: BulkRenameStatus | undefined): void {
bulk.setStatusFilter(value)
}
function focusLibrarySelect(): void {
librarySelectRef.value?.focus()
}
function handleColumnVisibilityChange(key: keyof BulkRenameColumnVisibility, checked: boolean): void {
columnVisibility.value = {
...columnVisibility.value,
[key]: checked,
}
}
function handleRefreshPreview(): void {
bulk.loadPreview()
}
function handlePrevPage(): void {
if (bulk.page.value > 1) {
bulk.setPage(bulk.page.value - 1)
}
}
function handleNextPage(): void {
if (bulk.page.value < bulk.totalPages.value) {
bulk.setPage(bulk.page.value + 1)
}
}
function handleOpenConfirm(): void {
showConfirmDialog.value = true
}
function handleCloseConfirm(): void {
showConfirmDialog.value = false
}
async function handleConfirmExecute(): Promise<void> {
showConfirmDialog.value = false
await bulk.execute()
if (!bulk.executionError.value) {
bulk.loadPreview()
}
}
function handleCancelExecution(): void {
bulk.cancelExecution()
}
function getStatusCount(status: BulkRenameStatus | undefined): number {
if (status === undefined) {
return Object.values(bulk.totalByStatus.value).reduce((total, count) => total + count, 0)
}
return bulk.totalByStatus.value[status] ?? 0
}
function isStatusActive(status: BulkRenameStatus | undefined): boolean {
return bulk.statusFilter.value === status
}
function normalizePath(value: string): string {
const normalized = value.replace(/\\/g, '/').replace(/\/+/g, '/')
if (normalized.length > 1 && normalized.endsWith('/')) {
return normalized.slice(0, -1)
}
return normalized
}
function stripLibraryRoot(path: string): string {
const normalizedPath = normalizePath(path)
const matchedRoot = selectedLibraryRoots.value
.map((root) => normalizePath(root))
.sort((a, b) => b.length - a.length)
.find((root) => normalizedPath === root || normalizedPath.startsWith(`${root}/`))
if (!matchedRoot) return path
if (normalizedPath === matchedRoot) return '.'
return normalizedPath.slice(matchedRoot.length + 1)
}
function truncateMiddle(value: string, maxLength: number): string {
if (value.length <= maxLength) return value
const remaining = maxLength - 3
const head = Math.ceil(remaining / 2)
const tail = Math.floor(remaining / 2)
return `${value.slice(0, head)}...${value.slice(value.length - tail)}`
}
function truncatePathKeepingExtension(value: string, maxLength: number): string {
if (value.length <= maxLength) return value
if (maxLength <= 12) return value.slice(0, Math.max(maxLength - 3, 1)) + '...'
const normalized = normalizePath(value)
const basename = normalized.slice(normalized.lastIndexOf('/') + 1)
const extensionIndex = basename.lastIndexOf('.')
const extension = extensionIndex > 0 ? basename.slice(extensionIndex) : ''
const remaining = maxLength - 3
const minimumTail = extension ? Math.min(Math.max(extension.length + 10, 18), 40) : 18
const tail = Math.min(Math.max(Math.floor(remaining * 0.55), minimumTail), remaining - 8)
const head = remaining - tail
return `${normalized.slice(0, head)}...${normalized.slice(normalized.length - tail)}`
}
function resolveVisiblePath(path: string): string {
return showFullPaths.value ? path : stripLibraryRoot(path)
}
function formatCardPath(path: string | null | undefined): string {
if (!path) return '-'
return truncatePathKeepingExtension(resolveVisiblePath(path), 72)
}
function formatTablePath(path: string | null | undefined): string {
if (!path) return '-'
return truncatePathKeepingExtension(resolveVisiblePath(path), tablePathMaxLength.value)
}
function formatTitle(title: string): string {
return truncateMiddle(title, 52)
}
watch(
() => bulk.selectedLibraryId.value,
(newId) => {
if (newId === null) {
bulk.previewItems.value = []
bulk.previewTotal.value = 0
}
},
)
watch(showFullPaths, (value) => {
storage.set('tools.bulkRename.showFullPaths', value)
})
watch(
columnVisibility,
(value) => {
storage.set('tools.bulkRename.columnVisibility', value)
},
{ deep: true },
)
</script>
<template>
<div class="flex h-full min-h-0 w-full flex-col gap-4">
<section class="rounded-lg border border-border/70 bg-card/50 p-4">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 lg:grid lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end lg:gap-3">
<div class="min-w-0 flex flex-col gap-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<label class="text-sm font-medium text-foreground sm:shrink-0">Library</label>
<select
ref="librarySelectRef"
class="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-primary sm:flex-1"
:value="bulk.selectedLibraryId.value ?? ''"
@change="handleLibraryChange"
>
<option value="" disabled>Select a library...</option>
<option v-for="lib in eligibleLibraries" :key="lib.id" :value="lib.id">
{{ lib.name }}
</option>
</select>
</div>
<p v-if="eligibleLibraries.length === 0" class="text-sm text-muted-foreground">
No libraries have file rename enabled. Enable it in Library Settings.
</p>
</div>
<div v-if="bulk.selectedLibraryId.value !== null" class="flex flex-wrap items-center gap-2 lg:justify-self-end">
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm font-medium hover:bg-muted/60 disabled:opacity-50"
:disabled="bulk.loading.value || bulk.executing.value"
@click="handleRefreshPreview"
>
<RefreshCw class="h-4 w-4" :class="{ 'animate-spin': bulk.loading.value }" />
Refresh
</button>
<button
v-if="!bulk.executing.value"
class="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
:disabled="bulk.totalByStatus.value.will_rename === 0 || bulk.loading.value"
@click="handleOpenConfirm"
>
<Play class="h-4 w-4" />
Apply Rename
</button>
<button
v-else
class="inline-flex items-center gap-1.5 rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90"
@click="handleCancelExecution"
>
<X class="h-4 w-4" />
Cancel
</button>
</div>
</div>
<div v-if="bulk.selectedLibraryId.value !== null" class="rounded-md bg-muted/20 px-3 py-2.5">
<div class="flex flex-col gap-2 text-sm sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span class="font-medium text-foreground">Summary</span>
<span class="rounded-full bg-primary/15 px-2.5 py-0.5 text-primary">{{ bulk.totalByStatus.value.will_rename }} to rename</span>
<span class="rounded-full bg-muted px-2.5 py-0.5 text-muted-foreground">{{ bulk.totalByStatus.value.unchanged }} unchanged</span>
<span v-if="bulk.totalByStatus.value.collision > 0" class="rounded-full bg-amber-500/15 px-2.5 py-0.5 text-amber-500">
{{ bulk.totalByStatus.value.collision }} collision{{ bulk.totalByStatus.value.collision === 1 ? '' : 's' }}
</span>
<span v-if="bulk.totalByStatus.value.error > 0" class="rounded-full bg-destructive/15 px-2.5 py-0.5 text-destructive">
{{ bulk.totalByStatus.value.error }} error{{ bulk.totalByStatus.value.error === 1 ? '' : 's' }}
</span>
</div>
<div class="flex items-center gap-2">
<label class="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="showFullPaths" type="checkbox" class="h-4 w-4 rounded border-border bg-background text-primary" />
Show full paths
</label>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
class="inline-flex items-center gap-1.5 rounded-md border border-border/70 bg-background/70 px-2 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
>
<SlidersHorizontal class="h-3.5 w-3.5" />
Columns
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<DropdownMenuCheckboxItem
v-for="opt in columnOptions"
:key="opt.key"
:model-value="columnVisibility[opt.key]"
@update:model-value="(checked) => handleColumnVisibilityChange(opt.key, checked === true)"
>
{{ opt.label }}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2">
<button
v-for="opt in statusOptions"
:key="opt.label"
class="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs font-medium transition-colors"
:class="
isStatusActive(opt.value)
? 'border-primary bg-primary/15 text-primary'
: 'border-border/70 bg-background/60 text-muted-foreground hover:bg-muted/50 hover:text-foreground'
"
@click="handleSetStatusFilter(opt.value)"
>
<span>{{ opt.label }}</span>
<span class="rounded bg-muted/70 px-1.5 py-0.5 text-[11px] leading-none text-foreground">
{{ getStatusCount(opt.value) }}
</span>
</button>
</div>
</div>
</div>
</section>
<div
v-if="bulk.selectedLibraryId.value === null"
class="flex flex-1 flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-border/60 bg-card/20 p-10 text-center"
>
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-muted/70">
<SlidersHorizontal class="h-6 w-6 text-muted-foreground" />
</div>
<div class="space-y-1">
<p class="text-base font-semibold text-foreground">Pick a Library to Start</p>
<p class="max-w-sm text-sm text-muted-foreground">
Select a library above to preview rename changes, filter by status, and apply the bulk rename run.
</p>
</div>
<button
class="inline-flex h-9 items-center rounded-md border border-border bg-background px-4 text-sm font-medium text-foreground transition-colors hover:bg-muted/60"
@click="focusLibrarySelect"
>
Choose Library
</button>
</div>
<template v-else>
<div v-if="bulk.executing.value" class="flex items-center gap-2 rounded-lg border border-primary/40 bg-primary/10 px-4 py-3 text-primary">
<Loader2 class="h-4 w-4 animate-spin" />
<span class="text-sm">Renaming files - you can cancel at any time.</span>
</div>
<div
v-if="bulk.executionStats.value && !bulk.executing.value"
class="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-4 py-3 text-emerald-600 dark:text-emerald-400"
>
<p class="text-sm font-medium">Bulk rename completed</p>
<p class="mt-1 text-sm">
{{ bulk.executionStats.value.succeeded }} renamed, {{ bulk.executionStats.value.failed }} failed,
{{ bulk.executionStats.value.skipped }} skipped ({{ bulk.executionStats.value.processed }} total)
</p>
</div>
<div
v-if="bulk.executionError.value && !bulk.executing.value"
class="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-3 text-destructive"
>
<p class="text-sm font-medium">Bulk rename failed</p>
<p class="mt-1 text-sm">{{ bulk.executionError.value }}</p>
</div>
<div
v-if="bulk.previewError.value && !hasPreview"
class="rounded-lg border border-destructive/40 bg-destructive/10 px-4 py-6 text-center text-destructive"
>
<p class="text-sm font-medium">Failed to load preview</p>
<p class="mt-1 text-sm">{{ bulk.previewError.value }}</p>
</div>
<div
v-else-if="bulk.loading.value && !hasPreview"
class="flex flex-1 items-center justify-center rounded-lg border border-border/60 bg-card/20"
>
<Loader2 class="h-6 w-6 animate-spin text-muted-foreground" />
</div>
<div v-else-if="hasPreview" class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border border-border/70 bg-card/40">
<div ref="tableViewportRef" class="min-h-0 flex-1 overflow-auto">
<div class="divide-y divide-border/60 md:hidden">
<article v-for="(item, idx) in bulk.previewItems.value" :key="item.bookId" class="space-y-2 px-3 py-3">
<div class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">#{{ (bulk.page.value - 1) * bulk.pageSize.value + idx + 1 }}</p>
<BulkRenameStatusBadge :status="item.status" class="shrink-0" />
</div>
<div v-if="showTitleColumn" class="flex min-w-0 items-center gap-2">
<RouterLink
:to="{ name: 'book-detail', params: { bookId: item.bookId } }"
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
aria-label="Open book details"
>
<ExternalLink class="h-3.5 w-3.5" />
</RouterLink>
<p class="min-w-0 truncate text-sm font-medium text-foreground" :title="item.title">
{{ formatTitle(item.title) }}
</p>
</div>
<div v-if="showCurrentPathColumn" class="space-y-0.5">
<p class="text-[11px] uppercase tracking-wide text-muted-foreground">Current Path</p>
<p class="truncate font-mono text-xs text-muted-foreground" :title="item.currentPath">{{ formatCardPath(item.currentPath) }}</p>
</div>
<div v-if="showNewPathColumn" class="space-y-0.5">
<p class="text-[11px] uppercase tracking-wide text-muted-foreground">New Path</p>
<p class="truncate font-mono text-xs text-muted-foreground" :title="item.newPath ?? '-'">{{ formatCardPath(item.newPath) }}</p>
</div>
</article>
</div>
<table class="hidden w-full table-fixed text-sm md:table">
<colgroup>
<col class="w-10" />
<col v-if="showTitleColumn" class="w-72" />
<col v-if="showCurrentPathColumn" />
<col v-if="showNewPathColumn" />
<col class="w-30" />
</colgroup>
<thead class="sticky top-0 z-10 bg-muted/70 backdrop-blur-sm">
<tr>
<th class="px-4 py-2.5 text-left font-medium text-muted-foreground">#</th>
<th v-if="showTitleColumn" class="w-56 px-4 py-2.5 text-left font-medium text-muted-foreground">Title</th>
<th v-if="showCurrentPathColumn" class="px-4 py-2.5 text-left font-medium text-muted-foreground">Current Path</th>
<th v-if="showNewPathColumn" class="px-4 py-2.5 text-left font-medium text-muted-foreground">New Path</th>
<th class="w-30 px-4 py-2.5 text-left font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-border/60">
<tr v-for="(item, idx) in bulk.previewItems.value" :key="item.bookId" class="hover:bg-muted/25">
<td class="px-4 py-2.5 align-top text-muted-foreground">{{ (bulk.page.value - 1) * bulk.pageSize.value + idx + 1 }}</td>
<td v-if="showTitleColumn" class="w-56 px-4 py-2.5 align-top font-medium text-foreground" :title="item.title">
<div class="flex min-w-0 items-center gap-2">
<RouterLink
:to="{ name: 'book-detail', params: { bookId: item.bookId } }"
class="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
aria-label="Open book details"
>
<ExternalLink class="h-3.5 w-3.5" />
</RouterLink>
<span class="min-w-0 truncate">{{ formatTitle(item.title) }}</span>
</div>
</td>
<td
v-if="showCurrentPathColumn"
class="max-w-0 px-4 py-2.5 align-top font-mono text-xs text-muted-foreground"
:title="item.currentPath"
>
<div class="overflow-hidden whitespace-nowrap">
{{ formatTablePath(item.currentPath) }}
</div>
</td>
<td
v-if="showNewPathColumn"
class="max-w-0 px-4 py-2.5 align-top font-mono text-xs text-muted-foreground"
:title="item.newPath ?? '-'"
>
<div class="overflow-hidden whitespace-nowrap">
{{ formatTablePath(item.newPath) }}
</div>
</td>
<td class="w-30 px-4 py-2.5 align-top whitespace-nowrap">
<BulkRenameStatusBadge :status="item.status" />
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-muted/20 px-4 py-3">
<p class="text-xs text-muted-foreground sm:text-sm">
Showing {{ (bulk.page.value - 1) * bulk.pageSize.value + 1 }}-{{
Math.min(bulk.page.value * bulk.pageSize.value, bulk.previewTotal.value)
}}
of {{ bulk.previewTotal.value }}
</p>
<div class="flex items-center gap-1">
<button
class="rounded-md p-1.5 hover:bg-muted transition-colors disabled:opacity-30"
:disabled="bulk.page.value <= 1"
@click="handlePrevPage"
>
<ChevronLeft class="h-4 w-4" />
</button>
<span class="px-2 text-sm text-muted-foreground">{{ bulk.page.value }} / {{ bulk.totalPages.value }}</span>
<button
class="rounded-md p-1.5 hover:bg-muted transition-colors disabled:opacity-30"
:disabled="bulk.page.value >= bulk.totalPages.value"
@click="handleNextPage"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
</div>
</div>
<div
v-else
class="flex flex-1 items-center justify-center rounded-lg border border-dashed border-border/60 bg-card/20 p-10 text-muted-foreground"
>
<p>No books found matching the current filter.</p>
</div>
</template>
<BulkRenameConfirmDialog
:open="showConfirmDialog"
:rename-count="bulk.totalByStatus.value.will_rename"
@confirm="handleConfirmExecute"
@cancel="handleCloseConfirm"
/>
</div>
</template>
@@ -17,6 +17,7 @@ const sections = computed<ToolSection[]>(() => {
if (hasPermission('manage_libraries')) {
result.push({ label: 'Entity Manager', routeName: 'tools-entity-manager' })
result.push({ label: 'Bulk Rename', routeName: 'tools-bulk-rename' })
}
return result
@@ -0,0 +1,308 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { nextTick } from 'vue'
vi.mock('@/lib/api', () => ({
api: vi.fn<() => Promise<Response>>(),
}))
vi.mock('../../api/bulk-rename', () => ({
fetchBulkRenamePreview: vi.fn<() => Promise<BulkRenamePreviewPage>>(),
fetchBulkRenameStatus: vi.fn<() => Promise<{ running: boolean }>>(),
executeBulkRename: vi.fn<() => Promise<Response>>(),
}))
import { useBulkRename } from '../useBulkRename'
import * as bulkRenameApi from '../../api/bulk-rename'
import type { BulkRenamePreviewPage } from '@bookorbit/types'
const mockFetchPreview = vi.mocked(bulkRenameApi.fetchBulkRenamePreview)
const mockExecute = vi.mocked(bulkRenameApi.executeBulkRename)
function makePreviewPage(overrides: Partial<BulkRenamePreviewPage> = {}): BulkRenamePreviewPage {
return {
items: overrides.items ?? [
{
bookId: 1,
title: 'Test Book',
currentPath: '/lib/old.epub',
newPath: '/lib/new.epub',
status: 'will_rename',
},
],
total: overrides.total ?? 1,
totalByStatus: overrides.totalByStatus ?? {
will_rename: 1,
unchanged: 0,
collision: 0,
no_pattern: 0,
error: 0,
},
}
}
describe('useBulkRename', () => {
beforeEach(() => {
vi.resetAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('initial state', () => {
it('has no library selected', () => {
const bulk = useBulkRename()
expect(bulk.selectedLibraryId.value).toBeNull()
})
it('has empty preview data', () => {
const bulk = useBulkRename()
expect(bulk.previewItems.value).toEqual([])
expect(bulk.previewTotal.value).toBe(0)
})
it('is not loading or executing', () => {
const bulk = useBulkRename()
expect(bulk.loading.value).toBe(false)
expect(bulk.executing.value).toBe(false)
})
it('has no errors', () => {
const bulk = useBulkRename()
expect(bulk.previewError.value).toBeNull()
expect(bulk.executionError.value).toBeNull()
})
})
describe('selectLibrary', () => {
it('sets the selected library id', () => {
const bulk = useBulkRename()
bulk.selectLibrary(5)
expect(bulk.selectedLibraryId.value).toBe(5)
})
it('resets page and status filter', () => {
const bulk = useBulkRename()
bulk.selectLibrary(1)
expect(bulk.page.value).toBe(1)
expect(bulk.statusFilter.value).toBeUndefined()
})
it('clears previous preview data', () => {
const bulk = useBulkRename()
bulk.previewItems.value = [{ bookId: 1, title: 'Old', currentPath: '/a', newPath: '/b', status: 'will_rename' }]
bulk.previewTotal.value = 1
bulk.selectLibrary(2)
expect(bulk.previewItems.value).toEqual([])
expect(bulk.previewTotal.value).toBe(0)
})
it('clears execution state', () => {
const bulk = useBulkRename()
bulk.executionStats.value = { processed: 1, succeeded: 1, failed: 0, skipped: 0 }
bulk.executionError.value = 'old error'
bulk.selectLibrary(3)
expect(bulk.executionStats.value).toBeNull()
expect(bulk.executionError.value).toBeNull()
})
})
describe('loadPreview', () => {
it('fetches preview from API', async () => {
const page = makePreviewPage()
mockFetchPreview.mockResolvedValue(page)
const bulk = useBulkRename()
bulk.selectLibrary(1)
await bulk.loadPreview()
expect(mockFetchPreview).toHaveBeenCalledWith(1, 1, 50, undefined)
expect(bulk.previewItems.value).toEqual(page.items)
expect(bulk.previewTotal.value).toBe(1)
expect(bulk.totalByStatus.value.will_rename).toBe(1)
})
it('does nothing when no library is selected', async () => {
const bulk = useBulkRename()
await bulk.loadPreview()
expect(mockFetchPreview).not.toHaveBeenCalled()
})
it('sets loading state during fetch', async () => {
let resolve: ((value: BulkRenamePreviewPage) => void) | undefined
mockFetchPreview.mockImplementation(() => new Promise((r) => (resolve = r)))
const bulk = useBulkRename()
bulk.selectLibrary(1)
const promise = bulk.loadPreview()
expect(bulk.loading.value).toBe(true)
resolve!(makePreviewPage())
await promise
expect(bulk.loading.value).toBe(false)
})
it('sets error on fetch failure', async () => {
mockFetchPreview.mockRejectedValue(new Error('network error'))
const bulk = useBulkRename()
bulk.selectLibrary(1)
await bulk.loadPreview()
expect(bulk.previewError.value).toBe('network error')
})
it('passes status filter to API', async () => {
mockFetchPreview.mockResolvedValue(makePreviewPage())
const bulk = useBulkRename()
bulk.selectLibrary(1)
bulk.setStatusFilter('collision')
await nextTick()
await nextTick()
expect(mockFetchPreview).toHaveBeenCalledWith(1, 1, 50, 'collision')
})
})
describe('setPage', () => {
it('updates the page number', () => {
const bulk = useBulkRename()
bulk.setPage(3)
expect(bulk.page.value).toBe(3)
})
})
describe('setStatusFilter', () => {
it('updates the status filter and resets page', () => {
const bulk = useBulkRename()
bulk.setPage(3)
bulk.setStatusFilter('will_rename')
expect(bulk.statusFilter.value).toBe('will_rename')
expect(bulk.page.value).toBe(1)
})
it('can be cleared to undefined', () => {
const bulk = useBulkRename()
bulk.setStatusFilter('collision')
bulk.setStatusFilter(undefined)
expect(bulk.statusFilter.value).toBeUndefined()
})
})
describe('totalPages', () => {
it('computes pages from total and pageSize', () => {
const bulk = useBulkRename()
bulk.previewTotal.value = 120
expect(bulk.totalPages.value).toBe(3)
})
it('returns 1 when total is 0', () => {
const bulk = useBulkRename()
expect(bulk.totalPages.value).toBe(1)
})
})
describe('execute', () => {
function makeSSEResponse(events: string[]): Response {
const encoder = new TextEncoder()
const chunks = events.map((e) => encoder.encode(`data: ${e}\n\n`))
let index = 0
const readable = new ReadableStream<Uint8Array>({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(chunks[index++])
} else {
controller.close()
}
},
})
return { ok: true, body: readable } as unknown as Response
}
it('streams SSE events and extracts final stats', async () => {
const doneEvent = JSON.stringify({ done: true, processed: 5, succeeded: 3, failed: 1, skipped: 1 })
mockExecute.mockResolvedValue(makeSSEResponse([JSON.stringify({ bookId: 1, status: 'success' }), doneEvent]))
const bulk = useBulkRename()
bulk.selectLibrary(1)
await bulk.execute()
expect(bulk.executionStats.value).toEqual({ processed: 5, succeeded: 3, failed: 1, skipped: 1 })
expect(bulk.executing.value).toBe(false)
})
it('does nothing when no library is selected', async () => {
const bulk = useBulkRename()
await bulk.execute()
expect(mockExecute).not.toHaveBeenCalled()
})
it('sets executing state during execution', async () => {
let resolve: ((value: Response) => void) | undefined
mockExecute.mockImplementation(() => new Promise((r) => (resolve = r)))
const bulk = useBulkRename()
bulk.selectLibrary(1)
const promise = bulk.execute()
expect(bulk.executing.value).toBe(true)
const doneEvent = JSON.stringify({ done: true, processed: 0, succeeded: 0, failed: 0, skipped: 0 })
const encoder = new TextEncoder()
const readable = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(`data: ${doneEvent}\n\n`))
controller.close()
},
})
resolve!({ ok: true, body: readable } as unknown as Response)
await promise
expect(bulk.executing.value).toBe(false)
})
it('sets error on HTTP failure', async () => {
mockExecute.mockResolvedValue({ ok: false, status: 500 } as Response)
const bulk = useBulkRename()
bulk.selectLibrary(1)
await bulk.execute()
expect(bulk.executionError.value).toBe('HTTP 500')
})
it('sets error on missing body', async () => {
mockExecute.mockResolvedValue({ ok: true, body: null } as unknown as Response)
const bulk = useBulkRename()
bulk.selectLibrary(1)
await bulk.execute()
expect(bulk.executionError.value).toBe('No response body')
})
})
describe('cancelExecution', () => {
it('aborts the execution without setting error', async () => {
const abortError = new DOMException('signal is aborted', 'AbortError')
mockExecute.mockRejectedValue(abortError)
const bulk = useBulkRename()
bulk.selectLibrary(1)
const executePromise = bulk.execute()
bulk.cancelExecution()
await executePromise
expect(bulk.executionError.value).toBeNull()
})
})
})
@@ -0,0 +1,169 @@
import { computed, onUnmounted, ref, watch } from 'vue'
import type { BulkRenamePreviewItem, BulkRenamePreviewPage, BulkRenameProgressEvent, BulkRenameStatus } from '@bookorbit/types'
import * as bulkRenameApi from '../api/bulk-rename'
export interface BulkRenameStats {
processed: number
succeeded: number
failed: number
skipped: number
}
export function useBulkRename() {
const selectedLibraryId = ref<number | null>(null)
const page = ref(1)
const pageSize = ref(50)
const statusFilter = ref<BulkRenameStatus | undefined>()
const previewItems = ref<BulkRenamePreviewItem[]>([])
const previewTotal = ref(0)
const totalByStatus = ref<Record<BulkRenameStatus, number>>({
will_rename: 0,
unchanged: 0,
collision: 0,
no_pattern: 0,
error: 0,
})
const totalPages = computed(() => Math.ceil(previewTotal.value / pageSize.value) || 1)
const loading = ref(false)
const previewError = ref<string | null>(null)
const executing = ref(false)
const executionStats = ref<BulkRenameStats | null>(null)
const executionError = ref<string | null>(null)
let abortController: AbortController | null = null
onUnmounted(() => {
abortController?.abort()
})
async function loadPreview(): Promise<void> {
if (selectedLibraryId.value === null) return
loading.value = true
previewError.value = null
try {
const result: BulkRenamePreviewPage = await bulkRenameApi.fetchBulkRenamePreview(
selectedLibraryId.value,
page.value,
pageSize.value,
statusFilter.value,
)
previewItems.value = result.items
previewTotal.value = result.total
totalByStatus.value = result.totalByStatus
} catch (e) {
previewError.value = e instanceof Error ? e.message : 'Failed to load preview'
} finally {
loading.value = false
}
}
async function execute(): Promise<void> {
if (selectedLibraryId.value === null) return
executing.value = true
executionStats.value = null
executionError.value = null
abortController = new AbortController()
try {
const res = await bulkRenameApi.executeBulkRename(selectedLibraryId.value, abortController.signal)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
if (!res.body) throw new Error('No response body')
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
const lines = buf.split('\n')
buf = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const raw = line.slice(6).trim()
if (!raw) continue
const event = JSON.parse(raw) as BulkRenameProgressEvent
if ('done' in event && event.done) {
executionStats.value = {
processed: event.processed,
succeeded: event.succeeded,
failed: event.failed,
skipped: event.skipped,
}
}
}
}
} catch (e) {
reader.cancel().catch(() => {})
throw e
}
} catch (e) {
if (abortController.signal.aborted) return
executionError.value = e instanceof Error ? e.message : 'Execution failed'
} finally {
executing.value = false
abortController = null
}
}
function cancelExecution(): void {
abortController?.abort()
}
function selectLibrary(libraryId: number): void {
selectedLibraryId.value = libraryId
page.value = 1
statusFilter.value = undefined
previewItems.value = []
previewTotal.value = 0
executionStats.value = null
executionError.value = null
}
function setPage(newPage: number): void {
page.value = newPage
}
function setStatusFilter(status: BulkRenameStatus | undefined): void {
statusFilter.value = status
page.value = 1
}
watch([page, statusFilter], () => {
if (selectedLibraryId.value !== null) {
loadPreview()
}
})
return {
selectedLibraryId,
page,
pageSize,
statusFilter,
totalPages,
previewItems,
previewTotal,
totalByStatus,
loading,
previewError,
executing,
executionStats,
executionError,
selectLibrary,
loadPreview,
execute,
cancelExecution,
setPage,
setStatusFilter,
}
}
+6
View File
@@ -296,6 +296,12 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/features/tools/entity-manager/views/EntityManagerView.vue'),
meta: { title: 'Entity Manager' },
},
{
path: 'bulk-rename',
name: 'tools-bulk-rename',
component: () => import('@/features/tools/bulk-rename/views/BulkRenameView.vue'),
meta: { title: 'Bulk Rename' },
},
{ path: ':pathMatch(.*)*', redirect: { name: 'tools-entity-manager' } },
],
},
+1
View File
@@ -38,6 +38,7 @@ export enum AuditAction {
LibraryAccessUpdate = "library.access.update",
LibraryAccessRevoke = "library.access.revoke",
LibraryWriteMetadataToFiles = "library.write_metadata_to_files",
LibraryBulkRename = "library.bulk_rename",
BookUpload = "book.upload",
BookMetadataUpdate = "book.metadata.update",
+23
View File
@@ -27,3 +27,26 @@ export interface FileRenameResult {
newPath?: string;
durationMs: number;
}
// ── Bulk Rename ─────────────────────────────────────────────────────────────
export type BulkRenameStatus = "will_rename" | "unchanged" | "collision" | "no_pattern" | "error";
export interface BulkRenamePreviewItem {
bookId: number;
title: string;
currentPath: string;
newPath: string | null;
status: BulkRenameStatus;
reason?: string;
}
export interface BulkRenamePreviewPage {
items: BulkRenamePreviewItem[];
total: number;
totalByStatus: Record<BulkRenameStatus, number>;
}
export type BulkRenameProgressEvent =
| { bookId: number; status: "success" | "failed" | "skipped"; reason?: string }
| { done: true; processed: number; succeeded: number; failed: number; skipped: number };
+4
View File
@@ -18,6 +18,8 @@ export const NotificationType = {
FileWriteBackFailed: "file_write_back_failed",
FileRenameCompleted: "file_rename_completed",
FileRenameFailed: "file_rename_failed",
BulkRenameCompleted: "bulk_rename_completed",
BulkRenameFailed: "bulk_rename_failed",
AchievementUnlocked: "achievement_unlocked",
} as const;
@@ -33,6 +35,7 @@ export const NOTIFICATION_CATEGORIES = {
migration: [NotificationType.MigrationCompleted, NotificationType.MigrationFailed],
fileWriteBack: [NotificationType.FileWriteBackCompleted, NotificationType.FileWriteBackFailed],
fileRename: [NotificationType.FileRenameCompleted, NotificationType.FileRenameFailed],
bulkRename: [NotificationType.BulkRenameCompleted, NotificationType.BulkRenameFailed],
achievements: [NotificationType.AchievementUnlocked],
} as const;
@@ -48,6 +51,7 @@ export const NOTIFICATION_CATEGORY_LABELS: Record<NotificationCategory, string>
migration: "Data Migration",
fileWriteBack: "File Write-back",
fileRename: "File Rename",
bulkRename: "Bulk Rename",
achievements: "Achievements",
};
+21
View File
@@ -427,6 +427,27 @@ export const E2E_SUITES = Object.freeze({
...SHARED_DB_AND_HELPER_PATHS,
],
},
"file-rename": {
id: "file-rename",
name: "File Rename",
timeout: 120,
lane: "full",
description: "Bulk file rename end-to-end suite covering all library/file structure permutations",
vitestTarget: "test/file-rename.e2e-spec.ts",
junitOutput: `${TEST_RESULTS_DIR}/file-rename-e2e-junit.xml`,
prepareDedicatedDatabase: true,
useDedicatedDatabase: true,
changedPaths: [
"server/src/modules/library/**",
"server/src/modules/file-write/**",
"server/src/modules/scanner/**",
"server/src/modules/app-settings/**",
"server/test/file-rename.e2e-spec.ts",
"server/test/e2e/file-rename/**",
"packages/types/src/file-write.ts",
...SHARED_DB_AND_HELPER_PATHS,
],
},
});
export function listE2ESuites() {
@@ -0,0 +1,137 @@
import { Inject, Injectable } from '@nestjs/common';
import { asc, eq, inArray } 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 BulkRenameBookData {
bookId: number;
title: string | null;
absolutePath: string;
relPath: string | null;
format: string | null;
libraryFolderPath: string;
organizationMode: string;
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[];
}
@Injectable()
export class BulkRenameRepository {
constructor(@Inject(DB) private readonly db: Db) {}
async findAllBooksForLibrary(libraryId: number): Promise<BulkRenameBookData[]> {
const rows = await this.db
.select({
bookId: books.id,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
format: bookFiles.format,
libraryFolderPath: libraryFolders.path,
organizationMode: libraries.organizationMode,
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.libraryId, libraryId))
.orderBy(asc(books.id));
const bookIds = rows.map((r) => r.bookId);
if (bookIds.length === 0) return [];
const authorRows = await this.db
.select({
bookId: bookAuthors.bookId,
name: authors.name,
})
.from(bookAuthors)
.innerJoin(authors, eq(authors.id, bookAuthors.authorId))
.where(inArray(bookAuthors.bookId, bookIds))
.orderBy(asc(bookAuthors.bookId), asc(bookAuthors.displayOrder));
const authorsByBook = new Map<number, string[]>();
for (const row of authorRows) {
const existing = authorsByBook.get(row.bookId);
if (existing) {
existing.push(row.name);
} else {
authorsByBook.set(row.bookId, [row.name]);
}
}
return rows.map((row) => ({
bookId: row.bookId,
title: row.title,
absolutePath: row.absolutePath,
relPath: row.relPath,
format: row.format,
libraryFolderPath: row.libraryFolderPath,
organizationMode: row.organizationMode,
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: authorsByBook.get(row.bookId) ?? [],
}));
}
async findLibrarySettings(libraryId: number): Promise<{
fileRenameEnabled: boolean;
fileNamingPattern: string | null;
organizationMode: string;
watch: boolean;
} | null> {
const [row] = await this.db
.select({
fileRenameEnabled: libraries.fileRenameEnabled,
fileNamingPattern: libraries.fileNamingPattern,
organizationMode: libraries.organizationMode,
watch: libraries.watch,
})
.from(libraries)
.where(eq(libraries.id, libraryId))
.limit(1);
return row ?? null;
}
async findLibraryBookIds(libraryId: number): Promise<number[]> {
const rows = await this.db.select({ id: books.id }).from(books).where(eq(books.libraryId, libraryId)).orderBy(asc(books.id));
return rows.map((r) => r.id);
}
}
@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import { asc, eq } from 'drizzle-orm';
import { asc, eq, inArray } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
@@ -162,4 +162,25 @@ export class FileRenameRepository {
return rows.length > 0 && rows[0].bookId !== currentBookId;
}
async findExistingPaths(absolutePaths: string[]): Promise<Map<string, number>> {
if (absolutePaths.length === 0) return new Map();
const result = new Map<string, number>();
const batchSize = 500;
for (let i = 0; i < absolutePaths.length; i += batchSize) {
const batch = absolutePaths.slice(i, i + batchSize);
const rows = await this.db
.select({ absolutePath: bookFiles.absolutePath, bookId: bookFiles.bookId })
.from(bookFiles)
.where(inArray(bookFiles.absolutePath, batch));
for (const row of rows) {
result.set(row.absolutePath, row.bookId);
}
}
return result;
}
}
@@ -488,4 +488,192 @@ describe('FileRenameService', () => {
service.scheduleRename(2, 10);
expect(() => service.onModuleDestroy()).not.toThrow();
});
it('moves files individually when new folder is nested inside old folder (deepening)', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: '{authors}/{title}/{title}',
file: {
absolutePath: '/library/Frank Herbert/Dune.epub',
relPath: 'Frank Herbert/Dune.epub',
},
bookFolderPath: '/library/Frank Herbert',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/Frank Herbert/Dune.epub', relPath: 'Frank Herbert/Dune.epub', role: 'primary' },
{ id: 11, absolutePath: '/library/Frank Herbert/cover.jpg', relPath: 'Frank Herbert/cover.jpg', role: 'cover' },
]);
const result = await service.performRename(5, 12);
expect(result).toEqual(expect.objectContaining({ status: 'success' }));
expect(mockMkdir).toHaveBeenCalledWith('/library/Frank Herbert/Dune', { recursive: true });
expect(mockRename).toHaveBeenCalledWith('/library/Frank Herbert/Dune.epub', '/library/Frank Herbert/Dune/Dune.epub');
expect(mockRename).toHaveBeenCalledWith('/library/Frank Herbert/cover.jpg', '/library/Frank Herbert/Dune/cover.jpg');
expect(renameRepo.applyFolderRename).toHaveBeenCalledWith(
5,
expect.arrayContaining([
expect.objectContaining({ id: 10, absolutePath: '/library/Frank Herbert/Dune/Dune.epub' }),
expect.objectContaining({ id: 11, absolutePath: '/library/Frank Herbert/Dune/cover.jpg' }),
]),
'/library/Frank Herbert/Dune',
);
});
it('moves files individually when old folder is nested inside new folder (flattening)', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: '{authors}/{title}',
file: {
absolutePath: '/library/Frank Herbert/Dune/Dune.epub',
relPath: 'Frank Herbert/Dune/Dune.epub',
},
bookFolderPath: '/library/Frank Herbert/Dune',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/Frank Herbert/Dune/Dune.epub', relPath: 'Frank Herbert/Dune/Dune.epub', role: 'primary' },
]);
const result = await service.performRename(5, 12);
expect(result).toEqual(expect.objectContaining({ status: 'success' }));
expect(mockMkdir).toHaveBeenCalledWith('/library/Frank Herbert', { recursive: true });
expect(mockRename).toHaveBeenCalledWith('/library/Frank Herbert/Dune/Dune.epub', '/library/Frank Herbert/Dune.epub');
});
it('moves a flat file into a new per-book folder when bookFolderPath equals the file path', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: '{authors}/{title}/{title}',
file: {
absolutePath: '/library/2666.epub',
relPath: '2666.epub',
},
bookFolderPath: '/library/2666.epub',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([{ id: 10, absolutePath: '/library/2666.epub', relPath: '2666.epub', role: 'content' }]);
const result = await service.performRename(5, 12);
expect(result).toEqual(
expect.objectContaining({
status: 'success',
oldPath: '/library/2666.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' }],
'/library/Frank Herbert/Dune',
);
expect(mockMkdir).toHaveBeenCalledWith('/library/Frank Herbert/Dune', { recursive: true });
expect(mockRename).toHaveBeenCalledTimes(1);
expect(mockRename).toHaveBeenCalledWith('/library/2666.epub', '/library/Frank Herbert/Dune/Dune.epub');
});
it('moves a flat file with sidecar files into a new per-book folder, preserving sidecar basenames', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: '{authors}/{title}/{title}',
file: {
absolutePath: '/library/2666.epub',
relPath: '2666.epub',
},
bookFolderPath: '/library/2666.epub',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/2666.epub', relPath: '2666.epub', role: 'content' },
{ id: 11, absolutePath: '/library/2666.opf', relPath: '2666.opf', role: 'metadata' },
{ id: 12, absolutePath: '/library/2666.jpg', relPath: '2666.jpg', role: 'cover' },
]);
const result = await service.performRename(5, 12);
expect(result).toEqual(expect.objectContaining({ status: 'success' }));
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/2666.opf', relPath: 'Frank Herbert/Dune/2666.opf' },
{ id: 12, absolutePath: '/library/Frank Herbert/Dune/2666.jpg', relPath: 'Frank Herbert/Dune/2666.jpg' },
],
'/library/Frank Herbert/Dune',
);
expect(mockRename).toHaveBeenCalledTimes(3);
expect(mockRename).toHaveBeenNthCalledWith(1, '/library/2666.epub', '/library/Frank Herbert/Dune/Dune.epub');
expect(mockRename).toHaveBeenNthCalledWith(2, '/library/2666.opf', '/library/Frank Herbert/Dune/2666.opf');
expect(mockRename).toHaveBeenNthCalledWith(3, '/library/2666.jpg', '/library/Frank Herbert/Dune/2666.jpg');
});
it('detects case-only nested folder moves and uses per-file individual moves (avoids EINVAL on case-insensitive filesystems)', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: '{authors}/{title}/{title} ({year})/{title} ({year})',
authors: ['Craig DiLouie'],
metadata: { title: 'The Infection', publishedYear: 2011 },
file: {
absolutePath: '/library/Craig Dilouie/The Infection/old.epub',
relPath: 'Craig Dilouie/The Infection/old.epub',
},
bookFolderPath: '/library/Craig Dilouie/The Infection',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/Craig Dilouie/The Infection/old.epub', relPath: 'Craig Dilouie/The Infection/old.epub', role: 'content' },
]);
const result = await service.performRename(5, 12);
expect(result).toEqual(expect.objectContaining({ status: 'success' }));
// Goes through moveBookFilesIndividually (not a folder rename), so mkdir creates the new folder
// and the file moves individually — avoiding the EINVAL that a folder-rename would hit.
expect(mockMkdir).toHaveBeenCalledWith('/library/Craig DiLouie/The Infection/The Infection (2011)', { recursive: true });
expect(mockRename).toHaveBeenCalledWith(
'/library/Craig Dilouie/The Infection/old.epub',
'/library/Craig DiLouie/The Infection/The Infection (2011)/The Infection (2011).epub',
);
});
it('rolls back individually moved files on error during nested folder move', async () => {
const { service, renameRepo } = makeService();
renameRepo.findBookRenameData.mockResolvedValue(
makeRenameData({
organizationMode: 'book_per_folder',
fileNamingPattern: '{authors}/{title}/{title}',
file: {
absolutePath: '/library/Frank Herbert/Dune.epub',
relPath: 'Frank Herbert/Dune.epub',
},
bookFolderPath: '/library/Frank Herbert',
}),
);
renameRepo.findAllBookFiles.mockResolvedValue([
{ id: 10, absolutePath: '/library/Frank Herbert/Dune.epub', relPath: 'Frank Herbert/Dune.epub', role: 'primary' },
{ id: 11, absolutePath: '/library/Frank Herbert/cover.jpg', relPath: 'Frank Herbert/cover.jpg', role: 'cover' },
]);
mockRename.mockResolvedValueOnce(undefined as never).mockRejectedValueOnce(new Error('disk full') as never);
const result = await service.performRename(5, 12);
expect(result).toEqual(expect.objectContaining({ status: 'failed', reason: 'disk full' }));
expect(mockRename).toHaveBeenCalledTimes(3);
expect(mockRename).toHaveBeenNthCalledWith(3, '/library/Frank Herbert/Dune/Dune.epub', '/library/Frank Herbert/Dune.epub');
expect(renameRepo.applyFolderRename).toHaveBeenCalledTimes(2);
});
});
@@ -11,6 +11,7 @@ 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';
import { buildTokens } from './file-rename.utils';
const FILE_RENAME_EVENT = 'file.rename';
const FILE_RENAME_ROLLBACK_EVENT = 'file.rename_rollback';
@@ -119,10 +120,12 @@ export class FileRenameService implements OnModuleDestroy {
return { status: 'skipped', reason: 'collision', oldPath: currentAbsolutePath, newPath: newAbsolutePath, durationMs: Date.now() - startedAt };
}
const filesystemTargetPath = isBookPerFolder && newFolderPath !== currentFolderPath ? newFolderPath : newAbsolutePath;
const bookHasOwnFolder = isBookPerFolder && currentFolderPath !== currentAbsolutePath;
const nestedFolderMove = bookHasOwnFolder && newFolderPath !== currentFolderPath && this.foldersAreNested(currentFolderPath, newFolderPath);
const renamingFolder = bookHasOwnFolder && newFolderPath !== currentFolderPath && !nestedFolderMove;
const filesystemTargetPath = renamingFolder ? newFolderPath : newAbsolutePath;
if (await this.pathExists(filesystemTargetPath)) {
const reason =
isBookPerFolder && newFolderPath !== currentFolderPath ? 'target folder already exists on disk' : 'target path already exists on disk';
const reason = renamingFolder ? 'target folder already exists on disk' : 'target path already exists on disk';
this.logger.warn(
`[${FILE_RENAME_EVENT}] [end] bookId=${bookId} userId=${userId} durationMs=${Date.now() - startedAt} status=skipped reason="${sanitizeLogValue(reason)}" newPath="${sanitizeLogValue(newAbsolutePath)}" - rename skipped: target already exists on disk`,
);
@@ -131,10 +134,12 @@ export class FileRenameService implements OnModuleDestroy {
}
try {
if (isBookPerFolder && newFolderPath !== currentFolderPath) {
if (bookHasOwnFolder && newFolderPath !== currentFolderPath) {
await this.renameBookWithFolder(bookId, data, currentFolderPath, newFolderPath, newAbsolutePath);
} else if (isBookPerFolder && newFolderPath !== currentFolderPath) {
await this.renameFlatBookToFolder(bookId, data, newFolderPath, newAbsolutePath);
} else {
const nextBookFolderPath = isBookPerFolder ? currentFolderPath : newAbsolutePath;
const nextBookFolderPath = isBookPerFolder ? newFolderPath : newAbsolutePath;
await this.renameBookFileOnly(bookId, data, currentAbsolutePath, newAbsolutePath, nextBookFolderPath);
}
} catch (err) {
@@ -182,6 +187,44 @@ export class FileRenameService implements OnModuleDestroy {
}
}
private async renameFlatBookToFolder(bookId: number, data: BookRenameData, newFolderPath: string, newPrimaryAbsolutePath: string): Promise<void> {
const allFiles = await this.renameRepo.findAllBookFiles(bookId);
const targetFor = (file: { id: number; absolutePath: string }): string =>
file.id === data.file.id ? newPrimaryAbsolutePath : join(newFolderPath, basename(file.absolutePath));
const newUpdates: BookFilePathUpdate[] = allFiles.map((file) => {
const absolutePath = targetFor(file);
return { id: file.id, absolutePath, relPath: relative(data.libraryFolderPath, absolutePath) };
});
const oldUpdates: BookFilePathUpdate[] = allFiles.map((file) => ({
id: file.id,
absolutePath: file.absolutePath,
relPath: file.relPath,
}));
await this.renameRepo.applyFolderRename(bookId, newUpdates, newFolderPath);
const moved: Array<{ from: string; to: string }> = [];
try {
await mkdir(newFolderPath, { recursive: true });
for (const file of allFiles) {
const newAbsolutePath = targetFor(file);
await this.lockService.withLock(file.absolutePath, () => fsRename(file.absolutePath, newAbsolutePath));
moved.push({ from: file.absolutePath, to: newAbsolutePath });
}
} catch (error) {
for (const { from, to } of [...moved].reverse()) {
try {
await fsRename(to, from);
} catch (rollbackError) {
this.logRollbackFailure(bookId, error, rollbackError);
}
}
await this.rollbackFolderRename(bookId, oldUpdates, data.bookFolderPath, error);
throw error;
}
}
private async renameBookWithFolder(
bookId: number,
data: BookRenameData,
@@ -190,7 +233,6 @@ export class FileRenameService implements OnModuleDestroy {
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);
@@ -204,25 +246,77 @@ export class FileRenameService implements OnModuleDestroy {
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));
if (this.foldersAreNested(oldFolderPath, newFolderPath)) {
await this.moveBookFilesIndividually(bookId, allFiles, data, oldFolderPath, newFolderPath, newPrimaryAbsolutePath, oldUpdates);
} else {
const movedPrimaryAbsolutePath = join(newFolderPath, relative(oldFolderPath, data.file.absolutePath));
let folderRenamed = false;
try {
await mkdir(dirname(newFolderPath), { recursive: true });
await this.lockService.withLock(oldFolderPath, () => fsRename(oldFolderPath, newFolderPath));
folderRenamed = true;
if (movedPrimaryAbsolutePath !== newPrimaryAbsolutePath) {
await this.lockService.withLock(movedPrimaryAbsolutePath, () => fsRename(movedPrimaryAbsolutePath, newPrimaryAbsolutePath));
}
} catch (error) {
await this.rollbackFolderRename(bookId, oldUpdates, oldFolderPath, error);
if (folderRenamed) {
await this.rollbackFolderMove(bookId, newFolderPath, oldFolderPath, error);
}
throw error;
}
} catch (error) {
await this.rollbackFolderRename(bookId, oldUpdates, oldFolderPath, error);
if (folderRenamed) {
await this.rollbackFolderMove(bookId, newFolderPath, oldFolderPath, error);
}
throw error;
}
await this.tryRemoveEmptyDir(oldFolderPath);
await this.tryRemoveEmptyDir(dirname(oldFolderPath));
}
private async moveBookFilesIndividually(
bookId: number,
allFiles: Awaited<ReturnType<FileRenameRepository['findAllBookFiles']>>,
data: BookRenameData,
oldFolderPath: string,
newFolderPath: string,
newPrimaryAbsolutePath: string,
oldUpdates: BookFilePathUpdate[],
): Promise<void> {
const movedFiles: Array<{ from: string; to: string }> = [];
try {
await mkdir(newFolderPath, { recursive: true });
for (const file of allFiles) {
const relToOldFolder = relative(oldFolderPath, file.absolutePath);
const newFilePath = file.id === data.file.id ? newPrimaryAbsolutePath : join(newFolderPath, relToOldFolder);
const newFileDir = dirname(newFilePath);
if (newFileDir !== newFolderPath) {
await mkdir(newFileDir, { recursive: true });
}
await this.lockService.withLock(file.absolutePath, () => fsRename(file.absolutePath, newFilePath));
movedFiles.push({ from: file.absolutePath, to: newFilePath });
}
} catch (error) {
for (const { from, to } of [...movedFiles].reverse()) {
try {
await mkdir(dirname(from), { recursive: true });
await fsRename(to, from);
} catch (rollbackError) {
this.logRollbackFailure(bookId, error, rollbackError);
}
}
await this.rollbackFolderRename(bookId, oldUpdates, oldFolderPath, error);
throw error;
}
}
private foldersAreNested(pathA: string, pathB: string): boolean {
const normA = (pathA.endsWith('/') ? pathA : pathA + '/').toLowerCase();
const normB = (pathB.endsWith('/') ? pathB : pathB + '/').toLowerCase();
return normB.startsWith(normA) || normA.startsWith(normB);
}
private async rollbackFileRename(
bookId: number,
fileId: number,
@@ -319,46 +413,6 @@ export class FileRenameService implements OnModuleDestroy {
}
}
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;
@@ -0,0 +1,101 @@
import { buildTokens, formatSeriesIndex } from './file-rename.utils';
describe('file-rename.utils', () => {
describe('formatSeriesIndex', () => {
it('returns null for null input', () => {
expect(formatSeriesIndex(null)).toBeNull();
});
it('formats whole number with zero-padding', () => {
expect(formatSeriesIndex(1)).toBe('01');
expect(formatSeriesIndex(5)).toBe('05');
expect(formatSeriesIndex(12)).toBe('12');
});
it('formats fractional index correctly', () => {
expect(formatSeriesIndex(1.5)).toBe('01.5');
expect(formatSeriesIndex(3.25)).toBe('03.25');
});
it('formats zero correctly', () => {
expect(formatSeriesIndex(0)).toBe('00');
});
it('formats large numbers', () => {
expect(formatSeriesIndex(100)).toBe('100');
});
});
describe('buildTokens', () => {
const fullMetadata = {
title: 'Dune',
subtitle: 'A Novel',
publisher: 'Ace',
language: 'en',
isbn13: '9780441172719',
publishedYear: 1965,
seriesName: 'Dune Chronicles',
seriesIndex: 1,
};
it('builds all tokens from full metadata', () => {
const tokens = buildTokens(fullMetadata, ['Frank Herbert'], 'dune', 'epub');
expect(tokens).toEqual({
originalFilename: 'dune',
extension: 'epub',
title: 'Dune',
subtitle: 'A Novel',
publisher: 'Ace',
language: 'en',
isbn: '9780441172719',
year: '1965',
series: 'Dune Chronicles',
seriesIndex: '01',
authors: 'Frank Herbert',
});
});
it('joins multiple authors with comma', () => {
const tokens = buildTokens(fullMetadata, ['Author A', 'Author B'], 'book', 'pdf');
expect(tokens['authors']).toBe('Author A, Author B');
});
it('omits tokens for null metadata fields', () => {
const emptyMetadata = {
title: null,
subtitle: null,
publisher: null,
language: null,
isbn13: null,
publishedYear: null,
seriesName: null,
seriesIndex: null,
};
const tokens = buildTokens(emptyMetadata, [], 'file', 'epub');
expect(tokens).toEqual({ originalFilename: 'file', extension: 'epub' });
});
it('omits authors when array is empty', () => {
const tokens = buildTokens(fullMetadata, [], 'book', 'epub');
expect(tokens['authors']).toBeUndefined();
});
it('includes only non-null fields', () => {
const partialMetadata = {
title: 'Test',
subtitle: null,
publisher: null,
language: null,
isbn13: null,
publishedYear: 2023,
seriesName: null,
seriesIndex: null,
};
const tokens = buildTokens(partialMetadata, [], 'test', 'pdf');
expect(Object.keys(tokens).sort()).toEqual(['extension', 'originalFilename', 'title', 'year'].sort());
});
});
});
@@ -0,0 +1,36 @@
export interface RenameMetadata {
title: string | null;
subtitle: string | null;
publisher: string | null;
language: string | null;
isbn13: string | null;
publishedYear: number | null;
seriesName: string | null;
seriesIndex: number | null;
}
export function buildTokens(metadata: RenameMetadata, 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;
}
export 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;
}
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { NotificationModule } from '../notification/notification.module';
import { BulkRenameRepository } from './bulk-rename.repository';
import { FileLockService } from './file-lock.service';
import { FileRenameRepository } from './file-rename.repository';
import { FileRenameService } from './file-rename.service';
@@ -22,6 +23,7 @@ import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
FileRenameRepository,
FileRenameService,
FileLockService,
BulkRenameRepository,
EpubFormatWriter,
PdfFormatWriter,
CbzFormatWriter,
@@ -33,6 +35,6 @@ import { FORMAT_WRITERS } from './interfaces/format-writer.interface';
},
FormatWriterRegistry,
],
exports: [FileWriteService, FileWriteRepository, FileRenameService],
exports: [FileWriteService, FileWriteRepository, FileRenameService, FileRenameRepository, BulkRenameRepository],
})
export class FileWriteModule {}
@@ -0,0 +1,638 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BulkRenameService } from './bulk-rename.service';
import type { BulkRenameBookData } from '../file-write/bulk-rename.repository';
import type { FileRenameResult } from '@bookorbit/types';
function makeBookData(overrides: Partial<BulkRenameBookData> & { bookId?: number } = {}): BulkRenameBookData {
const bookId = overrides.bookId ?? 1;
return {
bookId,
title: 'Test Book',
absolutePath: `/library/folder/book-${bookId}.epub`,
relPath: `folder/book-${bookId}.epub`,
format: 'epub',
libraryFolderPath: '/library',
organizationMode: 'book_per_file',
fileNamingPattern: '{authors}/{title}',
bookFolderPath: `/library/folder/book-${bookId}.epub`,
metadata: {
title: overrides.title ?? 'Test Book',
subtitle: null,
publisher: null,
language: null,
isbn13: null,
publishedYear: null,
seriesName: null,
seriesIndex: null,
},
authors: ['Author A'],
...overrides,
};
}
describe('BulkRenameService', () => {
let service: BulkRenameService;
const bulkRenameRepo = {
findAllBooksForLibrary: vi.fn(),
findLibrarySettings: vi.fn(),
findLibraryBookIds: vi.fn(),
};
const fileRenameRepo = {
findExistingPaths: vi.fn(),
};
const fileRenameService = {
performRename: vi.fn(),
};
const appSettings = {
getUploadPattern: vi.fn(),
getUploadPatternBookPerFolder: vi.fn(),
isCrossPlatformPathSanitizationEnabled: vi.fn(),
};
const notificationService = {
notify: vi.fn(),
};
const fileWatcherService = {
stopWatcher: vi.fn(),
startWatcher: vi.fn(),
};
const libraryRepo = {
findFoldersByLibrary: vi.fn(),
};
beforeEach(() => {
vi.resetAllMocks();
appSettings.isCrossPlatformPathSanitizationEnabled.mockResolvedValue(false);
appSettings.getUploadPattern.mockResolvedValue('{authors}/{title}');
appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{authors}/{title}/');
notificationService.notify.mockResolvedValue(undefined);
fileRenameRepo.findExistingPaths.mockResolvedValue(new Map());
libraryRepo.findFoldersByLibrary.mockResolvedValue([{ path: '/library' }]);
service = new BulkRenameService(
bulkRenameRepo as any,
fileRenameRepo as any,
fileRenameService as any,
appSettings as any,
notificationService as any,
fileWatcherService as any,
libraryRepo as any,
);
});
describe('getPreview', () => {
it('computes preview items for a library', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{authors}/{title}',
organizationMode: 'book_per_file',
watch: false,
});
const book = makeBookData({ bookId: 1, title: 'Dune' });
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
const result = await service.getPreview(1, 1, 50);
expect(result.items).toHaveLength(1);
expect(result.items[0].bookId).toBe(1);
expect(result.items[0].status).toBe('will_rename');
expect(result.items[0].newPath).toContain('Author A');
expect(result.items[0].newPath).toContain('Dune');
expect(result.total).toBe(1);
expect(result.totalByStatus.will_rename).toBe(1);
});
it('marks books as unchanged when path already matches', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
});
const book = makeBookData({
bookId: 1,
title: 'MyBook',
absolutePath: '/library/MyBook.epub',
});
book.metadata.title = 'MyBook';
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
const result = await service.getPreview(1, 1, 50);
expect(result.items[0].status).toBe('unchanged');
});
it('detects cross-book collisions', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
});
const book1 = makeBookData({ bookId: 1, title: 'Dune' });
book1.metadata.title = 'Dune';
const book2 = makeBookData({ bookId: 2, title: 'Dune' });
book2.metadata.title = 'Dune';
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book1, book2]);
const result = await service.getPreview(1, 1, 50);
expect(result.items[0].status).toBe('collision');
expect(result.items[1].status).toBe('collision');
expect(result.totalByStatus.collision).toBe(2);
});
it('detects collision with existing paths in the database', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
});
const book = makeBookData({ bookId: 1, title: 'Taken' });
book.metadata.title = 'Taken';
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
fileRenameRepo.findExistingPaths.mockResolvedValue(new Map([['/library/Taken.epub', 99]]));
const result = await service.getPreview(1, 1, 50);
expect(result.items[0].status).toBe('collision');
expect(result.items[0].reason).toContain('already taken');
});
it('does not flag collision when the same book owns the existing path', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
});
const book = makeBookData({ bookId: 1, title: 'SameBook' });
book.metadata.title = 'SameBook';
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
fileRenameRepo.findExistingPaths.mockResolvedValue(new Map([['/library/SameBook.epub', 1]]));
const result = await service.getPreview(1, 1, 50);
expect(result.items[0].status).not.toBe('collision');
});
it('marks no_pattern when no pattern is configured', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: null,
organizationMode: 'book_per_file',
watch: false,
});
appSettings.getUploadPattern.mockResolvedValue(null);
const book = makeBookData({ bookId: 1 });
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
const result = await service.getPreview(1, 1, 50);
expect(result.items[0].status).toBe('no_pattern');
});
it('paginates correctly', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
});
const books = Array.from({ length: 5 }, (_, i) => {
const b = makeBookData({ bookId: i + 1, title: `Book${i + 1}` });
b.metadata.title = `Book${i + 1}`;
return b;
});
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue(books);
const page1 = await service.getPreview(1, 1, 2);
expect(page1.items).toHaveLength(2);
expect(page1.total).toBe(5);
expect(page1.items[0].bookId).toBe(1);
expect(page1.items[1].bookId).toBe(2);
const page2 = await service.getPreview(1, 2, 2);
expect(page2.items).toHaveLength(2);
expect(page2.items[0].bookId).toBe(3);
});
it('filters by status', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
});
const book1 = makeBookData({ bookId: 1, title: 'WillChange' });
book1.metadata.title = 'WillChange';
const book2 = makeBookData({ bookId: 2, title: 'AlreadyRight' });
book2.metadata.title = 'AlreadyRight';
book2.absolutePath = '/library/AlreadyRight.epub';
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book1, book2]);
const willRename = await service.getPreview(1, 1, 50, 'will_rename');
expect(willRename.items).toHaveLength(1);
expect(willRename.items[0].bookId).toBe(1);
expect(willRename.total).toBe(1);
const unchanged = await service.getPreview(1, 1, 50, 'unchanged');
expect(unchanged.items).toHaveLength(1);
expect(unchanged.items[0].bookId).toBe(2);
});
it('throws NotFoundException when library does not exist', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(null);
await expect(service.getPreview(999, 1, 50)).rejects.toThrow(NotFoundException);
});
it('uses cache for repeated calls', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
});
const book = makeBookData({ bookId: 1, title: 'Cached' });
book.metadata.title = 'Cached';
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
await service.getPreview(1, 1, 50);
await service.getPreview(1, 1, 50);
expect(bulkRenameRepo.findAllBooksForLibrary).toHaveBeenCalledTimes(1);
});
it('falls back to global pattern when library has no pattern', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: null,
organizationMode: 'book_per_file',
watch: false,
});
appSettings.getUploadPattern.mockResolvedValue('{title}');
const book = makeBookData({ bookId: 1, title: 'FallbackTest' });
book.metadata.title = 'FallbackTest';
book.fileNamingPattern = null;
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
const result = await service.getPreview(1, 1, 50);
expect(result.items[0].newPath).toContain('FallbackTest');
expect(appSettings.getUploadPattern).toHaveBeenCalled();
});
it('uses book_per_folder pattern for book_per_folder organization mode', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: null,
organizationMode: 'book_per_folder',
watch: false,
});
appSettings.getUploadPatternBookPerFolder.mockResolvedValue('{title}/');
const book = makeBookData({ bookId: 1, title: 'FolderBook' });
book.metadata.title = 'FolderBook';
book.fileNamingPattern = null;
book.organizationMode = 'book_per_folder';
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
const result = await service.getPreview(1, 1, 50);
expect(appSettings.getUploadPatternBookPerFolder).toHaveBeenCalled();
expect(result.items[0].newPath).toContain('FolderBook');
});
it('returns empty items for library with no books', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
});
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([]);
const result = await service.getPreview(1, 1, 50);
expect(result.items).toHaveLength(0);
expect(result.total).toBe(0);
expect(result.totalByStatus.will_rename).toBe(0);
});
});
describe('execute', () => {
const defaultSettings = {
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
};
it('renames all books sequentially and reports summary', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1, 2, 3]);
const results: FileRenameResult[] = [
{ status: 'success', durationMs: 10 },
{ status: 'skipped', reason: 'path unchanged', durationMs: 5 },
{ status: 'success', durationMs: 10 },
];
fileRenameService.performRename.mockImplementation(() => {
return Promise.resolve(results.shift()!);
});
const events: any[] = [];
const summary = await service.execute(1, 42, {
onProgress: (e) => events.push(e),
isCancelled: () => false,
});
expect(summary.processed).toBe(3);
expect(summary.succeeded).toBe(2);
expect(summary.failed).toBe(0);
expect(summary.skipped).toBe(1);
expect(summary.cancelled).toBe(false);
expect(events).toHaveLength(3);
expect(fileRenameService.performRename).toHaveBeenCalledTimes(3);
});
it('stops when cancelled and reports partial progress', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1, 2, 3]);
let callCount = 0;
fileRenameService.performRename.mockImplementation(() => {
callCount++;
return Promise.resolve({ status: 'success', durationMs: 10 });
});
const summary = await service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => callCount >= 1,
});
expect(summary.succeeded).toBe(1);
expect(summary.cancelled).toBe(true);
expect(fileRenameService.performRename).toHaveBeenCalledTimes(1);
});
it('throws when library not found', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(null);
await expect(
service.execute(999, 42, {
onProgress: () => {},
isCancelled: () => false,
}),
).rejects.toThrow(NotFoundException);
});
it('throws when file rename is not enabled', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
...defaultSettings,
fileRenameEnabled: false,
});
await expect(
service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
}),
).rejects.toThrow(BadRequestException);
});
it('prevents concurrent execution on same library', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1]);
let resolveRename: ((value: FileRenameResult) => void) | undefined;
fileRenameService.performRename.mockImplementation(
() =>
new Promise<FileRenameResult>((resolve) => {
resolveRename = resolve;
}),
);
const first = service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
});
await vi.waitFor(() => expect(resolveRename).toBeDefined());
await expect(
service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
}),
).rejects.toThrow(BadRequestException);
resolveRename!({ status: 'success', durationMs: 10 });
await first;
});
it('stops and restarts file watcher when library has watch enabled', async () => {
const watchSettings = { ...defaultSettings, watch: true };
bulkRenameRepo.findLibrarySettings.mockResolvedValue(watchSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1]);
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 10 });
await service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
});
expect(fileWatcherService.stopWatcher).toHaveBeenCalledWith(1);
expect(fileWatcherService.startWatcher).toHaveBeenCalledWith(1, ['/library']);
});
it('does not touch file watcher when library has watch disabled', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1]);
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 10 });
await service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
});
expect(fileWatcherService.stopWatcher).not.toHaveBeenCalled();
expect(fileWatcherService.startWatcher).not.toHaveBeenCalled();
});
it('restarts file watcher even after failure', async () => {
const watchSettings = { ...defaultSettings, watch: true };
bulkRenameRepo.findLibrarySettings.mockResolvedValue(watchSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1]);
fileRenameService.performRename.mockRejectedValue(new Error('disk error'));
await service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
});
expect(fileWatcherService.stopWatcher).toHaveBeenCalledWith(1);
expect(fileWatcherService.startWatcher).toHaveBeenCalledWith(1, ['/library']);
});
it('counts failed renames from performRename', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1, 2]);
fileRenameService.performRename.mockResolvedValueOnce({ status: 'failed', reason: 'collision', durationMs: 5 });
fileRenameService.performRename.mockResolvedValueOnce({ status: 'success', durationMs: 10 });
const events: any[] = [];
const summary = await service.execute(1, 42, {
onProgress: (e) => events.push(e),
isCancelled: () => false,
});
expect(summary.failed).toBe(1);
expect(summary.succeeded).toBe(1);
expect(events[0].status).toBe('failed');
expect(events[1].status).toBe('success');
});
it('handles thrown errors from performRename gracefully', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1]);
fileRenameService.performRename.mockRejectedValue(new Error('disk error'));
const events: any[] = [];
const summary = await service.execute(1, 42, {
onProgress: (e) => events.push(e),
isCancelled: () => false,
});
expect(summary.failed).toBe(1);
expect(events[0].status).toBe('failed');
expect(events[0].reason).toBe('disk error');
});
it('sends success notification when all renames succeed', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1]);
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 10 });
await service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
});
expect(notificationService.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'bulk_rename_completed',
title: 'Bulk rename completed',
}),
);
});
it('sends failure notification when some renames fail', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1]);
fileRenameService.performRename.mockResolvedValue({ status: 'failed', reason: 'collision', durationMs: 5 });
await service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
});
expect(notificationService.notify).toHaveBeenCalledWith(
expect.objectContaining({
type: 'bulk_rename_failed',
title: 'Bulk rename completed with errors',
}),
);
});
it('clears running lock after execution completes', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1]);
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 10 });
expect(service.isRunning(1)).toBe(false);
const promise = service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
});
await promise;
expect(service.isRunning(1)).toBe(false);
});
it('invalidates preview cache on execute', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue(defaultSettings);
const book = makeBookData({ bookId: 1, title: 'CacheTest' });
book.metadata.title = 'CacheTest';
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
await service.getPreview(1, 1, 50);
expect(bulkRenameRepo.findAllBooksForLibrary).toHaveBeenCalledTimes(1);
bulkRenameRepo.findLibraryBookIds.mockResolvedValue([1]);
fileRenameService.performRename.mockResolvedValue({ status: 'success', durationMs: 10 });
await service.execute(1, 42, {
onProgress: () => {},
isCancelled: () => false,
});
await service.getPreview(1, 1, 50);
expect(bulkRenameRepo.findAllBooksForLibrary).toHaveBeenCalledTimes(2);
});
});
describe('isRunning', () => {
it('returns false when no execution is in progress', () => {
expect(service.isRunning(1)).toBe(false);
});
});
describe('invalidateCache', () => {
it('causes next getPreview call to recompute', async () => {
bulkRenameRepo.findLibrarySettings.mockResolvedValue({
fileRenameEnabled: true,
fileNamingPattern: '{title}',
organizationMode: 'book_per_file',
watch: false,
});
const book = makeBookData({ bookId: 1, title: 'Invalidation' });
book.metadata.title = 'Invalidation';
bulkRenameRepo.findAllBooksForLibrary.mockResolvedValue([book]);
await service.getPreview(1, 1, 50);
service.invalidateCache(1);
await service.getPreview(1, 1, 50);
expect(bulkRenameRepo.findAllBooksForLibrary).toHaveBeenCalledTimes(2);
});
});
});
@@ -0,0 +1,315 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { basename, extname, join } from 'path';
import type { BulkRenamePreviewItem, BulkRenamePreviewPage, BulkRenameProgressEvent, BulkRenameStatus } 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 { FileRenameService } from '../file-write/file-rename.service';
import { FileRenameRepository } from '../file-write/file-rename.repository';
import { FileWatcherService } from '../scanner/file-watcher.service';
import { LibraryRepository } from './library.repository';
import type { BulkRenameBookData } from '../file-write/bulk-rename.repository';
import { BulkRenameRepository } from '../file-write/bulk-rename.repository';
import { buildTokens } from '../file-write/file-rename.utils';
const CACHE_TTL_MS = 60_000;
interface CachedPreview {
items: BulkRenamePreviewItem[];
totalByStatus: Record<BulkRenameStatus, number>;
createdAt: number;
}
interface BulkRenameStreamOptions {
onProgress: (event: BulkRenameProgressEvent) => void;
isCancelled: () => boolean;
}
interface BulkRenameSummary {
processed: number;
succeeded: number;
failed: number;
skipped: number;
cancelled: boolean;
}
@Injectable()
export class BulkRenameService {
private readonly logger = new Logger(BulkRenameService.name);
private readonly previewCache = new Map<number, CachedPreview>();
private readonly runningLibraries = new Set<number>();
constructor(
private readonly bulkRenameRepo: BulkRenameRepository,
private readonly fileRenameRepo: FileRenameRepository,
private readonly fileRenameService: FileRenameService,
private readonly appSettings: AppSettingsService,
private readonly notificationService: NotificationService,
private readonly fileWatcherService: FileWatcherService,
private readonly libraryRepo: LibraryRepository,
) {}
async getPreview(libraryId: number, page: number, pageSize: number, statusFilter?: BulkRenameStatus): Promise<BulkRenamePreviewPage> {
const cached = this.previewCache.get(libraryId);
const now = Date.now();
let preview: CachedPreview;
if (cached && now - cached.createdAt < CACHE_TTL_MS) {
preview = cached;
} else {
preview = await this.computeFullPreview(libraryId);
this.previewCache.set(libraryId, preview);
}
const filtered = statusFilter ? preview.items.filter((item) => item.status === statusFilter) : preview.items;
const start = (page - 1) * pageSize;
const items = filtered.slice(start, start + pageSize);
return {
items,
total: filtered.length,
totalByStatus: preview.totalByStatus,
};
}
async execute(libraryId: number, userId: number, options: BulkRenameStreamOptions): Promise<BulkRenameSummary> {
const event = 'library.bulk_rename';
const startedAt = Date.now();
if (this.runningLibraries.has(libraryId)) {
throw new BadRequestException('A bulk rename is already running for this library.');
}
const settings = await this.bulkRenameRepo.findLibrarySettings(libraryId);
if (!settings) throw new NotFoundException('Library not found');
if (!settings.fileRenameEnabled) {
throw new BadRequestException('File rename is not enabled for this library.');
}
this.runningLibraries.add(libraryId);
this.previewCache.delete(libraryId);
const bookIds = await this.bulkRenameRepo.findLibraryBookIds(libraryId);
this.logger.log(`[${event}] [start] libraryId=${libraryId} userId=${userId} bookCount=${bookIds.length} - bulk rename started`);
let watcherWasStopped = false;
if (settings.watch) {
await this.fileWatcherService.stopWatcher(libraryId);
watcherWasStopped = true;
}
let succeeded = 0;
let failed = 0;
let skipped = 0;
try {
for (const bookId of bookIds) {
if (options.isCancelled()) break;
try {
const result = await this.fileRenameService.performRename(bookId, userId);
if (result.status === 'success') succeeded++;
else if (result.status === 'failed') failed++;
else skipped++;
options.onProgress({
bookId,
status: result.status,
reason: result.reason,
});
} catch (err) {
failed++;
options.onProgress({
bookId,
status: 'failed',
reason: getErrorMessage(err),
});
}
}
const cancelled = options.isCancelled();
const durationMs = Date.now() - startedAt;
this.logger.log(
`[${event}] [end] libraryId=${libraryId} userId=${userId} durationMs=${durationMs} succeeded=${succeeded} failed=${failed} skipped=${skipped} cancelled=${cancelled} - bulk rename completed`,
);
await this.notifyCompletion(userId, libraryId, succeeded, failed);
return { processed: succeeded + failed + skipped, succeeded, failed, skipped, cancelled };
} catch (err) {
const durationMs = Date.now() - startedAt;
const errorClass = err instanceof Error ? err.name : 'Error';
const errorMessage = sanitizeLogValue(getErrorMessage(err));
this.logger.error(
`[${event}] [fail] libraryId=${libraryId} userId=${userId} durationMs=${durationMs} errorClass=${errorClass} error="${errorMessage}" - bulk rename failed`,
);
await this.notifyFailure(userId, libraryId, getErrorMessage(err));
throw err;
} finally {
this.runningLibraries.delete(libraryId);
if (watcherWasStopped) {
const folders = await this.libraryRepo.findFoldersByLibrary(libraryId);
await this.fileWatcherService.startWatcher(
libraryId,
folders.map((f) => f.path),
);
}
}
}
isRunning(libraryId: number): boolean {
return this.runningLibraries.has(libraryId);
}
invalidateCache(libraryId: number): void {
this.previewCache.delete(libraryId);
}
private async computeFullPreview(libraryId: number): Promise<CachedPreview> {
const settings = await this.bulkRenameRepo.findLibrarySettings(libraryId);
if (!settings) throw new NotFoundException('Library not found');
const books = await this.bulkRenameRepo.findAllBooksForLibrary(libraryId);
const pattern =
settings.fileNamingPattern ??
(settings.organizationMode === 'book_per_folder'
? await this.appSettings.getUploadPatternBookPerFolder()
: await this.appSettings.getUploadPattern());
const sanitizeForCrossPlatform = await this.appSettings.isCrossPlatformPathSanitizationEnabled();
const previewItems: BulkRenamePreviewItem[] = [];
const newPathToBookIds = new Map<string, number[]>();
for (const book of books) {
const item = this.computePreviewItem(book, pattern, sanitizeForCrossPlatform);
previewItems.push(item);
if (item.newPath) {
const existing = newPathToBookIds.get(item.newPath);
if (existing) {
existing.push(book.bookId);
} else {
newPathToBookIds.set(item.newPath, [book.bookId]);
}
}
}
const collisionPaths = new Set<string>();
for (const [path, ids] of newPathToBookIds) {
if (ids.length > 1) collisionPaths.add(path);
}
const nonCollisionNewPaths = previewItems
.filter((item) => item.newPath && !collisionPaths.has(item.newPath) && item.status === 'will_rename')
.map((item) => item.newPath!);
const existingPathOwners = await this.fileRenameRepo.findExistingPaths(nonCollisionNewPaths);
for (const item of previewItems) {
if (!item.newPath) continue;
if (collisionPaths.has(item.newPath)) {
item.status = 'collision';
item.reason = 'Multiple books would resolve to the same path';
continue;
}
if (item.status === 'will_rename') {
const owner = existingPathOwners.get(item.newPath);
if (owner !== undefined && owner !== item.bookId) {
item.status = 'collision';
item.reason = 'Path already taken by another book';
}
}
}
const totalByStatus: Record<BulkRenameStatus, number> = {
will_rename: 0,
unchanged: 0,
collision: 0,
no_pattern: 0,
error: 0,
};
for (const item of previewItems) {
totalByStatus[item.status]++;
}
return { items: previewItems, totalByStatus, createdAt: Date.now() };
}
private computePreviewItem(book: BulkRenameBookData, pattern: string | null, sanitizeForCrossPlatform: boolean): BulkRenamePreviewItem {
const baseItem: BulkRenamePreviewItem = {
bookId: book.bookId,
title: book.title ?? 'Untitled',
currentPath: book.absolutePath,
newPath: null,
status: 'unchanged',
};
if (!pattern) {
return { ...baseItem, status: 'no_pattern', reason: 'No naming pattern configured' };
}
try {
const format = (book.format ?? extname(book.absolutePath).slice(1)).toLowerCase();
const originalStem = basename(book.absolutePath, extname(book.absolutePath));
const tokens = buildTokens(book.metadata, book.authors, originalStem, format);
const resolvedRelPath = resolveUploadPath(pattern, tokens, format, { sanitizeForCrossPlatform });
if (!resolvedRelPath) {
return { ...baseItem, status: 'no_pattern', reason: 'Pattern resolved to empty' };
}
const newAbsolutePath = join(book.libraryFolderPath, resolvedRelPath);
if (newAbsolutePath === book.absolutePath) {
return { ...baseItem, newPath: newAbsolutePath, status: 'unchanged' };
}
return { ...baseItem, newPath: newAbsolutePath, status: 'will_rename' };
} catch (err) {
return { ...baseItem, status: 'error', reason: getErrorMessage(err) };
}
}
private async notifyCompletion(userId: number, libraryId: number, succeeded: number, failed: number): Promise<void> {
const type = failed > 0 ? NotificationType.BulkRenameFailed : NotificationType.BulkRenameCompleted;
const title = failed > 0 ? 'Bulk rename completed with errors' : 'Bulk rename completed';
const message = `${succeeded} renamed, ${failed} failed`;
await this.notificationService
.notify({
type,
title,
message,
scope: { kind: 'user', userId },
meta: { libraryId, succeeded, failed },
})
.catch(() => {});
}
private async notifyFailure(userId: number, libraryId: number, error: string): Promise<void> {
await this.notificationService
.notify({
type: NotificationType.BulkRenameFailed,
title: 'Bulk rename failed',
message: error.slice(0, 200),
scope: { kind: 'user', userId },
meta: { libraryId },
})
.catch(() => {});
}
}
function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}
@@ -0,0 +1,21 @@
import { IsIn, IsInt, IsOptional, Min } from 'class-validator';
import { Transform } from 'class-transformer';
import type { BulkRenameStatus } from '@bookorbit/types';
const VALID_STATUSES: BulkRenameStatus[] = ['will_rename', 'unchanged', 'collision', 'no_pattern', 'error'];
export class BulkRenamePreviewQueryDto {
@IsInt()
@Min(1)
@Transform(({ value }) => parseInt(value, 10))
page: number = 1;
@IsInt()
@Min(1)
@Transform(({ value }) => parseInt(value, 10))
pageSize: number = 50;
@IsOptional()
@IsIn(VALID_STATUSES)
status?: BulkRenameStatus;
}
@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import type { Mock } from 'vitest';
import { LibraryController } from './library.controller';
@@ -21,7 +22,13 @@ describe('LibraryController', () => {
const bookService = { queryForLibrary: vi.fn() };
const controller = new LibraryController(libraryService as any, bookService as any);
const bulkRenameService = {
getPreview: vi.fn(),
isRunning: vi.fn(),
execute: vi.fn(),
};
const controller = new LibraryController(libraryService as any, bookService as any, bulkRenameService as any);
beforeEach(() => {
vi.resetAllMocks();
@@ -73,7 +80,7 @@ describe('LibraryController', () => {
expect(reply.raw.off).toHaveBeenCalledWith('close', expect.any(Function));
expect(reply.raw.off).toHaveBeenCalledWith('aborted', expect.any(Function));
const doneLine = (reply.raw.write as vi.Mock).mock.calls[3][0] as string;
const doneLine = (reply.raw.write as Mock).mock.calls[3][0] as string;
const donePayload = JSON.parse(doneLine.replace(/^data:\s*/, '').trim());
expect(donePayload).toEqual(expect.objectContaining({ done: true, processed: 3, succeeded: 1, failed: 1, skipped: 1 }));
expect(reply.raw.end).toHaveBeenCalled();
@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPip
import type { FastifyReply } from 'fastify';
import { Permission, AuditAction, AuditResource } from '@bookorbit/types';
import type { BookQuery, LibraryFileSyncProgressEvent } from '@bookorbit/types';
import type { BookQuery, BulkRenameProgressEvent, LibraryFileSyncProgressEvent } from '@bookorbit/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequireLibraryAccess } from '../../common/decorators/require-library-access.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
@@ -10,12 +10,14 @@ import { Auditable } from '../../common/decorators/auditable.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { BookQueryPipe } from '../book/pipes/book-query.pipe';
import { BookService } from '../book/book.service';
import { BulkRenamePreviewQueryDto } from './dto/bulk-rename-preview-query.dto';
import { CreateLibraryDto } from './dto/create-library.dto';
import { GrantLibraryAccessDto } from './dto/grant-library-access.dto';
import { PrescanLibraryDto } from './dto/prescan-library.dto';
import { ReorderLibrariesDto } from './dto/reorder-libraries.dto';
import { UpdateLibraryAccessDto } from './dto/update-library-access.dto';
import { UpdateLibraryDto } from './dto/update-library.dto';
import { BulkRenameService } from './bulk-rename.service';
import { LibraryService } from './library.service';
@Controller('libraries')
@@ -23,6 +25,7 @@ export class LibraryController {
constructor(
private readonly libraryService: LibraryService,
private readonly bookService: BookService,
private readonly bulkRenameService: BulkRenameService,
) {}
@Get()
@@ -219,4 +222,86 @@ export class LibraryController {
revokeAccess(@Param('libraryId', ParseIntPipe) libraryId: number, @Param('userId', ParseIntPipe) userId: number) {
return this.libraryService.revokeAccess(libraryId, userId);
}
// ── Bulk rename ────────────────────────────────────────────────────────────
@Get(':id/bulk-rename/preview')
@RequireLibraryAccess('editor')
@RequirePermission(Permission.ManageLibraries)
getBulkRenamePreview(@Param('id', ParseIntPipe) libraryId: number, @Query() query: BulkRenamePreviewQueryDto) {
return this.bulkRenameService.getPreview(libraryId, query.page, query.pageSize, query.status);
}
@Get(':id/bulk-rename/status')
@RequireLibraryAccess('editor')
@RequirePermission(Permission.ManageLibraries)
getBulkRenameStatus(@Param('id', ParseIntPipe) libraryId: number) {
return { running: this.bulkRenameService.isRunning(libraryId) };
}
@Post(':id/bulk-rename/execute')
@RequireLibraryAccess('editor')
@RequirePermission(Permission.ManageLibraries)
@Auditable({
action: AuditAction.LibraryBulkRename,
resource: AuditResource.Library,
getResourceId: (req) => parseInt(req.params['id'], 10),
description: (req) => `Bulk renamed books in library #${req.params['id']}`,
})
async executeBulkRename(@Param('id', ParseIntPipe) libraryId: number, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
let disconnected = false;
let streamStarted = false;
const handleDisconnect = () => {
disconnected = true;
};
const ensureStreamStarted = () => {
if (streamStarted) return;
streamStarted = true;
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
reply.raw.on('close', handleDisconnect);
reply.raw.on('aborted', handleDisconnect);
};
const writeEvent = (event: BulkRenameProgressEvent) => {
if (disconnected || reply.raw.writableEnded || reply.raw.destroyed) return;
ensureStreamStarted();
reply.raw.write(`data: ${JSON.stringify(event)}\n\n`);
};
try {
const summary = await this.bulkRenameService.execute(libraryId, user.id, {
onProgress: writeEvent,
isCancelled: () => disconnected || reply.raw.writableEnded || reply.raw.destroyed,
});
const doneEvent: BulkRenameProgressEvent = {
done: true,
processed: summary.processed,
succeeded: summary.succeeded,
failed: summary.failed,
skipped: summary.skipped,
};
writeEvent(doneEvent);
if (streamStarted && !reply.raw.writableEnded && !reply.raw.destroyed) {
reply.raw.end();
}
} catch (error) {
if (streamStarted && !reply.raw.writableEnded && !reply.raw.destroyed) {
reply.raw.end();
return;
}
throw error;
} finally {
if (streamStarted) {
reply.raw.off('close', handleDisconnect);
reply.raw.off('aborted', handleDisconnect);
}
}
}
}
@@ -4,16 +4,19 @@ vi.mock('../book/book.module', () => ({ BookModule: class BookModule {} }));
vi.mock('../file-write/file-write.module', () => ({ FileWriteModule: class FileWriteModule {} }));
vi.mock('../scanner/scanner.module', () => ({ ScannerModule: class ScannerModule {} }));
vi.mock('../achievement/achievement.module', () => ({ AchievementModule: class AchievementModule {} }));
vi.mock('../app-settings/app-settings.module', () => ({ AppSettingsModule: class AppSettingsModule {} }));
vi.mock('../notification/notification.module', () => ({ NotificationModule: class NotificationModule {} }));
import { LibraryController } from './library.controller';
import { LibraryModule } from './library.module';
import { LibraryRepository } from './library.repository';
import { LibraryService } from './library.service';
import { BulkRenameService } from './bulk-rename.service';
describe('LibraryModule', () => {
it('registers expected controller/providers/exports', () => {
expect(Reflect.getMetadata('controllers', LibraryModule)).toEqual([LibraryController]);
expect(Reflect.getMetadata('providers', LibraryModule)).toEqual([LibraryService, LibraryRepository]);
expect(Reflect.getMetadata('providers', LibraryModule)).toEqual([LibraryService, LibraryRepository, BulkRenameService]);
expect(Reflect.getMetadata('exports', LibraryModule)).toEqual([LibraryService, LibraryRepository]);
});
});
+5 -2
View File
@@ -1,17 +1,20 @@
import { Module, forwardRef } from '@nestjs/common';
import { AchievementModule } from '../achievement/achievement.module';
import { AppSettingsModule } from '../app-settings/app-settings.module';
import { BookModule } from '../book/book.module';
import { FileWriteModule } from '../file-write/file-write.module';
import { NotificationModule } from '../notification/notification.module';
import { ScannerModule } from '../scanner/scanner.module';
import { BulkRenameService } from './bulk-rename.service';
import { LibraryController } from './library.controller';
import { LibraryRepository } from './library.repository';
import { LibraryService } from './library.service';
@Module({
imports: [ScannerModule, AchievementModule, forwardRef(() => BookModule), FileWriteModule],
imports: [ScannerModule, AchievementModule, forwardRef(() => BookModule), FileWriteModule, forwardRef(() => NotificationModule), AppSettingsModule],
controllers: [LibraryController],
providers: [LibraryService, LibraryRepository],
providers: [LibraryService, LibraryRepository, BulkRenameService],
exports: [LibraryService, LibraryRepository],
})
export class LibraryModule {}
+26 -1
View File
@@ -87,12 +87,37 @@ describe('root-level primary files', () => {
expect(paths).toContain(join(root, 'book2.pdf'));
});
it('root-level candidate contains only the primary file', async () => {
it('root-level candidate contains only the primary file when no sidecars share its stem', async () => {
await file('book.epub');
const { candidates } = await findBookCandidates(root);
expect(candidates[0].files).toHaveLength(1);
expect(candidates[0].files[0].absolutePath).toBe(join(root, 'book.epub'));
});
it('root-level candidate includes sidecar files that share its stem', async () => {
await file('book.epub');
await file('book.opf');
await file('book.jpg');
const { candidates } = await findBookCandidates(root);
expect(candidates).toHaveLength(1);
const paths = candidates[0].files.map((f) => f.absolutePath).sort();
expect(paths).toEqual([join(root, 'book.epub'), join(root, 'book.jpg'), join(root, 'book.opf')]);
});
it('root-level: each primary pulls in only sidecars with its own stem', async () => {
await file('A.epub');
await file('A.opf');
await file('B.epub');
await file('B.jpg');
await file('orphan.txt');
const { candidates } = await findBookCandidates(root);
expect(candidates).toHaveLength(2);
const byPrimary = new Map(candidates.map((c) => [c.folderPath, c.files.map((f) => f.absolutePath).sort()]));
expect(byPrimary.get(join(root, 'A.epub'))).toEqual([join(root, 'A.epub'), join(root, 'A.opf')]);
expect(byPrimary.get(join(root, 'B.epub'))).toEqual([join(root, 'B.epub'), join(root, 'B.jpg')]);
});
});
// ── SINGLE BOOK PER FOLDER ────────────────────────────────────────────────────
+14 -2
View File
@@ -305,9 +305,21 @@ export async function findBookCandidates(
if (primaryFiles.length === 0) continue;
if (dir === libraryFolderPath) {
// Root-level: each primary file is its own book.
// Root-level: each primary file is its own book. Sidecar files (non-primary)
// are grouped with their primary by matching stem (e.g. book.epub + book.opf + book.jpg).
const sidecarsByStem = new Map<string, FileStat[]>();
for (const file of files) {
if (isPrimaryFormat(file.absolutePath)) continue;
const stem = stemOf(basename(file.absolutePath));
const existing = sidecarsByStem.get(stem);
if (existing) existing.push(file);
else sidecarsByStem.set(stem, [file]);
}
for (const file of primaryFiles) {
candidates.push({ folderPath: file.absolutePath, files: [file] });
const stem = stemOf(basename(file.absolutePath));
const sidecars = sidecarsByStem.get(stem) ?? [];
sidecarsByStem.delete(stem);
candidates.push({ folderPath: file.absolutePath, files: [file, ...sidecars] });
}
continue;
}
@@ -0,0 +1,189 @@
import archiver from 'archiver';
import { randomUUID } from 'crypto';
import { createWriteStream } from 'fs';
import { mkdir, writeFile } from 'fs/promises';
import { PDFDocument } from 'pdf-lib';
import { dirname, join } from 'path';
export async function createEpubFile(absolutePath: string, title = 'Fixture Title'): Promise<void> {
await mkdir(dirname(absolutePath), { recursive: true });
const uid = `urn:uuid:${randomUUID()}`;
const containerXml = `<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="OPS/content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>`;
const opfXml = `<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" unique-identifier="uid" xmlns="http://www.idpf.org/2007/opf">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="uid">${uid}</dc:identifier>
<dc:title>${escapeXml(title)}</dc:title>
<dc:language>en</dc:language>
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
</metadata>
<manifest>
<item id="ch1" href="chapter.xhtml" media-type="application/xhtml+xml"/>
</manifest>
<spine>
<itemref idref="ch1"/>
</spine>
</package>`;
const chapterXml = `<html xmlns="http://www.w3.org/1999/xhtml"><head><title>${escapeXml(title)}</title></head><body><p>test content</p></body></html>`;
await writeZip(absolutePath, [
{ path: 'mimetype', content: 'application/epub+zip', store: true },
{ path: 'META-INF/container.xml', content: containerXml },
{ path: 'OPS/content.opf', content: opfXml },
{ path: 'OPS/chapter.xhtml', content: chapterXml },
]);
}
export async function createPdfFile(absolutePath: string, title = 'Fixture PDF'): Promise<void> {
await mkdir(dirname(absolutePath), { recursive: true });
const pdf = await PDFDocument.create();
const page = pdf.addPage([360, 240]);
page.drawText(title);
pdf.setTitle(title);
const bytes = await pdf.save();
await writeFile(absolutePath, Buffer.from(bytes));
}
export async function createDummyFile(absolutePath: string, content = 'placeholder'): Promise<void> {
await mkdir(dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content);
}
function escapeXml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
interface ZipEntry {
path: string;
content: string | Buffer;
store?: boolean;
}
async function writeZip(outPath: string, entries: ZipEntry[]): Promise<void> {
const output = createWriteStream(outPath);
const archive = archiver('zip', { zlib: { level: 6 } });
await new Promise<void>((resolve, reject) => {
output.on('close', resolve);
output.on('error', reject);
archive.on('error', reject);
archive.pipe(output);
for (const entry of entries) {
archive.append(typeof entry.content === 'string' ? Buffer.from(entry.content, 'utf8') : entry.content, {
name: entry.path,
store: entry.store ?? false,
});
}
void archive.finalize();
});
}
// ── Factory helpers ──────────────────────────────────────────────────────────
export async function buildBookPerFileFlat(libraryRoot: string): Promise<Array<{ relPath: string; stem: string }>> {
const books = [
{ relPath: 'old-book-alpha.epub', stem: 'old-book-alpha' },
{ relPath: 'old-book-beta.epub', stem: 'old-book-beta' },
{ relPath: 'old-book-gamma.epub', stem: 'old-book-gamma' },
{ relPath: 'old-book-delta.epub', stem: 'old-book-delta' },
];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.relPath), book.stem);
}
return books;
}
export async function buildBookPerFileWithSubdirs(libraryRoot: string): Promise<Array<{ relPath: string; stem: string }>> {
const books = [
{ relPath: 'OldAuthorA/title-one.epub', stem: 'title-one' },
{ relPath: 'OldAuthorB/deep/nested/title-two.epub', stem: 'title-two' },
{ relPath: 'title-three-root.epub', stem: 'title-three-root' },
{ relPath: 'OldAuthorC/title-four.pdf', stem: 'title-four' },
];
for (const book of books) {
if (book.relPath.endsWith('.pdf')) {
await createPdfFile(join(libraryRoot, book.relPath), book.stem);
} else {
await createEpubFile(join(libraryRoot, book.relPath), book.stem);
}
}
return books;
}
export async function buildBookPerFolderFlat(libraryRoot: string): Promise<Array<{ relPath: string; folderName: string }>> {
const books = [
{ relPath: 'FolderA/book.epub', folderName: 'FolderA' },
{ relPath: 'FolderB/book.epub', folderName: 'FolderB' },
{ relPath: 'FolderC/book.epub', folderName: 'FolderC' },
];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.relPath), book.folderName);
}
return books;
}
export async function buildBookPerFolderNested(libraryRoot: string): Promise<Array<{ relPath: string; folderName: string }>> {
const books = [
{ relPath: 'AuthorX/SeriesY/vol01/book.epub', folderName: 'vol01' },
{ relPath: 'AuthorX/standalone/book.epub', folderName: 'standalone' },
{ relPath: 'AuthorZ/TitleZ/TitleZ.epub', folderName: 'TitleZ' },
];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.relPath), book.folderName);
}
return books;
}
export async function buildBookPerFolderMultiFile(
libraryRoot: string,
): Promise<Array<{ primaryRelPath: string; extraRelPaths: string[]; folderName: string }>> {
const books = [
{
primaryRelPath: 'MultiFileBook/book.epub',
extraRelPaths: ['MultiFileBook/book.pdf', 'MultiFileBook/cover.jpg'],
folderName: 'MultiFileBook',
},
];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.primaryRelPath), book.folderName);
await createPdfFile(join(libraryRoot, book.extraRelPaths[0]), `${book.folderName}-pdf`);
await createDummyFile(join(libraryRoot, book.extraRelPaths[1]), 'JPEG_PLACEHOLDER');
}
return books;
}
export async function buildCollisionPair(libraryRoot: string): Promise<Array<{ relPath: string }>> {
const books = [{ relPath: 'collision-src-a.epub' }, { relPath: 'collision-src-b.epub' }];
for (const book of books) {
await createEpubFile(join(libraryRoot, book.relPath), book.relPath.replace('.epub', ''));
}
return books;
}
export async function buildSpecialCharBook(libraryRoot: string): Promise<string> {
const relPath = 'special-chars-src.epub';
await createEpubFile(join(libraryRoot, relPath), 'placeholder');
return relPath;
}
@@ -0,0 +1,407 @@
import { randomUUID } from 'crypto';
import { hash } from 'bcryptjs';
import fastifyCookie from '@fastify/cookie';
import { Test } from '@nestjs/testing';
import { ValidationPipe } from '@nestjs/common';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { mkdir } from 'fs/promises';
import { asc, eq } from 'drizzle-orm';
import type { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DEFAULT_FORMAT_PRIORITY } from '@bookorbit/types';
import type { BulkRenamePreviewPage, BulkRenameProgressEvent, BulkRenameStatus } from '@bookorbit/types';
import { AppModule } from '../../../src/app.module';
import { DB } from '../../../src/db';
import * as schema from '../../../src/db/schema';
import { bookFiles, books, libraries, libraryFolders, scanJobs } from '../../../src/db/schema';
import { GlobalExceptionFilter } from '../../../src/common/filters/http-exception.filter';
export type Db = NodePgDatabase<typeof schema>;
const ADMIN_SETUP_DTO = {
username: 'file-rename-e2e-admin',
name: 'File Rename E2E Admin',
email: 'file-rename-e2e-admin@example.com',
password: 'FileRenameE2E123!',
};
interface EnvSnapshot {
appDataPath: string | undefined;
fileWriteDebounceMs: string | undefined;
booksPath: string | undefined;
}
export interface FileRenameE2EContext {
app: NestFastifyApplication;
db: Db;
adminToken: string;
booksRoot: string;
envSnapshot: EnvSnapshot;
}
export interface CreatedLibrary {
libraryId: number;
libraryFolderId: number;
folderPath: string;
}
export interface LocatedBook {
bookId: number;
primaryFileId: number | null;
absolutePath: string;
relPath: string | null;
folderPath: string;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function createFileRenameE2EContext(booksRoot: string): Promise<FileRenameE2EContext> {
await mkdir(booksRoot, { recursive: true });
const envSnapshot: EnvSnapshot = {
appDataPath: process.env.APP_DATA_PATH,
fileWriteDebounceMs: process.env.FILE_WRITE_DEBOUNCE_MS,
booksPath: process.env.BOOKS_PATH,
};
process.env.APP_DATA_PATH = booksRoot;
process.env.BOOKS_PATH = booksRoot;
// Large debounce prevents auto-renames from racing with bulk-rename test steps
process.env.FILE_WRITE_DEBOUNCE_MS = '300000';
const moduleFixture = await Test.createTestingModule({ imports: [AppModule] }).compile();
const app = moduleFixture.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
app.setGlobalPrefix('api/v1');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
app.useGlobalFilters(new GlobalExceptionFilter());
await app.register(fastifyCookie as never);
await app.init();
await app.getHttpAdapter().getInstance().ready();
const db = app.get<Db>(DB);
const adminToken = await getAdminToken(app, db);
return { app, db, adminToken, booksRoot, envSnapshot };
}
export async function closeFileRenameE2EContext(ctx: FileRenameE2EContext): Promise<void> {
await ctx.app.close();
restoreEnv(ctx.envSnapshot);
}
function restoreEnv(snapshot: EnvSnapshot): void {
if (snapshot.appDataPath !== undefined) process.env.APP_DATA_PATH = snapshot.appDataPath;
else delete process.env.APP_DATA_PATH;
if (snapshot.fileWriteDebounceMs !== undefined) process.env.FILE_WRITE_DEBOUNCE_MS = snapshot.fileWriteDebounceMs;
else delete process.env.FILE_WRITE_DEBOUNCE_MS;
if (snapshot.booksPath !== undefined) process.env.BOOKS_PATH = snapshot.booksPath;
else delete process.env.BOOKS_PATH;
}
export async function createLibrary(
ctx: FileRenameE2EContext,
options: {
mode?: 'book_per_file' | 'book_per_folder';
name?: string;
fileRenameEnabled?: boolean;
fileNamingPattern?: string | null;
} = {},
): Promise<CreatedLibrary> {
const folderPath = `${ctx.booksRoot}/library-${randomUUID()}`;
await mkdir(folderPath, { recursive: true });
const [library] = await ctx.db
.insert(libraries)
.values({
name: options.name ?? `file-rename-${randomUUID()}`,
icon: '📚',
watch: false,
organizationMode: options.mode ?? 'book_per_file',
allowedFormats: [],
excludePatterns: [],
formatPriority: [...DEFAULT_FORMAT_PRIORITY],
fileWriteEnabled: false,
fileWriteWriteCover: false,
fileWriteEpubEnabled: false,
fileWritePdfEnabled: false,
fileWriteCbxEnabled: false,
fileRenameEnabled: options.fileRenameEnabled ?? true,
fileNamingPattern: options.fileNamingPattern ?? null,
})
.returning({ id: libraries.id });
const [libraryFolder] = await ctx.db
.insert(libraryFolders)
.values({ libraryId: library.id, path: folderPath })
.returning({ id: libraryFolders.id });
return { libraryId: library.id, libraryFolderId: libraryFolder.id, folderPath };
}
export async function triggerAndWaitForScan(ctx: FileRenameE2EContext, libraryId: number, timeoutMs = 30_000): Promise<void> {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/scanner/libraries/${libraryId}/scan`,
headers: authHeader(ctx.adminToken),
});
if (response.statusCode !== 202) {
throw new Error(`Scan endpoint returned ${response.statusCode}: ${response.body}`);
}
const body = response.json() as { jobId?: number };
if (!body.jobId) throw new Error(`Scan returned no jobId: ${response.body}`);
await waitForScanJob(ctx.db, body.jobId, timeoutMs);
}
async function waitForScanJob(db: Db, jobId: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const [job] = await db.select().from(scanJobs).where(eq(scanJobs.id, jobId)).limit(1);
if (!job) throw new Error(`Scan job ${jobId} not found`);
if (job.status === 'completed') return;
if (job.status === 'failed') throw new Error(`Scan job failed: ${job.errorMessage ?? 'unknown'}`);
await sleep(100);
}
throw new Error(`Timed out waiting for scan job ${jobId}`);
}
export async function findBookByRelPath(ctx: FileRenameE2EContext, libraryId: number, relPath: string): Promise<LocatedBook> {
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
const [row] = await ctx.db
.select({
bookId: books.id,
primaryFileId: books.primaryFileId,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
folderPath: books.folderPath,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(bookFiles.relPath, relPath))
.limit(1);
if (row && row.absolutePath.includes(libraryId.toString().padStart(1))) {
return row;
}
// fallback: search by just relPath without lib filter for simplicity
const [anyRow] = await ctx.db
.select({
bookId: books.id,
primaryFileId: books.primaryFileId,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
folderPath: books.folderPath,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(bookFiles.relPath, relPath))
.limit(1);
if (anyRow) return anyRow;
await sleep(100);
}
throw new Error(`Book with relPath "${relPath}" not found after waiting`);
}
export async function findAllBooksInLibrary(ctx: FileRenameE2EContext, libraryId: number): Promise<LocatedBook[]> {
const rows = await ctx.db
.select({
bookId: books.id,
primaryFileId: books.primaryFileId,
absolutePath: bookFiles.absolutePath,
relPath: bookFiles.relPath,
folderPath: books.folderPath,
})
.from(bookFiles)
.innerJoin(books, eq(books.id, bookFiles.bookId))
.where(eq(books.libraryId, libraryId))
.orderBy(asc(books.id));
return rows;
}
export function authHeader(token: string): Record<string, string> {
return { authorization: `Bearer ${token}` };
}
export async function setBookMetadata(
ctx: FileRenameE2EContext,
bookId: number,
metadata: {
title?: string | null;
subtitle?: string | null;
publisher?: string | null;
publishedYear?: number | null;
seriesName?: string | null;
seriesIndex?: number | null;
language?: string | null;
isbn13?: string | null;
authors?: string[];
},
): Promise<void> {
const response = await ctx.app.inject({
method: 'PATCH',
url: `/api/v1/books/${bookId}/metadata`,
headers: authHeader(ctx.adminToken),
payload: {
...(metadata.title !== undefined && { title: metadata.title }),
...(metadata.subtitle !== undefined && { subtitle: metadata.subtitle }),
...(metadata.publisher !== undefined && { publisher: metadata.publisher }),
...(metadata.publishedYear !== undefined && { publishedYear: metadata.publishedYear }),
...(metadata.seriesName !== undefined && { seriesName: metadata.seriesName }),
...(metadata.seriesIndex !== undefined && { seriesIndex: metadata.seriesIndex }),
...(metadata.language !== undefined && { language: metadata.language }),
...(metadata.isbn13 !== undefined && { isbn13: metadata.isbn13 }),
...(metadata.authors !== undefined && { authors: metadata.authors }),
},
});
if (response.statusCode !== 200) {
throw new Error(`PATCH metadata returned ${response.statusCode}: ${response.body}`);
}
}
export async function getBulkRenamePreview(
ctx: FileRenameE2EContext,
libraryId: number,
options: { page?: number; pageSize?: number; status?: BulkRenameStatus } = {},
): Promise<BulkRenamePreviewPage> {
const params = new URLSearchParams();
params.set('page', String(options.page ?? 1));
params.set('pageSize', String(options.pageSize ?? 100));
if (options.status) params.set('status', options.status);
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/libraries/${libraryId}/bulk-rename/preview?${params.toString()}`,
headers: authHeader(ctx.adminToken),
});
if (response.statusCode !== 200) {
throw new Error(`Preview returned ${response.statusCode}: ${response.body}`);
}
return response.json() as BulkRenamePreviewPage;
}
export async function getBulkRenameStatus(ctx: FileRenameE2EContext, libraryId: number): Promise<{ running: boolean }> {
const response = await ctx.app.inject({
method: 'GET',
url: `/api/v1/libraries/${libraryId}/bulk-rename/status`,
headers: authHeader(ctx.adminToken),
});
if (response.statusCode !== 200) {
throw new Error(`Status returned ${response.statusCode}: ${response.body}`);
}
return response.json() as { running: boolean };
}
export interface BulkRenameExecuteResult {
statusCode: number;
events: BulkRenameProgressEvent[];
rawBody: string;
error?: string;
}
export async function executeBulkRename(ctx: FileRenameE2EContext, libraryId: number): Promise<BulkRenameExecuteResult> {
const response = await ctx.app.inject({
method: 'POST',
url: `/api/v1/libraries/${libraryId}/bulk-rename/execute`,
headers: authHeader(ctx.adminToken),
});
const rawBody = response.body;
const events: BulkRenameProgressEvent[] = [];
if (response.statusCode === 200) {
for (const line of rawBody.split('\n')) {
if (line.startsWith('data: ')) {
try {
events.push(JSON.parse(line.slice(6)) as BulkRenameProgressEvent);
} catch {
// skip malformed lines
}
}
}
}
return { statusCode: response.statusCode, events, rawBody };
}
export async function refreshPreviewCache(ctx: FileRenameE2EContext, libraryId: number): Promise<void> {
// Wait a moment to ensure the cache TTL doesn't interfere (cache is 60s)
// We invalidate by patching library settings to force a fresh compute
await ctx.db.update(libraries).set({ updatedAt: new Date() }).where(eq(libraries.id, libraryId));
}
async function getAdminToken(app: NestFastifyApplication, db: Db): Promise<string> {
const response = await app.inject({
method: 'POST',
url: '/api/v1/auth/setup',
payload: ADMIN_SETUP_DTO,
});
if (response.statusCode === 409) {
const loginResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username: ADMIN_SETUP_DTO.username, password: ADMIN_SETUP_DTO.password },
});
if (loginResponse.statusCode === 200) {
const body = loginResponse.json() as { accessToken?: string };
if (body.accessToken) return body.accessToken;
}
const suffix = randomUUID().replaceAll('-', '');
const fallbackUsername = `file-rename-admin-${suffix}`;
const passwordHash = await hash(ADMIN_SETUP_DTO.password, 12);
await db.insert(schema.users).values({
username: fallbackUsername,
name: 'File Rename E2E Admin (fallback)',
email: `${fallbackUsername}@example.com`,
passwordHash,
isSuperuser: true,
isDefaultPassword: false,
provisioningMethod: 'local',
});
const fallbackResponse = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { username: fallbackUsername, password: ADMIN_SETUP_DTO.password },
});
if (fallbackResponse.statusCode !== 200) {
throw new Error(`Fallback admin login failed: ${fallbackResponse.statusCode} ${fallbackResponse.body}`);
}
const body = fallbackResponse.json() as { accessToken?: string };
if (!body.accessToken) throw new Error('Fallback login returned no accessToken');
return body.accessToken;
}
if (response.statusCode !== 201) {
throw new Error(`Setup returned ${response.statusCode}: ${response.body}`);
}
const body = response.json() as { accessToken?: string };
if (!body.accessToken) throw new Error('Setup returned no accessToken');
return body.accessToken;
}
File diff suppressed because it is too large Load Diff