mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user