feat(server): add Audiobookshelf and Calibre-Web Automated migration sources

Extend the migration module beyond Booklore and Grimmory so an existing Audiobookshelf or
Calibre-Web Automated library can be moved into BookOrbit without rebuilding user state by
hand. Audiobookshelf supports both a live API connection and an offline backup file, while
Calibre-Web Automated reads a stopped snapshot of the app and metadata databases.

Match source books by ASIN in addition to ISBN, and batch the ISBN, ASIN, file hash, file
path, and title/author lookups so planning a large library issues a bounded number of
queries rather than one per book. Route the batched author comparison through the shared
accent-insensitive predicate, which now accepts a SQL fragment so a per-row pattern column
is matched the same way a bound literal is.

Extract the shared source connection form into MigrationSourceFields so the modal and the
settings page stay in sync as source types are added, and allow a mapping to skip a source
user instead of forcing every account to be imported.

Closes #986
This commit is contained in:
neon
2026-08-16 05:15:34 +00:00
parent 5527852b4b
commit ec4072e4be
87 changed files with 13988 additions and 698 deletions
@@ -96,7 +96,7 @@ function sourceState(overrides: Record<string, unknown> = {}) {
}
function setupDefaults() {
migrationApiMocks.listSupportedSourceTypes.mockResolvedValue(['booklore', 'grimmory'])
migrationApiMocks.listSupportedSourceTypes.mockResolvedValue(['booklore', 'grimmory', 'audiobookshelf', 'calibre_web_automated'])
migrationApiMocks.listTargetUsers.mockResolvedValue([{ id: 1, username: 'neon', name: 'Neon', email: null }])
migrationApiMocks.listTargetLibraryFolders.mockResolvedValue([])
migrationApiMocks.suggestUserMappings.mockResolvedValue({ sourceId: 5, generatedAt: '2026-06-17T00:00:00.000Z', suggestions: [] })
@@ -173,4 +173,258 @@ describe('MigrationModal reset setup', () => {
expect(wrapper.text()).not.toContain('Reset Setup')
})
it('renders ASIN match and unresolved labels in a dry-run plan', async () => {
migrationApiMocks.getWorkflowState.mockResolvedValueOnce(
sourceState({
profile: {
id: 6,
sourceId: 5,
name: 'Mappings',
userMappings: [],
pathMappings: [],
scope: {},
createdAt: '2026-06-18T00:00:00.000Z',
updatedAt: '2026-06-18T00:00:00.000Z',
},
plan: {
id: 7,
sourceId: 5,
profileId: 6,
plan: {
duplicateBookMatches: [
{
targetBookId: 10,
sourceBookIds: ['asin-source', 'hash-source'],
strategies: ['asin', 'file_hash'],
sourceCandidates: [
{
sourceBookId: 'asin-source',
title: 'ASIN Candidate',
author: 'Author One',
filePath: null,
strategy: 'asin',
},
{
sourceBookId: 'hash-source',
title: 'Hash Candidate',
author: 'Author Two',
filePath: null,
strategy: 'file_hash',
},
],
reason: 'duplicate_target_match',
},
],
},
summary: {
matchedBooks: 0,
unresolvedBooks: 1,
duplicateBookMatches: 1,
unresolvedByReason: { no_asin_match: 1, ambiguous_asin_match: 1 },
status: 'blocked',
},
createdAt: '2026-06-17T00:00:00.000Z',
updatedAt: '2026-06-17T00:00:00.000Z',
},
}),
)
const wrapper = mountModal()
await flushPromises()
expect(wrapper.text()).toContain('ASIN did not match any book in this library')
expect(wrapper.text()).toContain('Multiple books matched the same ASIN')
expect(wrapper.text()).toContain('Matched by ASIN')
expect(wrapper.text()).toContain('Matched by file hash')
})
it('saves an explicitly skipped source user as a null target mapping', async () => {
migrationApiMocks.getWorkflowState.mockResolvedValue(sourceState())
migrationApiMocks.suggestUserMappings.mockResolvedValue({
sourceId: 5,
generatedAt: '2026-06-17T00:00:00.000Z',
suggestions: [
{
sourceUserId: 'maya',
username: 'maya',
name: null,
email: null,
suggestedTargetUserId: 1,
confidence: 'high',
candidates: [],
},
{
sourceUserId: 'lina',
username: 'lina',
name: null,
email: null,
suggestedTargetUserId: null,
confidence: null,
candidates: [],
},
],
})
migrationApiMocks.createProfile.mockResolvedValue({})
const wrapper = mountModal()
await flushPromises()
const skipCheckboxes = wrapper.findAll('input[type="checkbox"]')
expect(skipCheckboxes).toHaveLength(2)
await skipCheckboxes[1]!.setValue(true)
await findButton(wrapper, 'Save Mappings').trigger('click')
await flushPromises()
expect(migrationApiMocks.createProfile).toHaveBeenCalledWith(
expect.objectContaining({
userMappings: [
{ sourceUserId: 'maya', targetUserId: 1 },
{ sourceUserId: 'lina', targetUserId: null },
],
}),
)
})
it('hydrates a saved Audiobookshelf API source with its redacted token', async () => {
migrationApiMocks.getWorkflowState.mockResolvedValueOnce(
sourceState({
source: {
id: 5,
type: 'audiobookshelf',
name: 'Living room server',
connectionConfig: {
mode: 'api',
baseUrl: 'http://abs.local:13378',
apiToken: '********',
allowPrivateNetwork: true,
},
capabilities: null,
lastValidatedAt: null,
createdAt: '2026-06-17T00:00:00.000Z',
updatedAt: '2026-06-17T00:00:00.000Z',
},
}),
)
const wrapper = mountModal()
await flushPromises()
await findButton(wrapper, 'Source Connection').trigger('click')
expect(wrapper.get('input[type="url"]').element).toHaveProperty('value', 'http://abs.local:13378')
expect(wrapper.get('input[data-lpignore="true"]').element).toHaveProperty('value', '********')
expect(wrapper.get('input[type="checkbox"]').element).toHaveProperty('checked', true)
expect(wrapper.find('input[placeholder="127.0.0.1"]').exists()).toBe(false)
})
it('tests an Audiobookshelf backup using only backup-mode fields', async () => {
migrationApiMocks.getWorkflowState.mockResolvedValueOnce({ active: null, hasActiveRun: false })
migrationApiMocks.testSource.mockResolvedValueOnce({ ok: true, warnings: [] })
const wrapper = mountModal()
await flushPromises()
await wrapper.get('select').setValue('audiobookshelf')
await wrapper.get('input[type="radio"][value="backup"]').setValue()
await wrapper.get('input[placeholder="/imports/audiobookshelf/backup.audiobookshelf"]').setValue('/imports/abs.audiobookshelf')
await findButton(wrapper, 'Test Connection').trigger('click')
await flushPromises()
expect(migrationApiMocks.testSource).toHaveBeenCalledWith({
type: 'audiobookshelf',
connectionConfig: {
mode: 'backup',
backupPath: '/imports/abs.audiobookshelf',
},
})
})
it('rejects an Audiobookshelf API URL with a path before making a request', async () => {
migrationApiMocks.getWorkflowState.mockResolvedValueOnce({ active: null, hasActiveRun: false })
const wrapper = mountModal()
await flushPromises()
await wrapper.get('select').setValue('audiobookshelf')
await wrapper.get('input[type="url"]').setValue('https://abs.example.test/api')
await wrapper.get('input[data-lpignore="true"]').setValue('admin-token')
await findButton(wrapper, 'Test Connection').trigger('click')
expect(migrationApiMocks.testSource).not.toHaveBeenCalled()
expect(toastMocks.error).toHaveBeenCalledWith('Audiobookshelf URL must be a clean HTTP or HTTPS origin without a path')
})
it('tests a CWA snapshot using exactly its fixed mode and two paths', async () => {
migrationApiMocks.getWorkflowState.mockResolvedValueOnce({ active: null, hasActiveRun: false })
migrationApiMocks.testSource.mockResolvedValueOnce({ ok: true, warnings: [] })
const wrapper = mountModal()
await flushPromises()
await wrapper.get('select').setValue('calibre_web_automated')
await wrapper.get('input[placeholder="/imports/calibre-web-automated/app.db"]').setValue(' /imports/cwa/app.db ')
await wrapper.get('input[placeholder="/imports/calibre-web-automated/metadata.db"]').setValue(' /imports/cwa/metadata.db ')
await findButton(wrapper, 'Test Connection').trigger('click')
await flushPromises()
expect(migrationApiMocks.testSource).toHaveBeenCalledWith({
type: 'calibre_web_automated',
connectionConfig: {
mode: 'snapshot',
appDatabasePath: '/imports/cwa/app.db',
metadataDatabasePath: '/imports/cwa/metadata.db',
},
})
})
it('validates both CWA snapshot paths before making a request', async () => {
migrationApiMocks.getWorkflowState.mockResolvedValueOnce({ active: null, hasActiveRun: false })
const wrapper = mountModal()
await flushPromises()
await wrapper.get('select').setValue('calibre_web_automated')
await findButton(wrapper, 'Test Connection').trigger('click')
expect(toastMocks.error).toHaveBeenLastCalledWith('The Calibre-Web Automated app.db snapshot path is required')
await wrapper.get('input[placeholder="/imports/calibre-web-automated/app.db"]').setValue('relative/app.db')
await findButton(wrapper, 'Test Connection').trigger('click')
expect(toastMocks.error).toHaveBeenLastCalledWith('The Calibre-Web Automated app.db snapshot path must be absolute')
await wrapper.get('input[placeholder="/imports/calibre-web-automated/app.db"]').setValue('/imports/cwa/app.db')
await wrapper.get('input[placeholder="/imports/calibre-web-automated/metadata.db"]').setValue('relative/metadata.db')
await findButton(wrapper, 'Test Connection').trigger('click')
expect(migrationApiMocks.testSource).not.toHaveBeenCalled()
expect(toastMocks.error).toHaveBeenLastCalledWith('The Calibre metadata.db snapshot path must be absolute')
})
it('hydrates a saved CWA source with both snapshot paths', async () => {
migrationApiMocks.getWorkflowState.mockResolvedValueOnce(
sourceState({
source: {
id: 5,
type: 'calibre_web_automated',
name: 'Stopped CWA',
connectionConfig: {
mode: 'snapshot',
appDatabasePath: '/imports/cwa/app.db',
metadataDatabasePath: '/imports/cwa/metadata.db',
},
capabilities: null,
lastValidatedAt: null,
createdAt: '2026-08-14T00:00:00.000Z',
updatedAt: '2026-08-14T00:00:00.000Z',
},
}),
)
const wrapper = mountModal()
await flushPromises()
await findButton(wrapper, 'Source Connection').trigger('click')
expect(wrapper.get('input[placeholder="/imports/calibre-web-automated/app.db"]').element).toHaveProperty('value', '/imports/cwa/app.db')
expect(wrapper.get('input[placeholder="/imports/calibre-web-automated/metadata.db"]').element).toHaveProperty('value', '/imports/cwa/metadata.db')
expect(wrapper.find('input[placeholder="127.0.0.1"]').exists()).toBe(false)
})
})
@@ -3,9 +3,10 @@ import { Button } from '@/components/ui/button'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '@/i18n/formatters'
import { Eye, EyeOff, Loader2, X, ChevronLeft, ChevronRight, Check, AlertCircle, RotateCcw } from '@lucide/vue'
import { Loader2, X, ChevronLeft, ChevronRight, Check, AlertCircle, RotateCcw } from '@lucide/vue'
import { toast } from 'vue-sonner'
import SearchableUserSelect from './SearchableUserSelect.vue'
import MigrationSourceFields from './MigrationSourceFields.vue'
import { api } from '@/lib/api'
import {
cancelRun,
@@ -40,7 +41,13 @@ import {
import { useMigrationPolling } from '@/features/migration/composables/useMigrationPolling'
import { useMigrationProgress } from '@/features/migration/composables/useMigrationProgress'
import { useMigrationSetupReset } from '@/features/migration/composables/useMigrationSetupReset'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
import {
buildMigrationSourceConnectionConfig,
createMigrationSourceDraft,
hydrateMigrationSourceDraft,
validateMigrationSourceDraft,
type MigrationSourceValidationError,
} from '@/features/migration/lib/migration-source-config'
interface StepDefinition {
label: string
@@ -64,6 +71,7 @@ interface UserMappingDraft {
sourceUserId: string
username: string
targetUserId: number | null
skip: boolean
}
interface PathMappingDraft {
@@ -95,19 +103,7 @@ const suggestionsLoadedAt = ref<string | null>(null)
const userMappings = ref<UserMappingDraft[]>([])
const pathMappings = ref<PathMappingDraft[]>([{ sourcePrefix: '', targetPrefix: '' }])
const DEFAULT_SOURCE_DRAFT = {
type: 'booklore',
name: 'Booklore',
host: '',
port: 3306,
user: '',
password: '',
database: '',
ssl: false,
mediaRootPath: '',
}
const sourceDraft = reactive({ ...DEFAULT_SOURCE_DRAFT })
const sourceDraft = reactive(createMigrationSourceDraft())
const busy = reactive({
testingSource: false,
@@ -247,6 +243,7 @@ watch(activeStepIndex, (newVal) => {
watch(
() => [
sourceDraft.type,
sourceDraft.mediaRootPath,
sourceDraft.host,
sourceDraft.port,
@@ -254,6 +251,8 @@ watch(
sourceDraft.password,
sourceDraft.database,
sourceDraft.ssl,
sourceDraft.cwaAppDatabasePath,
sourceDraft.cwaMetadataDatabasePath,
],
() => {
mediaPathTestState.value = 'idle'
@@ -290,7 +289,7 @@ const stepTitles = computed(() => [
t('migration.steps.report.title'),
])
const stepSubtitles = computed(() => [
t('migration.steps.source.subtitle'),
t('migration.steps.source.genericSubtitle'),
t('migration.steps.mappings.subtitle'),
t('migration.steps.dryRun.subtitle'),
t('migration.steps.migration.subtitle'),
@@ -364,7 +363,8 @@ const duplicateTargetBookLabels = ref<Map<number, string>>(new Map())
const duplicateGroupsRemaining = computed(() => duplicateMatches.value.filter((dup) => !duplicateResolutions.value.has(dup.targetBookId)).length)
const STRATEGY_PRIORITY: Record<string, number> = {
isbn: 40,
isbn: 50,
asin: 40,
file_hash: 30,
path_mapping: 20,
title_author: 10,
@@ -392,7 +392,7 @@ function sourceBookSecondary(dup: DuplicateBookMatch, sourceId: string): string
if (author) return t('migration.duplicates.byAuthor', { author, id: sourceId })
const filePath = candidate?.filePath?.trim()
if (filePath) return t('migration.duplicates.byFilePath', { filePath, id: sourceId })
return t('migration.duplicates.bookloreSourceId', { id: sourceId })
return t('migration.duplicates.sourceRecordId', { id: sourceId })
}
function recommendedSourceBookId(dup: DuplicateBookMatch): string | null {
@@ -669,23 +669,11 @@ function hydrateSourceDraft() {
resetSourceDraft()
return
}
sourceDraft.type = currentSource.type || sourceDraft.type
sourceDraft.name = currentSource.name || sourceDraft.name
const cfg = asRecord(currentSource.connectionConfig)
sourceDraft.host = asString(cfg.host) ?? sourceDraft.host
sourceDraft.port = asNumber(cfg.port) ?? sourceDraft.port
sourceDraft.user = asString(cfg.user) ?? sourceDraft.user
sourceDraft.password = asString(cfg.password) ?? ''
sourceDraft.database = asString(cfg.database) ?? sourceDraft.database
sourceDraft.ssl = asBoolean(cfg.ssl) ?? sourceDraft.ssl
sourceDraft.mediaRootPath = asString(cfg.mediaRootPath) ?? ''
hydrateMigrationSourceDraft(sourceDraft, currentSource)
}
function resetSourceDraft() {
Object.assign(sourceDraft, {
...DEFAULT_SOURCE_DRAFT,
type: supportedTypes.value[0] ?? DEFAULT_SOURCE_DRAFT.type,
})
Object.assign(sourceDraft, createMigrationSourceDraft(supportedTypes.value[0] ?? 'booklore'))
mediaPathTestState.value = 'idle'
mediaPathTestMessage.value = null
}
@@ -705,6 +693,7 @@ function hydrateUserMappingsFromProfile() {
sourceUserId: row.sourceUserId,
username: row.sourceUserId,
targetUserId: row.targetUserId,
skip: row.targetUserId === null,
}))
}
@@ -720,7 +709,10 @@ async function hydrateUserMappingsFromSuggestions(showSuccessToast: boolean) {
userMappings.value = response.suggestions.map((row) => ({
sourceUserId: row.sourceUserId,
username: row.username,
targetUserId: savedMappings.get(row.sourceUserId) ?? row.suggestedTargetUserId ?? fallbackSingleTargetUserId,
targetUserId: savedMappings.has(row.sourceUserId)
? (savedMappings.get(row.sourceUserId) ?? null)
: (row.suggestedTargetUserId ?? fallbackSingleTargetUserId),
skip: savedMappings.has(row.sourceUserId) && savedMappings.get(row.sourceUserId) === null,
}))
if (showSuccessToast) {
if (userMappings.value.length === 0) toast.warning(t('migration.toast.noSourceUsers'))
@@ -734,19 +726,28 @@ async function hydrateUserMappingsFromSuggestions(showSuccessToast: boolean) {
}
function buildSourceConnectionConfig() {
return {
host: sourceDraft.host.trim(),
port: sourceDraft.port,
user: sourceDraft.user.trim(),
password: sourceDraft.password,
database: sourceDraft.database.trim(),
ssl: sourceDraft.ssl,
mediaRootPath: sourceDraft.mediaRootPath.trim(),
}
return buildMigrationSourceConnectionConfig(sourceDraft)
}
function hasValidSourceDraft() {
return !!sourceDraft.name.trim() && !!sourceDraft.host.trim() && !!sourceDraft.user.trim() && !!sourceDraft.database.trim()
return validateMigrationSourceDraft(sourceDraft) == null
}
function sourceDraftValidationMessage() {
const error = validateMigrationSourceDraft(sourceDraft)
const keyByError: Record<MigrationSourceValidationError, string> = {
nameRequired: 'migration.source.validation.nameRequired',
databaseFieldsRequired: 'migration.source.validation.databaseFieldsRequired',
apiFieldsRequired: 'migration.source.validation.apiFieldsRequired',
baseUrlInvalid: 'migration.source.validation.baseUrlInvalid',
backupPathRequired: 'migration.source.validation.backupPathRequired',
backupPathAbsolute: 'migration.source.validation.backupPathAbsolute',
cwaAppDatabasePathRequired: 'migration.source.validation.cwaAppDatabasePathRequired',
cwaAppDatabasePathAbsolute: 'migration.source.validation.cwaAppDatabasePathAbsolute',
cwaMetadataDatabasePathRequired: 'migration.source.validation.cwaMetadataDatabasePathRequired',
cwaMetadataDatabasePathAbsolute: 'migration.source.validation.cwaMetadataDatabasePathAbsolute',
}
return error ? t(keyByError[error]) : t('migration.toast.sourceFieldsRequired')
}
function extractWarningStrings(result: Record<string, unknown>): string[] {
@@ -759,7 +760,7 @@ function extractMediaPathWarnings(result: Record<string, unknown>): string[] {
async function onTestSource() {
if (!hasValidSourceDraft()) {
toast.error(t('migration.toast.sourceFieldsRequired'))
toast.error(sourceDraftValidationMessage())
return
}
busy.testingSource = true
@@ -795,7 +796,7 @@ async function onTestMediaPath() {
return
}
if (!hasValidSourceDraft()) {
toast.error(t('migration.toast.sourceFieldsRequired'))
toast.error(sourceDraftValidationMessage())
return
}
const mediaRootPath = sourceDraft.mediaRootPath.trim()
@@ -849,7 +850,7 @@ async function onSaveAndValidate() {
return
}
if (!hasValidSourceDraft()) {
toast.error(t('migration.toast.sourceFieldsRequired'))
toast.error(sourceDraftValidationMessage())
return
}
busy.savingSource = true
@@ -914,9 +915,7 @@ function cleanedPathMappings() {
}
function cleanedUserMappings() {
return userMappings.value
.map((row) => ({ sourceUserId: row.sourceUserId, targetUserId: row.targetUserId }))
.filter((row): row is { sourceUserId: string; targetUserId: number } => !!row.targetUserId)
return userMappings.value.map((row) => ({ sourceUserId: row.sourceUserId, targetUserId: row.skip ? null : row.targetUserId }))
}
async function onSaveMappings() {
@@ -930,11 +929,11 @@ async function onSaveMappings() {
return
}
const mappings = cleanedUserMappings()
if (mappings.length === 0) {
if (!mappings.some((row) => row.targetUserId !== null)) {
toast.error(t('migration.toast.mapAtLeastOneUser'))
return
}
if (mappings.length !== userMappings.value.length) {
if (userMappings.value.some((row) => !row.skip && row.targetUserId === null)) {
toast.error(t('migration.toast.mapEveryUser'))
return
}
@@ -1178,8 +1177,10 @@ function friendlyUnresolvedReason(reason: string | null): string {
if (reason === 'no_file_path_match') return t('migration.unresolvedReason.noFilePathMatch')
if (reason === 'no_file_hash_match') return t('migration.unresolvedReason.noFileHashMatch')
if (reason === 'no_isbn_match') return t('migration.unresolvedReason.noIsbnMatch')
if (reason === 'no_asin_match') return t('migration.unresolvedReason.noAsinMatch')
if (reason === 'insufficient_source_data') return t('migration.unresolvedReason.insufficientSourceData')
if (reason === 'ambiguous_isbn_match') return t('migration.unresolvedReason.ambiguousIsbnMatch')
if (reason === 'ambiguous_asin_match') return t('migration.unresolvedReason.ambiguousAsinMatch')
if (reason === 'ambiguous_file_hash_match') return t('migration.unresolvedReason.ambiguousFileHashMatch')
if (reason === 'ambiguous_file_path_match') return t('migration.unresolvedReason.ambiguousFilePathMatch')
if (reason === 'ambiguous_title_author_match') return t('migration.unresolvedReason.ambiguousTitleAuthorMatch')
@@ -1188,6 +1189,7 @@ function friendlyUnresolvedReason(reason: string | null): string {
function friendlyMatchStrategy(strategy: string | null): string {
if (strategy === 'isbn') return t('migration.matchStrategy.isbn')
if (strategy === 'asin') return t('migration.matchStrategy.asin')
if (strategy === 'file_hash') return t('migration.matchStrategy.fileHash')
if (strategy === 'path_mapping') return t('migration.matchStrategy.pathMapping')
if (strategy === 'title_author') return t('migration.matchStrategy.titleAuthor')
@@ -1274,20 +1276,6 @@ function asString(value: unknown): string | null {
return null
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function asBoolean(value: unknown): boolean | null {
if (typeof value === 'boolean') return value
return null
}
function formatSourceCountLabel(key: string) {
return key
.replace(/([A-Z])/g, ' $1')
@@ -1338,7 +1326,7 @@ const sourceTypeCompatibility = computed<SourceTypeCompatibility | null>(() => {
<!-- Sidebar (desktop only) -->
<nav class="hidden md:flex flex-col w-52 shrink-0 bg-muted/40 border-r border-border">
<div class="px-4 pt-5 pb-4 border-b border-border flex items-center justify-between shrink-0">
<span class="font-semibold text-foreground font-serif truncate">{{ t('migration.sidebarTitle') }}</span>
<span class="font-semibold text-foreground font-serif truncate">{{ t('migration.importTitle') }}</span>
<Button variant="ghost" size="icon-sm" class="shrink-0" @click="handleClose">
<X :size="14" />
</Button>
@@ -1396,120 +1384,18 @@ const sourceTypeCompatibility = computed<SourceTypeCompatibility | null>(() => {
<template v-else>
<!-- Step 0: Source Connection -->
<div v-if="currentStep === 0" class="space-y-3">
<div class="grid gap-2 md:grid-cols-2">
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.type') }}</span>
<select v-model="sourceDraft.type" class="select-field mt-1 w-full" :disabled="hasActiveRun">
<option v-for="type in supportedTypes" :key="type" :value="type">{{ type }}</option>
</select>
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.name') }}</span>
<input
v-model="sourceDraft.name"
class="input-field mt-1 w-full"
:placeholder="t('migration.sidebarTitle')"
:disabled="hasActiveRun"
/>
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.host') }}</span>
<input v-model="sourceDraft.host" class="input-field mt-1 w-full" placeholder="127.0.0.1" :disabled="hasActiveRun" />
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.port') }}</span>
<input
v-model.number="sourceDraft.port"
class="input-field mt-1 w-full"
type="number"
min="1"
max="65535"
:disabled="hasActiveRun"
/>
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.user') }}</span>
<input v-model="sourceDraft.user" class="input-field mt-1 w-full" placeholder="booklore" :disabled="hasActiveRun" />
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.password') }}</span>
<div class="relative mt-1">
<input
v-model="sourceDraft.password"
v-bind="SECRET_INPUT_ATTRS"
class="input-field w-full pr-9"
:class="{ 'input-secret': !showPassword }"
type="text"
:placeholder="source ? t('migration.source.fields.passwordSaved') : t('migration.source.fields.passwordNotSet')"
:disabled="hasActiveRun"
/>
<Button
variant="ghost"
size="icon-sm"
type="button"
class="absolute inset-y-0 right-0"
tabindex="-1"
@click="onTogglePassword"
>
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
</Button>
</div>
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.database') }}</span>
<input v-model="sourceDraft.database" class="input-field mt-1 w-full" placeholder="booklore" :disabled="hasActiveRun" />
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.mediaRootPath') }}</span>
<div class="mt-1 flex items-center gap-2">
<input
v-model="sourceDraft.mediaRootPath"
class="input-field w-full"
placeholder="/data/booklore/media"
:disabled="hasActiveRun"
/>
<button
type="button"
:disabled="busy.testingMediaPath || hasActiveRun"
class="inline-flex h-9 items-center rounded-md border px-2.5 text-xs font-medium transition-colors disabled:opacity-50"
:class="
mediaPathTestState === 'pass'
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20'
: mediaPathTestState === 'fail'
? 'border-amber-500/40 bg-amber-500/10 text-amber-700 hover:bg-amber-500/20'
: 'border-border bg-background text-foreground hover:bg-muted'
"
@click="onTestMediaPath"
>
<Loader2 v-if="busy.testingMediaPath" class="size-3.5 animate-spin" />
<span v-else>
{{
mediaPathTestState === 'pass'
? t('migration.source.mediaPath.buttonOk')
: mediaPathTestState === 'fail'
? t('migration.source.mediaPath.buttonIssue')
: t('migration.source.mediaPath.buttonTest')
}}
</span>
</button>
</div>
<p class="mt-1 text-xs" :class="mediaRootPathHint.className">{{ mediaRootPathHint.text }}</p>
<p
v-if="mediaPathTestMessage"
class="mt-1 text-xs"
:class="mediaPathTestState === 'pass' ? 'text-emerald-700' : 'text-amber-700'"
>
{{ mediaPathTestMessage }}
</p>
</label>
<div class="flex items-center">
<label class="flex h-9 cursor-pointer items-center gap-2">
<input v-model="sourceDraft.ssl" type="checkbox" class="size-4 rounded border-border" :disabled="hasActiveRun" />
<span class="settings-hint">{{ t('migration.source.fields.ssl') }}</span>
</label>
</div>
</div>
<MigrationSourceFields
:draft="sourceDraft"
:supported-types="supportedTypes"
:disabled="hasActiveRun"
:show-secret="showPassword"
:testing-media-path="busy.testingMediaPath"
:media-path-test-state="mediaPathTestState"
:media-path-test-message="mediaPathTestMessage"
:media-root-path-hint="mediaRootPathHint"
@toggle-secret="onTogglePassword"
@test-media-path="onTestMediaPath"
/>
<div v-if="sourceTypeCompatibility" class="rounded border border-border bg-muted/40 px-3.5 py-3 text-xs space-y-1">
<p class="font-medium text-foreground">{{ t('migration.source.compatibility.title') }}</p>
@@ -1589,7 +1475,18 @@ const sourceTypeCompatibility = computed<SourceTypeCompatibility | null>(() => {
<p class="text-xs text-muted-foreground">{{ row.sourceUserId }}</p>
</td>
<td class="px-3 py-2">
<SearchableUserSelect v-model="row.targetUserId" :users="targetUsers" :disabled="hasActiveRun" />
<div class="space-y-2">
<SearchableUserSelect v-model="row.targetUserId" :users="targetUsers" :disabled="hasActiveRun || row.skip" />
<label class="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input
v-model="row.skip"
type="checkbox"
class="size-4 rounded border-input accent-primary"
:disabled="hasActiveRun"
/>
{{ t('migration.mappings.doNotImport') }}
</label>
</div>
</td>
</tr>
<tr v-if="userMappings.length === 0" class="border-t border-border">
@@ -0,0 +1,130 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { reactive } from 'vue'
import { createMigrationSourceDraft } from '@/features/migration/lib/migration-source-config'
import MigrationSourceFields from './MigrationSourceFields.vue'
function mountFields(type = 'audiobookshelf', wide = false) {
const draft = reactive(createMigrationSourceDraft(type))
const wrapper = mount(MigrationSourceFields, {
props: {
draft,
supportedTypes: ['booklore', 'grimmory', 'audiobookshelf', 'calibre_web_automated'],
disabled: false,
showSecret: false,
testingMediaPath: false,
mediaPathTestState: 'idle',
mediaPathTestMessage: null,
mediaRootPathHint: { className: 'text-muted-foreground', text: 'hint' },
wide,
},
})
return { draft, wrapper }
}
describe('MigrationSourceFields', () => {
it('renders translated source type labels instead of internal identifiers', () => {
const { wrapper } = mountFields()
const options = wrapper.findAll('option').map((option) => option.text())
expect(options).toEqual(['Booklore', 'Grimmory', 'Audiobookshelf', 'Calibre-Web Automated'])
expect(wrapper.text()).not.toContain('calibre_web_automated')
})
it('shows live API fields and keeps the secret control keyboard reachable', async () => {
const { draft, wrapper } = mountFields()
expect(wrapper.get('input[type="url"]').attributes('placeholder')).toContain('audiobookshelf.local')
expect(wrapper.text()).toContain('API token')
expect(wrapper.text()).toContain('Allow private network access')
expect(wrapper.find('input[placeholder="/imports/audiobookshelf/backup.audiobookshelf"]').exists()).toBe(false)
const secretButton = wrapper.get('button[aria-label="Show secret"]')
expect(secretButton.element.tagName).toBe('BUTTON')
expect(secretButton.attributes('tabindex')).toBeUndefined()
await secretButton.trigger('click')
expect(wrapper.emitted('toggleSecret')).toHaveLength(1)
await wrapper.get('input[type="checkbox"]').setValue(true)
expect(draft.allowPrivateNetwork).toBe(true)
})
it('switches to a backup form and hides API-only controls', async () => {
const { draft, wrapper } = mountFields()
await wrapper.get('input[type="radio"][value="backup"]').setValue()
expect(draft.audiobookshelfMode).toBe('backup')
expect(wrapper.get('input[placeholder="/imports/audiobookshelf/backup.audiobookshelf"]')).toBeTruthy()
expect(wrapper.find('input[type="url"]').exists()).toBe(false)
expect(wrapper.find('button[aria-label="Show secret"]').exists()).toBe(false)
})
it('uses responsive grid semantics in the wider settings entry point', () => {
const { wrapper } = mountFields('audiobookshelf', true)
expect(wrapper.get('[data-testid="migration-source-fields"]').classes()).toEqual(
expect.arrayContaining(['grid', 'md:grid-cols-2', 'xl:grid-cols-4']),
)
expect(wrapper.get('fieldset').classes()).toContain('md:col-span-2')
})
it('shows only the two CWA snapshot paths with accessible guidance in the narrow entry point', () => {
const { wrapper } = mountFields('calibre_web_automated')
const fields = wrapper.get('[data-testid="migration-source-fields"]')
const inputs = wrapper.findAll('input')
const appDatabaseInput = wrapper.get('input[placeholder="/imports/calibre-web-automated/app.db"]')
const metadataDatabaseInput = wrapper.get('input[placeholder="/imports/calibre-web-automated/metadata.db"]')
expect(fields.classes()).toEqual(expect.arrayContaining(['grid', 'md:grid-cols-2']))
expect(fields.classes()).not.toContain('grid-cols-2')
expect(inputs).toHaveLength(3)
expect(wrapper.find('input[type="url"]').exists()).toBe(false)
expect(wrapper.find('input[placeholder="127.0.0.1"]').exists()).toBe(false)
expect(wrapper.find('input[placeholder="/imports/audiobookshelf/backup.audiobookshelf"]').exists()).toBe(false)
expect(appDatabaseInput.element.closest('label')?.textContent).toContain('Calibre-Web Automated app.db snapshot')
expect(metadataDatabaseInput.element.closest('label')?.textContent).toContain('Calibre metadata.db snapshot')
expect(appDatabaseInput.attributes('aria-describedby')).toBe('cwa-snapshot-guidance')
expect(metadataDatabaseInput.attributes('aria-describedby')).toBe('cwa-snapshot-guidance')
expect(appDatabaseInput.attributes('tabindex')).toBeUndefined()
expect(metadataDatabaseInput.attributes('tabindex')).toBeUndefined()
expect(appDatabaseInput.classes()).toContain('input-field')
expect(metadataDatabaseInput.classes()).toContain('input-field')
const guidance = wrapper.get('#cwa-snapshot-guidance')
expect(guidance.attributes('aria-labelledby')).toBe('cwa-snapshot-guidance-title')
expect(guidance.text()).toContain('Stop Calibre-Web Automated before copying both databases')
expect(guidance.text()).toContain('MIGRATION_IMPORT_ROOT')
expect(guidance.text()).toContain('logical Calibre library root stored in app.db')
expect(guidance.text()).toContain('v4.0.6')
expect(guidance.classes()).toContain('md:col-span-2')
})
it('spans CWA guidance and snapshot paths responsively in the wide entry point', () => {
const { wrapper } = mountFields('calibre_web_automated', true)
const guidance = wrapper.get('#cwa-snapshot-guidance')
const appDatabaseLabel = wrapper.get('input[placeholder="/imports/calibre-web-automated/app.db"]').element.closest('label')
const metadataDatabaseLabel = wrapper.get('input[placeholder="/imports/calibre-web-automated/metadata.db"]').element.closest('label')
expect(wrapper.get('[data-testid="migration-source-fields"]').classes()).toEqual(expect.arrayContaining(['md:grid-cols-2', 'xl:grid-cols-4']))
expect(guidance.classes()).toEqual(expect.arrayContaining(['md:col-span-2', 'xl:col-span-4']))
expect(appDatabaseLabel?.classList.contains('md:col-span-2')).toBe(true)
expect(metadataDatabaseLabel?.classList.contains('md:col-span-2')).toBe(true)
})
it('keeps database fields and media path testing for Booklore-compatible sources', async () => {
const { wrapper } = mountFields('booklore')
expect(wrapper.get('input[placeholder="127.0.0.1"]')).toBeTruthy()
expect(wrapper.text()).toContain('Media Root Path')
expect(wrapper.text()).not.toContain('Connection mode')
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('toggleSecret')).toHaveLength(1)
const testPathButton = wrapper.findAll('button').find((button) => button.text().includes('Test Path'))
expect(testPathButton).toBeDefined()
await testPathButton?.trigger('click')
expect(wrapper.emitted('testMediaPath')).toHaveLength(1)
})
})
@@ -0,0 +1,279 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Eye, EyeOff, Loader2 } from '@lucide/vue'
import { Button } from '@/components/ui/button'
import { defaultMigrationSourceName, type MigrationSourceDraft } from '@/features/migration/lib/migration-source-config'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
const props = defineProps<{
supportedTypes: string[]
disabled: boolean
showSecret: boolean
testingMediaPath: boolean
mediaPathTestState: 'idle' | 'pass' | 'fail'
mediaPathTestMessage: string | null
mediaRootPathHint: { className: string; text: string }
wide?: boolean
}>()
const draft = defineModel<MigrationSourceDraft>('draft', { required: true })
const emit = defineEmits<{
toggleSecret: []
testMediaPath: []
}>()
const { t } = useI18n()
const isAudiobookshelf = computed(() => draft.value.type === 'audiobookshelf')
const isCalibreWebAutomated = computed(() => draft.value.type === 'calibre_web_automated')
const gridClass = computed(() => (props.wide ? 'md:grid-cols-2 xl:grid-cols-4' : 'md:grid-cols-2'))
const secretToggleLabel = computed(() => (props.showSecret ? t('migration.source.fields.hideSecret') : t('migration.source.fields.showSecret')))
const sourceTypeKeys: Record<string, string> = {
booklore: 'migration.source.types.booklore',
grimmory: 'migration.source.types.grimmory',
audiobookshelf: 'migration.source.types.audiobookshelf',
calibre_web_automated: 'migration.source.types.calibreWebAutomated',
}
watch(
() => draft.value.type,
(type, previousType) => {
if (!draft.value.name.trim() || draft.value.name === defaultMigrationSourceName(previousType)) {
draft.value.name = defaultMigrationSourceName(type)
}
},
)
function handleToggleSecret() {
emit('toggleSecret')
}
function handleTestMediaPath() {
emit('testMediaPath')
}
function sourceTypeLabel(type: string): string {
const key = sourceTypeKeys[type]
return key ? t(key) : type
}
</script>
<template>
<div class="grid gap-2" :class="gridClass" data-testid="migration-source-fields">
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.type') }}</span>
<select v-model="draft.type" class="select-field mt-1 w-full" :disabled="disabled">
<option v-for="type in supportedTypes" :key="type" :value="type">{{ sourceTypeLabel(type) }}</option>
</select>
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.name') }}</span>
<input v-model="draft.name" class="input-field mt-1 w-full" :placeholder="t('migration.source.fields.namePlaceholder')" :disabled="disabled" />
</label>
<template v-if="isCalibreWebAutomated">
<section
id="cwa-snapshot-guidance"
class="space-y-2 rounded-md border border-primary/30 bg-primary/10 p-3 text-sm"
:class="wide ? 'md:col-span-2 xl:col-span-4' : 'md:col-span-2'"
aria-labelledby="cwa-snapshot-guidance-title"
>
<h3 id="cwa-snapshot-guidance-title" class="font-medium text-foreground">
{{ t('migration.source.cwa.snapshotTitle') }}
</h3>
<p class="font-medium text-foreground">{{ t('migration.source.cwa.stoppedWarning') }}</p>
<p class="text-muted-foreground">{{ t('migration.source.cwa.importRootGuidance') }}</p>
<p class="text-muted-foreground">{{ t('migration.source.cwa.logicalRootGuidance') }}</p>
<p class="text-muted-foreground">{{ t('migration.source.cwa.compatibilityNote') }}</p>
</section>
<label class="block md:col-span-2">
<span class="settings-hint">{{ t('migration.source.fields.cwaAppDatabasePath') }}</span>
<input
v-model="draft.cwaAppDatabasePath"
class="input-field mt-1 w-full"
:placeholder="t('migration.source.fields.cwaAppDatabasePathPlaceholder')"
aria-describedby="cwa-snapshot-guidance"
:disabled="disabled"
/>
</label>
<label class="block md:col-span-2">
<span class="settings-hint">{{ t('migration.source.fields.cwaMetadataDatabasePath') }}</span>
<input
v-model="draft.cwaMetadataDatabasePath"
class="input-field mt-1 w-full"
:placeholder="t('migration.source.fields.cwaMetadataDatabasePathPlaceholder')"
aria-describedby="cwa-snapshot-guidance"
:disabled="disabled"
/>
</label>
</template>
<template v-else-if="isAudiobookshelf">
<fieldset class="min-w-0" :class="wide ? 'md:col-span-2 xl:col-span-2' : 'md:col-span-2'">
<legend class="settings-hint">{{ t('migration.source.fields.connectionMode') }}</legend>
<div class="mt-1 flex min-h-9 flex-wrap items-center gap-x-5 gap-y-2">
<label class="flex cursor-pointer items-center gap-2">
<input v-model="draft.audiobookshelfMode" type="radio" value="api" class="size-4 border-border" :disabled="disabled" />
<span class="text-sm text-foreground">{{ t('migration.source.fields.apiMode') }}</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<input v-model="draft.audiobookshelfMode" type="radio" value="backup" class="size-4 border-border" :disabled="disabled" />
<span class="text-sm text-foreground">{{ t('migration.source.fields.backupMode') }}</span>
</label>
</div>
</fieldset>
<template v-if="draft.audiobookshelfMode === 'api'">
<label class="block" :class="wide ? 'md:col-span-2' : ''">
<span class="settings-hint">{{ t('migration.source.fields.baseUrl') }}</span>
<input
v-model="draft.baseUrl"
class="input-field mt-1 w-full"
type="url"
inputmode="url"
placeholder="http://audiobookshelf.local:13378"
:disabled="disabled"
/>
</label>
<label class="block" :class="wide ? 'md:col-span-2' : ''">
<span class="settings-hint">{{ t('migration.source.fields.apiToken') }}</span>
<div class="relative mt-1">
<input
v-model="draft.apiToken"
v-bind="SECRET_INPUT_ATTRS"
class="input-field w-full pe-10"
:class="{ 'input-secret': !showSecret }"
type="text"
:placeholder="
draft.apiToken === '********' ? t('migration.source.fields.secretSaved') : t('migration.source.fields.apiTokenPlaceholder')
"
:disabled="disabled"
/>
<Button
variant="ghost"
size="icon-sm"
type="button"
class="absolute inset-y-0 end-0"
:aria-label="secretToggleLabel"
:disabled="disabled"
@click="handleToggleSecret"
>
<EyeOff v-if="showSecret" class="size-4" aria-hidden="true" />
<Eye v-else class="size-4" aria-hidden="true" />
</Button>
</div>
</label>
<div class="flex items-center" :class="wide ? 'md:col-span-2 xl:col-span-4' : 'md:col-span-2'">
<label class="flex min-h-9 cursor-pointer items-center gap-2">
<input v-model="draft.allowPrivateNetwork" type="checkbox" class="size-4 rounded border-border" :disabled="disabled" />
<span class="settings-hint">{{ t('migration.source.fields.allowPrivateNetwork') }}</span>
</label>
</div>
<p class="text-xs text-muted-foreground" :class="wide ? 'md:col-span-2 xl:col-span-4' : 'md:col-span-2'">
{{ t('migration.source.fields.allowPrivateNetworkHint') }}
</p>
</template>
<label v-else class="block" :class="wide ? 'md:col-span-2 xl:col-span-4' : 'md:col-span-2'">
<span class="settings-hint">{{ t('migration.source.fields.backupPath') }}</span>
<input
v-model="draft.backupPath"
class="input-field mt-1 w-full"
placeholder="/imports/audiobookshelf/backup.audiobookshelf"
:disabled="disabled"
/>
<span class="mt-1 block text-xs text-muted-foreground">{{ t('migration.source.fields.backupPathHint') }}</span>
</label>
</template>
<template v-else>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.host') }}</span>
<input v-model="draft.host" class="input-field mt-1 w-full" placeholder="127.0.0.1" :disabled="disabled" />
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.port') }}</span>
<input v-model.number="draft.port" class="input-field mt-1 w-full" type="number" min="1" max="65535" :disabled="disabled" />
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.user') }}</span>
<input v-model="draft.user" class="input-field mt-1 w-full" placeholder="booklore" :disabled="disabled" />
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.password') }}</span>
<div class="relative mt-1">
<input
v-model="draft.password"
v-bind="SECRET_INPUT_ATTRS"
class="input-field w-full pe-10"
:class="{ 'input-secret': !showSecret }"
type="text"
:placeholder="draft.password === '********' ? t('migration.source.fields.passwordSaved') : t('migration.source.fields.passwordNotSet')"
:disabled="disabled"
/>
<Button
variant="ghost"
size="icon-sm"
type="button"
class="absolute inset-y-0 end-0"
:aria-label="secretToggleLabel"
:disabled="disabled"
@click="handleToggleSecret"
>
<EyeOff v-if="showSecret" class="size-4" aria-hidden="true" />
<Eye v-else class="size-4" aria-hidden="true" />
</Button>
</div>
</label>
<label class="block">
<span class="settings-hint">{{ t('migration.source.fields.database') }}</span>
<input v-model="draft.database" class="input-field mt-1 w-full" placeholder="booklore" :disabled="disabled" />
</label>
<label class="block" :class="wide ? 'md:col-span-2 xl:col-span-2' : ''">
<span class="settings-hint">{{ t('migration.source.fields.mediaRootPath') }}</span>
<div class="mt-1 flex items-center gap-2">
<input v-model="draft.mediaRootPath" class="input-field w-full" placeholder="/data/booklore/media" :disabled="disabled" />
<button
type="button"
:disabled="testingMediaPath || disabled"
class="inline-flex h-9 items-center rounded-md border px-2.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
:class="
mediaPathTestState === 'pass'
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20'
: mediaPathTestState === 'fail'
? 'border-amber-500/40 bg-amber-500/10 text-amber-700 hover:bg-amber-500/20'
: 'border-border bg-background text-foreground hover:bg-muted'
"
@click="handleTestMediaPath"
>
<Loader2 v-if="testingMediaPath" class="size-3.5 animate-spin" aria-hidden="true" />
<span v-else>
{{
mediaPathTestState === 'pass'
? t('migration.source.mediaPath.buttonOk')
: mediaPathTestState === 'fail'
? t('migration.source.mediaPath.buttonIssue')
: t('migration.source.mediaPath.buttonTest')
}}
</span>
</button>
</div>
<p class="mt-1 text-xs" :class="mediaRootPathHint.className">{{ mediaRootPathHint.text }}</p>
<p v-if="mediaPathTestMessage" class="mt-1 text-xs" :class="mediaPathTestState === 'pass' ? 'text-emerald-700' : 'text-amber-700'">
{{ mediaPathTestMessage }}
</p>
</label>
<div class="flex items-center">
<label class="flex min-h-9 cursor-pointer items-center gap-2">
<input v-model="draft.ssl" type="checkbox" class="size-4 rounded border-border" :disabled="disabled" />
<span class="settings-hint">{{ t('migration.source.fields.ssl') }}</span>
</label>
</div>
</template>
</div>
</template>
@@ -225,7 +225,7 @@ export function validatePathMappings(
export function createProfile(payload: {
sourceId: number
name: string
userMappings: Array<{ sourceUserId: string; targetUserId: number }>
userMappings: Array<{ sourceUserId: string; targetUserId: number | null }>
pathMappings?: Array<{ sourcePrefix: string; targetPrefix: string }>
scope?: Record<string, unknown>
}) {
@@ -0,0 +1,139 @@
import { describe, expect, it } from 'vitest'
import {
buildMigrationSourceConnectionConfig,
createMigrationSourceDraft,
hydrateMigrationSourceDraft,
validateMigrationSourceDraft,
} from './migration-source-config'
describe('migration source configuration', () => {
it('builds a normalized Audiobookshelf API configuration and preserves a redacted token', () => {
const draft = createMigrationSourceDraft('audiobookshelf')
draft.name = 'Saved Audiobookshelf'
draft.baseUrl = ' https://abs.example.test/ '
draft.apiToken = '********'
draft.allowPrivateNetwork = true
expect(validateMigrationSourceDraft(draft)).toBeNull()
expect(buildMigrationSourceConnectionConfig(draft)).toEqual({
mode: 'api',
baseUrl: 'https://abs.example.test',
apiToken: '********',
allowPrivateNetwork: true,
})
})
it('hydrates API and backup configurations without mixing mode-specific fields', () => {
const draft = createMigrationSourceDraft()
hydrateMigrationSourceDraft(draft, {
type: 'audiobookshelf',
name: 'Live server',
connectionConfig: {
mode: 'api',
baseUrl: 'http://abs.local:13378',
apiToken: '********',
allowPrivateNetwork: true,
},
})
expect(draft).toMatchObject({
audiobookshelfMode: 'api',
baseUrl: 'http://abs.local:13378',
apiToken: '********',
allowPrivateNetwork: true,
backupPath: '',
})
hydrateMigrationSourceDraft(draft, {
type: 'audiobookshelf',
name: 'Backup archive',
connectionConfig: { mode: 'backup', backupPath: '/imports/abs.audiobookshelf' },
})
expect(draft).toMatchObject({
audiobookshelfMode: 'backup',
baseUrl: '',
apiToken: '',
allowPrivateNetwork: false,
backupPath: '/imports/abs.audiobookshelf',
})
expect(buildMigrationSourceConnectionConfig(draft)).toEqual({
mode: 'backup',
backupPath: '/imports/abs.audiobookshelf',
})
})
it('validates source-specific required fields and safe URL and path shapes', () => {
const draft = createMigrationSourceDraft('audiobookshelf')
expect(validateMigrationSourceDraft(draft)).toBe('apiFieldsRequired')
draft.baseUrl = 'https://abs.example.test/api'
draft.apiToken = 'token'
expect(validateMigrationSourceDraft(draft)).toBe('baseUrlInvalid')
draft.audiobookshelfMode = 'backup'
draft.backupPath = 'relative/backup.audiobookshelf'
expect(validateMigrationSourceDraft(draft)).toBe('backupPathAbsolute')
draft.backupPath = '/imports/backup.audiobookshelf'
expect(validateMigrationSourceDraft(draft)).toBeNull()
})
it('builds exactly the two CWA snapshot paths and fixed mode', () => {
const draft = createMigrationSourceDraft('calibre_web_automated')
draft.cwaAppDatabasePath = ' /imports/cwa/app.db '
draft.cwaMetadataDatabasePath = ' /imports/cwa/metadata.db '
draft.host = 'must-not-leak'
draft.apiToken = 'must-not-leak'
expect(draft.name).toBe('Calibre-Web Automated')
expect(validateMigrationSourceDraft(draft)).toBeNull()
expect(buildMigrationSourceConnectionConfig(draft)).toEqual({
mode: 'snapshot',
appDatabasePath: '/imports/cwa/app.db',
metadataDatabasePath: '/imports/cwa/metadata.db',
})
})
it('hydrates saved CWA paths without treating them as database connection fields', () => {
const draft = createMigrationSourceDraft('booklore')
draft.host = 'old-database-host'
hydrateMigrationSourceDraft(draft, {
type: 'calibre_web_automated',
name: 'Stopped CWA',
connectionConfig: {
mode: 'snapshot',
appDatabasePath: '/imports/cwa/app.db',
metadataDatabasePath: '/imports/cwa/metadata.db',
},
})
expect(draft).toMatchObject({
type: 'calibre_web_automated',
name: 'Stopped CWA',
cwaAppDatabasePath: '/imports/cwa/app.db',
cwaMetadataDatabasePath: '/imports/cwa/metadata.db',
host: '',
database: '',
backupPath: '',
})
})
it('validates each CWA path and stops returning CWA errors after the type changes', () => {
const draft = createMigrationSourceDraft('calibre_web_automated')
expect(validateMigrationSourceDraft(draft)).toBe('cwaAppDatabasePathRequired')
draft.cwaAppDatabasePath = 'relative/app.db'
expect(validateMigrationSourceDraft(draft)).toBe('cwaAppDatabasePathAbsolute')
draft.cwaAppDatabasePath = '/imports/cwa/app.db'
expect(validateMigrationSourceDraft(draft)).toBe('cwaMetadataDatabasePathRequired')
draft.cwaMetadataDatabasePath = 'relative/metadata.db'
expect(validateMigrationSourceDraft(draft)).toBe('cwaMetadataDatabasePathAbsolute')
draft.type = 'booklore'
expect(validateMigrationSourceDraft(draft)).toBe('databaseFieldsRequired')
expect(draft.port).toBe(3306)
})
})
@@ -0,0 +1,191 @@
export type AudiobookshelfConnectionMode = 'api' | 'backup'
export interface MigrationSourceDraft {
type: string
name: string
host: string
port: number
user: string
password: string
database: string
ssl: boolean
mediaRootPath: string
audiobookshelfMode: AudiobookshelfConnectionMode
baseUrl: string
apiToken: string
allowPrivateNetwork: boolean
backupPath: string
cwaAppDatabasePath: string
cwaMetadataDatabasePath: string
}
export type MigrationSourceValidationError =
| 'nameRequired'
| 'databaseFieldsRequired'
| 'apiFieldsRequired'
| 'baseUrlInvalid'
| 'backupPathRequired'
| 'backupPathAbsolute'
| 'cwaAppDatabasePathRequired'
| 'cwaAppDatabasePathAbsolute'
| 'cwaMetadataDatabasePathRequired'
| 'cwaMetadataDatabasePathAbsolute'
export function createMigrationSourceDraft(type = 'booklore'): MigrationSourceDraft {
return {
type,
name: defaultMigrationSourceName(type),
host: '',
port: 3306,
user: '',
password: '',
database: '',
ssl: false,
mediaRootPath: '',
audiobookshelfMode: 'api',
baseUrl: '',
apiToken: '',
allowPrivateNetwork: false,
backupPath: '',
cwaAppDatabasePath: '',
cwaMetadataDatabasePath: '',
}
}
export function defaultMigrationSourceName(type: string): string {
if (type === 'audiobookshelf') return 'Audiobookshelf'
if (type === 'calibre_web_automated') return 'Calibre-Web Automated'
if (type === 'grimmory') return 'Grimmory'
return 'Booklore'
}
export function hydrateMigrationSourceDraft(draft: MigrationSourceDraft, source: { type: string; name: string; connectionConfig: unknown }): void {
const hydrated = createMigrationSourceDraft(source.type)
hydrated.name = source.name || hydrated.name
const config = asRecord(source.connectionConfig)
if (source.type === 'audiobookshelf') {
hydrated.audiobookshelfMode = config.mode === 'backup' ? 'backup' : 'api'
hydrated.baseUrl = asString(config.baseUrl) ?? ''
hydrated.apiToken = asString(config.apiToken) ?? ''
hydrated.allowPrivateNetwork = config.allowPrivateNetwork === true
hydrated.backupPath = asString(config.backupPath) ?? ''
} else if (source.type === 'calibre_web_automated') {
hydrated.cwaAppDatabasePath = asString(config.appDatabasePath) ?? ''
hydrated.cwaMetadataDatabasePath = asString(config.metadataDatabasePath) ?? ''
} else {
hydrated.host = asString(config.host) ?? ''
hydrated.port = asNumber(config.port) ?? hydrated.port
hydrated.user = asString(config.user) ?? ''
hydrated.password = asString(config.password) ?? ''
hydrated.database = asString(config.database) ?? ''
hydrated.ssl = config.ssl === true
hydrated.mediaRootPath = asString(config.mediaRootPath) ?? ''
}
Object.assign(draft, hydrated)
}
export function buildMigrationSourceConnectionConfig(draft: MigrationSourceDraft): Record<string, unknown> {
if (draft.type === 'calibre_web_automated') {
return {
mode: 'snapshot',
appDatabasePath: draft.cwaAppDatabasePath.trim(),
metadataDatabasePath: draft.cwaMetadataDatabasePath.trim(),
}
}
if (draft.type === 'audiobookshelf') {
if (draft.audiobookshelfMode === 'backup') {
return {
mode: 'backup',
backupPath: draft.backupPath.trim(),
}
}
return {
mode: 'api',
baseUrl: normalizeBaseUrl(draft.baseUrl),
apiToken: draft.apiToken,
allowPrivateNetwork: draft.allowPrivateNetwork,
}
}
return {
host: draft.host.trim(),
port: draft.port,
user: draft.user.trim(),
password: draft.password,
database: draft.database.trim(),
ssl: draft.ssl,
mediaRootPath: draft.mediaRootPath.trim(),
}
}
export function validateMigrationSourceDraft(draft: MigrationSourceDraft): MigrationSourceValidationError | null {
if (!draft.name.trim()) return 'nameRequired'
if (draft.type === 'calibre_web_automated') {
const appDatabasePath = draft.cwaAppDatabasePath.trim()
const metadataDatabasePath = draft.cwaMetadataDatabasePath.trim()
if (!appDatabasePath) return 'cwaAppDatabasePathRequired'
if (!appDatabasePath.startsWith('/')) return 'cwaAppDatabasePathAbsolute'
if (!metadataDatabasePath) return 'cwaMetadataDatabasePathRequired'
return metadataDatabasePath.startsWith('/') ? null : 'cwaMetadataDatabasePathAbsolute'
}
if (draft.type !== 'audiobookshelf') {
return draft.host.trim() && draft.user.trim() && draft.database.trim() ? null : 'databaseFieldsRequired'
}
if (draft.audiobookshelfMode === 'backup') {
const backupPath = draft.backupPath.trim()
if (!backupPath) return 'backupPathRequired'
return backupPath.startsWith('/') ? null : 'backupPathAbsolute'
}
if (!draft.baseUrl.trim() || !draft.apiToken.trim()) return 'apiFieldsRequired'
return isValidBaseUrl(draft.baseUrl) ? null : 'baseUrlInvalid'
}
function normalizeBaseUrl(value: string): string {
const trimmed = value.trim()
try {
return new URL(trimmed).origin
} catch {
return trimmed
}
}
function isValidBaseUrl(value: string): boolean {
try {
const parsed = new URL(value.trim())
return (
(parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
parsed.username === '' &&
parsed.password === '' &&
parsed.pathname === '/' &&
parsed.search === '' &&
parsed.hash === ''
)
} catch {
return false
}
}
function asRecord(value: unknown): Record<string, unknown> {
return value != null && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
}
function asString(value: unknown): string | null {
return typeof value === 'string' ? value : null
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
@@ -308,9 +308,9 @@ function formatDate(iso: string | null | undefined): string {
<div class="min-w-0">
<p class="settings-label">
<template v-if="migrationCardState === 'none' || migrationCardState === 'loading'">{{
t('settings.admin.maintenance.importFromBooklore')
t('settings.admin.maintenance.importLibraryData')
}}</template>
<template v-else-if="migrationCardState === 'configured'">{{ t('settings.admin.maintenance.bookloreImportConfigured') }}</template>
<template v-else-if="migrationCardState === 'configured'">{{ t('settings.admin.maintenance.migrationConfigured') }}</template>
<template v-else-if="migrationCardState === 'running'">{{ t('settings.admin.maintenance.migrationRunning') }}</template>
<template v-else-if="migrationCardState === 'completed'">{{ t('settings.admin.maintenance.migrationCompleted') }}</template>
<template v-else-if="migrationCardState === 'failed'">{{ t('settings.admin.maintenance.migrationFailed') }}</template>
@@ -320,7 +320,7 @@ function formatDate(iso: string | null | undefined): string {
class="settings-hint leading-relaxed max-w-sm mt-0.5 md:[display:block] overflow-hidden text-ellipsis [display:-webkit-box] [-webkit-line-clamp:2] [-webkit-box-orient:vertical]"
>
<template v-if="migrationCardState === 'none' || migrationCardState === 'loading'">
{{ t('settings.admin.maintenance.importDescription') }}
{{ t('settings.admin.maintenance.migrationDescription') }}
</template>
<template v-else-if="migrationCardState === 'configured'">
{{
@@ -0,0 +1,137 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import MigrationSettings from './MigrationSettings.vue'
const migrationApiMocks = vi.hoisted(() => ({
cancelRun: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
createDryRunPlan: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
createProfile: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
createSource: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
exportRunReport: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
getRunProgress: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
getRunReport: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
getWorkflowState: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
listSourcePathPrefixes: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
listSupportedSourceTypes: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
listTargetLibraryFolders: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
listTargetUsers: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
resolveDuplicateMatches: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
retryRun: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
startLiveRun: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
suggestUserMappings: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
testSource: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
validatePathMappings: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
validateSourceById: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
}))
vi.mock('@/features/migration/lib/migration-api', () => migrationApiMocks)
vi.mock('vue-sonner', () => ({
toast: {
error: vi.fn<(message: string) => void>(),
success: vi.fn<(message: string) => void>(),
warning: vi.fn<(message: string) => void>(),
},
}))
vi.mock('@/features/migration/composables/useMigrationProgress', () => ({
useMigrationProgress: () => ({
subscribeRun: vi.fn<(runId: number) => void>(),
unsubscribeRun: vi.fn<(runId: number) => void>(),
getProgress: vi.fn<(runId: number) => unknown>(),
progressMap: ref(new Map()),
}),
}))
vi.mock('@/features/migration/composables/useMigrationPolling', () => ({
useMigrationPolling: () => ({ pollingError: ref(null), retry: vi.fn<() => void>() }),
}))
vi.mock('@/lib/api', () => ({ api: vi.fn<(...args: unknown[]) => Promise<Response>>() }))
describe('MigrationSettings source configuration', () => {
beforeEach(() => {
vi.clearAllMocks()
migrationApiMocks.listSupportedSourceTypes.mockResolvedValue(['booklore', 'grimmory', 'audiobookshelf', 'calibre_web_automated'])
migrationApiMocks.listTargetUsers.mockResolvedValue([])
migrationApiMocks.listTargetLibraryFolders.mockResolvedValue([])
migrationApiMocks.getWorkflowState.mockResolvedValue({
active: {
source: {
id: 7,
type: 'audiobookshelf',
name: 'Nightly backup',
connectionConfig: { mode: 'backup', backupPath: '/imports/nightly.audiobookshelf' },
capabilities: null,
lastValidatedAt: null,
createdAt: '2026-08-14T00:00:00.000Z',
updatedAt: '2026-08-14T00:00:00.000Z',
},
profile: null,
plan: null,
run: null,
},
hasActiveRun: false,
})
})
it('hydrates the backup form in the full-page entry point', async () => {
const wrapper = mount(MigrationSettings, {
global: {
stubs: {
SettingsPageHeader: true,
MigrationStepper: true,
},
},
})
await flushPromises()
wrapper.getComponent({ name: 'MigrationStepper' }).vm.$emit('step-click', 0)
await flushPromises()
expect(wrapper.get('input[placeholder="/imports/audiobookshelf/backup.audiobookshelf"]').element).toHaveProperty(
'value',
'/imports/nightly.audiobookshelf',
)
expect(wrapper.get('[data-testid="migration-source-fields"]').classes()).toEqual(expect.arrayContaining(['md:grid-cols-2', 'xl:grid-cols-4']))
expect(wrapper.find('input[type="url"]').exists()).toBe(false)
})
it('hydrates CWA snapshot paths in the wide full-page entry point', async () => {
migrationApiMocks.getWorkflowState.mockResolvedValueOnce({
active: {
source: {
id: 9,
type: 'calibre_web_automated',
name: 'Stopped CWA',
connectionConfig: {
mode: 'snapshot',
appDatabasePath: '/imports/cwa/app.db',
metadataDatabasePath: '/imports/cwa/metadata.db',
},
capabilities: null,
lastValidatedAt: null,
createdAt: '2026-08-14T00:00:00.000Z',
updatedAt: '2026-08-14T00:00:00.000Z',
},
profile: null,
plan: null,
run: null,
},
hasActiveRun: false,
})
const wrapper = mount(MigrationSettings, {
global: {
stubs: {
SettingsPageHeader: true,
MigrationStepper: true,
},
},
})
await flushPromises()
wrapper.getComponent({ name: 'MigrationStepper' }).vm.$emit('step-click', 0)
await flushPromises()
expect(wrapper.get('input[placeholder="/imports/calibre-web-automated/app.db"]').element).toHaveProperty('value', '/imports/cwa/app.db')
expect(wrapper.get('input[placeholder="/imports/calibre-web-automated/metadata.db"]').element).toHaveProperty('value', '/imports/cwa/metadata.db')
expect(wrapper.get('#cwa-snapshot-guidance').classes()).toEqual(expect.arrayContaining(['md:col-span-2', 'xl:col-span-4']))
expect(wrapper.find('input[placeholder="127.0.0.1"]').exists()).toBe(false)
})
})
@@ -3,10 +3,11 @@ import { Button } from '@/components/ui/button'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '@/i18n/formatters'
import { Eye, EyeOff, Loader2 } from '@lucide/vue'
import { Loader2 } from '@lucide/vue'
import { toast } from 'vue-sonner'
import SettingsPageHeader from './SettingsPageHeader.vue'
import MigrationStepper from '@/features/migration/components/MigrationStepper.vue'
import MigrationSourceFields from '@/features/migration/components/MigrationSourceFields.vue'
import SearchableUserSelect from '@/features/migration/components/SearchableUserSelect.vue'
import type { StepDefinition } from '@/features/migration/components/MigrationStepper.vue'
import { api } from '@/lib/api'
@@ -42,7 +43,13 @@ import {
} from '@/features/migration/lib/migration-api'
import { useMigrationPolling } from '@/features/migration/composables/useMigrationPolling'
import { useMigrationProgress } from '@/features/migration/composables/useMigrationProgress'
import { SECRET_INPUT_ATTRS } from '@/lib/secret-input'
import {
buildMigrationSourceConnectionConfig,
createMigrationSourceDraft,
hydrateMigrationSourceDraft,
validateMigrationSourceDraft,
type MigrationSourceValidationError,
} from '@/features/migration/lib/migration-source-config'
const { t } = useI18n()
@@ -59,6 +66,7 @@ interface UserMappingDraft {
sourceUserId: string
username: string
targetUserId: number | null
skip: boolean
}
interface PathMappingDraft {
@@ -90,17 +98,7 @@ const suggestionsLoadedAt = ref<string | null>(null)
const userMappings = ref<UserMappingDraft[]>([])
const pathMappings = ref<PathMappingDraft[]>([{ sourcePrefix: '', targetPrefix: '' }])
const sourceDraft = reactive({
type: 'booklore',
name: 'Booklore',
host: '',
port: 3306,
user: '',
password: '',
database: '',
ssl: false,
mediaRootPath: '',
})
const sourceDraft = reactive(createMigrationSourceDraft())
const busy = reactive({
testingSource: false,
@@ -233,6 +231,7 @@ watch(activeStepIndex, (newVal) => {
watch(
() => [
sourceDraft.type,
sourceDraft.mediaRootPath,
sourceDraft.host,
sourceDraft.port,
@@ -240,6 +239,8 @@ watch(
sourceDraft.password,
sourceDraft.database,
sourceDraft.ssl,
sourceDraft.cwaAppDatabasePath,
sourceDraft.cwaMetadataDatabasePath,
],
() => {
mediaPathTestState.value = 'idle'
@@ -271,7 +272,7 @@ const stepTitles = computed(() => [
t('settings.admin.migration.stepReportTitle'),
])
const stepSubtitles = computed(() => [
t('settings.admin.migration.subtitleSource'),
t('settings.admin.migration.genericSubtitleSource'),
t('settings.admin.migration.subtitleMappings'),
t('settings.admin.migration.subtitleDryRun'),
t('settings.admin.migration.subtitleRun'),
@@ -338,7 +339,8 @@ const duplicateTargetBookLabels = ref<Map<number, string>>(new Map())
const duplicateGroupsRemaining = computed(() => duplicateMatches.value.filter((dup) => !duplicateResolutions.value.has(dup.targetBookId)).length)
const STRATEGY_PRIORITY: Record<string, number> = {
isbn: 40,
isbn: 50,
asin: 40,
file_hash: 30,
path_mapping: 20,
title_author: 10,
@@ -366,7 +368,7 @@ function sourceBookSecondary(dup: DuplicateBookMatch, sourceId: string): string
if (author) return t('settings.admin.migration.byAuthorWithId', { author, id: sourceId })
const filePath = candidate?.filePath?.trim()
if (filePath) return t('settings.admin.migration.filePathWithId', { path: filePath, id: sourceId })
return t('settings.admin.migration.bookloreSourceId', { id: sourceId })
return t('settings.admin.migration.sourceRecordId', { id: sourceId })
}
function recommendedSourceBookId(dup: DuplicateBookMatch): string | null {
@@ -629,16 +631,7 @@ async function refreshWorkflowState() {
function hydrateSourceDraft() {
const currentSource = source.value
if (!currentSource) return
sourceDraft.type = currentSource.type || sourceDraft.type
sourceDraft.name = currentSource.name || sourceDraft.name
const cfg = asRecord(currentSource.connectionConfig)
sourceDraft.host = asString(cfg.host) ?? sourceDraft.host
sourceDraft.port = asNumber(cfg.port) ?? sourceDraft.port
sourceDraft.user = asString(cfg.user) ?? sourceDraft.user
sourceDraft.password = asString(cfg.password) ?? ''
sourceDraft.database = asString(cfg.database) ?? sourceDraft.database
sourceDraft.ssl = asBoolean(cfg.ssl) ?? sourceDraft.ssl
sourceDraft.mediaRootPath = asString(cfg.mediaRootPath) ?? ''
hydrateMigrationSourceDraft(sourceDraft, currentSource)
}
function hydratePathMappings() {
@@ -656,6 +649,7 @@ function hydrateUserMappingsFromProfile() {
sourceUserId: row.sourceUserId,
username: row.sourceUserId,
targetUserId: row.targetUserId,
skip: row.targetUserId === null,
}))
}
@@ -673,7 +667,10 @@ async function hydrateUserMappingsFromSuggestions(showSuccessToast: boolean) {
userMappings.value = response.suggestions.map((row) => ({
sourceUserId: row.sourceUserId,
username: row.username,
targetUserId: savedMappings.get(row.sourceUserId) ?? row.suggestedTargetUserId ?? fallbackSingleTargetUserId,
targetUserId: savedMappings.has(row.sourceUserId)
? (savedMappings.get(row.sourceUserId) ?? null)
: (row.suggestedTargetUserId ?? fallbackSingleTargetUserId),
skip: savedMappings.has(row.sourceUserId) && savedMappings.get(row.sourceUserId) === null,
}))
if (showSuccessToast) {
@@ -693,19 +690,28 @@ async function hydrateUserMappingsFromSuggestions(showSuccessToast: boolean) {
}
function buildSourceConnectionConfig() {
return {
host: sourceDraft.host.trim(),
port: sourceDraft.port,
user: sourceDraft.user.trim(),
password: sourceDraft.password,
database: sourceDraft.database.trim(),
ssl: sourceDraft.ssl,
mediaRootPath: sourceDraft.mediaRootPath.trim(),
}
return buildMigrationSourceConnectionConfig(sourceDraft)
}
function hasValidSourceDraft() {
return !!sourceDraft.name.trim() && !!sourceDraft.host.trim() && !!sourceDraft.user.trim() && !!sourceDraft.database.trim()
return validateMigrationSourceDraft(sourceDraft) == null
}
function sourceDraftValidationMessage() {
const error = validateMigrationSourceDraft(sourceDraft)
const keyByError: Record<MigrationSourceValidationError, string> = {
nameRequired: 'migration.source.validation.nameRequired',
databaseFieldsRequired: 'migration.source.validation.databaseFieldsRequired',
apiFieldsRequired: 'migration.source.validation.apiFieldsRequired',
baseUrlInvalid: 'migration.source.validation.baseUrlInvalid',
backupPathRequired: 'migration.source.validation.backupPathRequired',
backupPathAbsolute: 'migration.source.validation.backupPathAbsolute',
cwaAppDatabasePathRequired: 'migration.source.validation.cwaAppDatabasePathRequired',
cwaAppDatabasePathAbsolute: 'migration.source.validation.cwaAppDatabasePathAbsolute',
cwaMetadataDatabasePathRequired: 'migration.source.validation.cwaMetadataDatabasePathRequired',
cwaMetadataDatabasePathAbsolute: 'migration.source.validation.cwaMetadataDatabasePathAbsolute',
}
return error ? t(keyByError[error]) : t('settings.admin.migration.requiredFields')
}
function extractWarningStrings(result: Record<string, unknown>): string[] {
@@ -718,7 +724,7 @@ function extractMediaPathWarnings(result: Record<string, unknown>): string[] {
async function onTestSource() {
if (!hasValidSourceDraft()) {
toast.error(t('settings.admin.migration.requiredFields'))
toast.error(sourceDraftValidationMessage())
return
}
@@ -755,7 +761,7 @@ async function onTestMediaPath() {
return
}
if (!hasValidSourceDraft()) {
toast.error(t('settings.admin.migration.requiredFields'))
toast.error(sourceDraftValidationMessage())
return
}
const mediaRootPath = sourceDraft.mediaRootPath.trim()
@@ -809,7 +815,7 @@ async function onSaveAndValidate() {
return
}
if (!hasValidSourceDraft()) {
toast.error(t('settings.admin.migration.requiredFields'))
toast.error(sourceDraftValidationMessage())
return
}
@@ -878,9 +884,7 @@ function cleanedPathMappings() {
}
function cleanedUserMappings() {
return userMappings.value
.map((row) => ({ sourceUserId: row.sourceUserId, targetUserId: row.targetUserId }))
.filter((row): row is { sourceUserId: string; targetUserId: number } => !!row.targetUserId)
return userMappings.value.map((row) => ({ sourceUserId: row.sourceUserId, targetUserId: row.skip ? null : row.targetUserId }))
}
async function onSaveMappings() {
@@ -895,11 +899,11 @@ async function onSaveMappings() {
}
const mappings = cleanedUserMappings()
if (mappings.length === 0) {
if (!mappings.some((row) => row.targetUserId !== null)) {
toast.error(t('settings.admin.migration.mapAtLeastOneUser'))
return
}
if (mappings.length !== userMappings.value.length) {
if (userMappings.value.some((row) => !row.skip && row.targetUserId === null)) {
toast.error(t('settings.admin.migration.mapEveryUser'))
return
}
@@ -1157,8 +1161,10 @@ function friendlyUnresolvedReason(reason: string | null): string {
if (reason === 'no_file_path_match') return t('settings.admin.migration.reasonNoFilePathMatch')
if (reason === 'no_file_hash_match') return t('settings.admin.migration.reasonNoFileHashMatch')
if (reason === 'no_isbn_match') return t('settings.admin.migration.reasonNoIsbnMatch')
if (reason === 'no_asin_match') return t('settings.admin.migration.reasonNoAsinMatch')
if (reason === 'insufficient_source_data') return t('settings.admin.migration.reasonInsufficientSourceData')
if (reason === 'ambiguous_isbn_match') return t('settings.admin.migration.reasonAmbiguousIsbnMatch')
if (reason === 'ambiguous_asin_match') return t('settings.admin.migration.reasonAmbiguousAsinMatch')
if (reason === 'ambiguous_file_hash_match') return t('settings.admin.migration.reasonAmbiguousFileHashMatch')
if (reason === 'ambiguous_file_path_match') return t('settings.admin.migration.reasonAmbiguousFilePathMatch')
if (reason === 'ambiguous_title_author_match') return t('settings.admin.migration.reasonAmbiguousTitleAuthorMatch')
@@ -1167,6 +1173,7 @@ function friendlyUnresolvedReason(reason: string | null): string {
function friendlyMatchStrategy(strategy: string | null): string {
if (strategy === 'isbn') return t('settings.admin.migration.strategyIsbn')
if (strategy === 'asin') return t('settings.admin.migration.strategyAsin')
if (strategy === 'file_hash') return t('settings.admin.migration.strategyFileHash')
if (strategy === 'path_mapping') return t('settings.admin.migration.strategyPathMapping')
if (strategy === 'title_author') return t('settings.admin.migration.strategyTitleAuthor')
@@ -1257,20 +1264,6 @@ function asString(value: unknown): string | null {
return null
}
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
function asBoolean(value: unknown): boolean | null {
if (typeof value === 'boolean') return value
return null
}
function formatSourceCountLabel(key: string) {
return key
.replace(/([A-Z])/g, ' $1')
@@ -1281,7 +1274,7 @@ function formatSourceCountLabel(key: string) {
</script>
<template>
<SettingsPageHeader :title="t('settings.admin.migration.title')" :subtitle="t('settings.admin.migration.subtitle')" />
<SettingsPageHeader :title="t('settings.admin.migration.title')" :subtitle="t('settings.admin.migration.genericSubtitle')" />
<div v-if="loading" class="flex items-center justify-center py-16">
<Loader2 class="size-5 animate-spin text-muted-foreground" />
@@ -1326,94 +1319,19 @@ function formatSourceCountLabel(key: string) {
<div class="px-6 py-6">
<!-- Step 0: Source Connection -->
<div v-if="currentStep === 0" class="space-y-3">
<div class="grid gap-2 md:grid-cols-2 xl:grid-cols-4">
<label class="block">
<span class="settings-hint">{{ t('settings.admin.migration.sourceType') }}</span>
<select v-model="sourceDraft.type" class="select-field mt-1 w-full" :disabled="hasActiveRun">
<option v-for="type in supportedTypes" :key="type" :value="type">{{ type }}</option>
</select>
</label>
<label class="block">
<span class="settings-hint">{{ t('settings.admin.migration.sourceName') }}</span>
<input v-model="sourceDraft.name" class="input-field mt-1 w-full" placeholder="Booklore Import" :disabled="hasActiveRun" />
</label>
<label class="block">
<span class="settings-hint">{{ t('settings.admin.migration.host') }}</span>
<input v-model="sourceDraft.host" class="input-field mt-1 w-full" placeholder="127.0.0.1" :disabled="hasActiveRun" />
</label>
<label class="block">
<span class="settings-hint">{{ t('settings.admin.migration.port') }}</span>
<input v-model.number="sourceDraft.port" class="input-field mt-1 w-full" type="number" min="1" max="65535" :disabled="hasActiveRun" />
</label>
<label class="block">
<span class="settings-hint">{{ t('settings.admin.migration.user') }}</span>
<input v-model="sourceDraft.user" class="input-field mt-1 w-full" placeholder="booklore" :disabled="hasActiveRun" />
</label>
<label class="block">
<span class="settings-hint">{{ t('settings.admin.migration.password') }}</span>
<div class="relative mt-1">
<input
v-model="sourceDraft.password"
v-bind="SECRET_INPUT_ATTRS"
class="input-field w-full pr-9"
:class="{ 'input-secret': !showPassword }"
type="text"
:placeholder="
source ? t('settings.admin.migration.passwordSavedPlaceholder') : t('settings.admin.migration.passwordEmptyPlaceholder')
"
:disabled="hasActiveRun"
/>
<Button variant="ghost" size="icon-sm" type="button" class="absolute inset-y-0 right-0" tabindex="-1" @click="onTogglePassword">
<EyeOff v-if="showPassword" class="size-4" />
<Eye v-else class="size-4" />
</Button>
</div>
</label>
<label class="block">
<span class="settings-hint">{{ t('settings.admin.migration.database') }}</span>
<input v-model="sourceDraft.database" class="input-field mt-1 w-full" placeholder="booklore" :disabled="hasActiveRun" />
</label>
<label class="block md:col-span-2 xl:col-span-2">
<span class="settings-hint">{{ t('settings.admin.migration.mediaRootPath') }}</span>
<div class="mt-1 flex items-center gap-2">
<input v-model="sourceDraft.mediaRootPath" class="input-field w-full" placeholder="/data/booklore/media" :disabled="hasActiveRun" />
<button
type="button"
:disabled="busy.testingMediaPath || hasActiveRun"
class="inline-flex h-9 items-center rounded-md border px-2.5 text-xs font-medium transition-colors disabled:opacity-50"
:class="
mediaPathTestState === 'pass'
? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20'
: mediaPathTestState === 'fail'
? 'border-amber-500/40 bg-amber-500/10 text-amber-700 hover:bg-amber-500/20'
: 'border-border bg-background text-foreground hover:bg-muted'
"
@click="onTestMediaPath"
>
<Loader2 v-if="busy.testingMediaPath" class="size-3.5 animate-spin" />
<span v-else>
{{
mediaPathTestState === 'pass'
? t('settings.admin.migration.pathOk')
: mediaPathTestState === 'fail'
? t('settings.admin.migration.pathIssue')
: t('settings.admin.migration.testPath')
}}
</span>
</button>
</div>
<p class="mt-1 text-xs" :class="mediaRootPathHint.className">{{ mediaRootPathHint.text }}</p>
<p v-if="mediaPathTestMessage" class="mt-1 text-xs" :class="mediaPathTestState === 'pass' ? 'text-emerald-700' : 'text-amber-700'">
{{ mediaPathTestMessage }}
</p>
</label>
<div class="flex items-center">
<label class="flex h-9 cursor-pointer items-center gap-2">
<input v-model="sourceDraft.ssl" type="checkbox" class="size-4 rounded border-border" :disabled="hasActiveRun" />
<span class="settings-hint">{{ t('settings.admin.migration.useTls') }}</span>
</label>
</div>
</div>
<MigrationSourceFields
:draft="sourceDraft"
:supported-types="supportedTypes"
:disabled="hasActiveRun"
:show-secret="showPassword"
:testing-media-path="busy.testingMediaPath"
:media-path-test-state="mediaPathTestState"
:media-path-test-message="mediaPathTestMessage"
:media-root-path-hint="mediaRootPathHint"
wide
@toggle-secret="onTogglePassword"
@test-media-path="onTestMediaPath"
/>
<div class="flex flex-wrap gap-2">
<Button variant="outline" size="sm" :disabled="busy.testingSource || hasActiveRun" @click="onTestSource" type="button">
@@ -1482,7 +1400,13 @@ function formatSourceCountLabel(key: string) {
<p class="text-xs text-muted-foreground">{{ row.sourceUserId }}</p>
</td>
<td class="px-3 py-2">
<SearchableUserSelect v-model="row.targetUserId" :users="targetUsers" :disabled="hasActiveRun" />
<div class="space-y-2">
<SearchableUserSelect v-model="row.targetUserId" :users="targetUsers" :disabled="hasActiveRun || row.skip" />
<label class="inline-flex items-center gap-2 text-xs text-muted-foreground">
<input v-model="row.skip" type="checkbox" class="size-4 rounded border-input accent-primary" :disabled="hasActiveRun" />
{{ t('settings.admin.migration.doNotImport') }}
</label>
</div>
</td>
</tr>
<tr v-if="userMappings.length === 0" class="border-t border-border">
@@ -80,7 +80,7 @@ describe('secret input policy', () => {
'features/hardcover/components/HardcoverConnectionCard.vue',
'features/readwise/components/ReadwiseSettings.vue',
'features/storygraph/components/StorygraphConnectionCard.vue',
'features/settings/MigrationSettings.vue',
'features/migration/components/MigrationSourceFields.vue',
'features/settings/OpdsSettings.vue',
'features/settings/KoreaderSettings.vue',
'features/settings/OidcSettings.vue',
+60 -2
View File
@@ -782,10 +782,13 @@
"updatesGroup": "Updates",
"importFromBooklore": "Import from Booklore",
"bookloreImportConfigured": "Booklore import configured",
"importLibraryData": "Import library data",
"migrationConfigured": "Migration source configured",
"migrationRunning": "Migration running",
"migrationCompleted": "Migration completed",
"migrationFailed": "Migration failed",
"importDescription": "One-time import of books, metadata, and reading progress from a previous Booklore installation.",
"migrationDescription": "One-time import of books, metadata, and reading progress from another library application.",
"configuredDescription": "Source: {name}. No migration run yet - continue setup to run the import.",
"runningDescription": "Stage: {stage} - started {started}",
"completedDescription": "From {name} - completed {date}",
@@ -827,6 +830,7 @@
"migration": {
"title": "Migration",
"subtitle": "One-time Booklore import: connect source, map users, dry-run, start migration, and export a report.",
"genericSubtitle": "One-time library import: connect a source, map users, run a dry run, start migration, and export a report.",
"progress": "Progress",
"stepSourceConnection": "Source Connection",
"stepUserPathMapping": "User & Path Mapping",
@@ -836,6 +840,7 @@
"stepReport": "Report",
"stepReportTitle": "Migration Report",
"subtitleSource": "Configure the database connection to your source Booklore instance.",
"genericSubtitleSource": "Configure the connection to your source library application.",
"subtitleMappings": "Map source users to target users and translate file paths.",
"subtitleDryRun": "Preview what will be imported before running the live migration.",
"subtitleRun": "Start the live import and monitor its progress.",
@@ -920,13 +925,16 @@
"reasonNoFilePathMatch": "File path did not match any book in this library",
"reasonNoFileHashMatch": "File hash did not match any book in this library",
"reasonNoIsbnMatch": "ISBN did not match any book in this library",
"reasonNoAsinMatch": "ASIN did not match any book in this library",
"reasonInsufficientSourceData": "Not enough metadata to attempt matching",
"reasonAmbiguousIsbnMatch": "Multiple books matched the same ISBN",
"reasonAmbiguousAsinMatch": "Multiple books matched the same ASIN",
"reasonAmbiguousFileHashMatch": "Multiple books matched the same file hash",
"reasonAmbiguousFilePathMatch": "Multiple books matched the same file path",
"reasonAmbiguousTitleAuthorMatch": "Multiple books matched the same title and author",
"reasonUnknown": "Could not determine reason",
"strategyIsbn": "Matched by ISBN",
"strategyAsin": "Matched by ASIN",
"strategyFileHash": "Matched by file hash",
"strategyPathMapping": "Matched by mapped file path",
"strategyTitleAuthor": "Matched by title and author",
@@ -962,6 +970,7 @@
"refresh": "Refresh",
"sourceUser": "Source User",
"targetUser": "Target User",
"doNotImport": "Do not import this user",
"noSourceUsersLoaded": "No source users loaded yet.",
"noActiveTargetUsers": "No active target users found. Create or re-enable a BookOrbit user before mapping.",
"pathPrefixMappings": "Path Prefix Mappings",
@@ -5862,6 +5871,7 @@
},
"migration": {
"sidebarTitle": "Booklore Import",
"importTitle": "Library Import",
"status": {
"done": "Done",
"saved": "Saved",
@@ -5877,7 +5887,8 @@
"source": {
"short": "Source Connection",
"title": "Source Connection",
"subtitle": "Configure the database connection to your source Booklore instance."
"subtitle": "Configure the database connection to your source Booklore instance.",
"genericSubtitle": "Configure the connection to your source library application."
},
"mappings": {
"short": "User & Path Mapping",
@@ -5919,18 +5930,61 @@
"testConnection": "Test Connection",
"saveAndValidate": "Save & Validate",
"validatedWithWarnings": "Source validated with warnings",
"types": {
"booklore": "Booklore",
"grimmory": "Grimmory",
"audiobookshelf": "Audiobookshelf",
"calibreWebAutomated": "Calibre-Web Automated"
},
"fields": {
"type": "Source Type",
"name": "Source Name",
"namePlaceholder": "Library import",
"host": "Host",
"port": "Port",
"user": "User",
"password": "Password",
"passwordSaved": "Saved - leave unchanged",
"passwordNotSet": "No password set",
"showSecret": "Show secret",
"hideSecret": "Hide secret",
"database": "Database",
"mediaRootPath": "Media Root Path",
"ssl": "Use TLS/SSL"
"ssl": "Use TLS/SSL",
"connectionMode": "Connection mode",
"apiMode": "Live API",
"backupMode": "Backup file",
"baseUrl": "Audiobookshelf URL",
"apiToken": "API token",
"apiTokenPlaceholder": "Enter an administrator API token",
"secretSaved": "Saved - leave unchanged",
"allowPrivateNetwork": "Allow private network access",
"allowPrivateNetworkHint": "Enable this only when Audiobookshelf is hosted on a trusted local or private network.",
"backupPath": "Backup file path",
"backupPathHint": "Enter an absolute server path inside the configured migration import directory.",
"cwaAppDatabasePath": "Calibre-Web Automated app.db snapshot",
"cwaAppDatabasePathPlaceholder": "/imports/calibre-web-automated/app.db",
"cwaMetadataDatabasePath": "Calibre metadata.db snapshot",
"cwaMetadataDatabasePathPlaceholder": "/imports/calibre-web-automated/metadata.db"
},
"validation": {
"nameRequired": "Source name is required",
"databaseFieldsRequired": "Host, user, database, and source name are required",
"apiFieldsRequired": "Audiobookshelf URL, API token, and source name are required",
"baseUrlInvalid": "Audiobookshelf URL must be a clean HTTP or HTTPS origin without a path",
"backupPathRequired": "Backup file path and source name are required",
"backupPathAbsolute": "Backup file path must be an absolute path",
"cwaAppDatabasePathRequired": "The Calibre-Web Automated app.db snapshot path is required",
"cwaAppDatabasePathAbsolute": "The Calibre-Web Automated app.db snapshot path must be absolute",
"cwaMetadataDatabasePathRequired": "The Calibre metadata.db snapshot path is required",
"cwaMetadataDatabasePathAbsolute": "The Calibre metadata.db snapshot path must be absolute"
},
"cwa": {
"snapshotTitle": "Stopped snapshot mode",
"stoppedWarning": "Stop Calibre-Web Automated before copying both databases. BookOrbit cannot verify that the two snapshots were copied at exactly the same instant.",
"importRootGuidance": "Both snapshot files must be readable server paths below the configured MIGRATION_IMPORT_ROOT directory.",
"logicalRootGuidance": "Book path mapping uses the logical Calibre library root stored in app.db, not the host folder containing these database snapshots.",
"compatibilityNote": "Schema behavior is verified against Calibre-Web Automated v4.0.6. Its databases do not expose a reliable application release version."
},
"mediaPath": {
"hintRequired": "Required for migrating book covers.",
@@ -5957,6 +6011,7 @@
"refresh": "Refresh",
"sourceUser": "Source User",
"targetUser": "Target User",
"doNotImport": "Do not import this user",
"noSourceUsersLoaded": "No source users loaded yet.",
"noActiveTargetUsers": "No active target users found. Create or re-enable a BookOrbit user before mapping.",
"pathPrefixMappings": "Path Prefix Mappings",
@@ -6074,8 +6129,10 @@
"noFilePathMatch": "File path did not match any book in this library",
"noFileHashMatch": "File hash did not match any book in this library",
"noIsbnMatch": "ISBN did not match any book in this library",
"noAsinMatch": "ASIN did not match any book in this library",
"insufficientSourceData": "Not enough metadata to attempt matching",
"ambiguousIsbnMatch": "Multiple books matched the same ISBN",
"ambiguousAsinMatch": "Multiple books matched the same ASIN",
"ambiguousFileHashMatch": "Multiple books matched the same file hash",
"ambiguousFilePathMatch": "Multiple books matched the same file path",
"ambiguousTitleAuthorMatch": "Multiple books matched the same title and author",
@@ -6083,6 +6140,7 @@
},
"matchStrategy": {
"isbn": "Matched by ISBN",
"asin": "Matched by ASIN",
"fileHash": "Matched by file hash",
"pathMapping": "Matched by mapped file path",
"titleAuthor": "Matched by title and author",