mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(server): add Booklore migration with source adapter and executor
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -13,6 +13,7 @@ beforeEach(() => {
|
||||
})
|
||||
import DetailsTab from '../detail/tabs/DetailsTab.vue'
|
||||
import type { BookDetail } from '@projectx/types'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-router')>()
|
||||
@@ -92,6 +93,13 @@ function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
|
||||
}
|
||||
}
|
||||
|
||||
function makeApiResponse(data: unknown, ok = true): Response {
|
||||
return {
|
||||
ok,
|
||||
json: async () => data,
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('DetailsTab — missing state', () => {
|
||||
it('renders the amber warning banner', () => {
|
||||
const wrapper = mount(DetailsTab, {
|
||||
@@ -146,4 +154,27 @@ describe('DetailsTab — present state', () => {
|
||||
expect(wrapper.find('a[title="Open in Goodreads"]').exists()).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('Info Links')
|
||||
})
|
||||
|
||||
it('renders only collections that already contain the book', async () => {
|
||||
vi.mocked(api).mockImplementation(async (input) => {
|
||||
if (input === '/api/v1/collections?bookIds=1') {
|
||||
return makeApiResponse([
|
||||
{ id: 10, name: 'Favorites', syncToKobo: false, memberCount: 1 },
|
||||
{ id: 11, name: 'Want to Read', syncToKobo: true, memberCount: 0 },
|
||||
])
|
||||
}
|
||||
|
||||
return makeApiResponse({}, false)
|
||||
})
|
||||
|
||||
const wrapper = mount(DetailsTab, {
|
||||
props: { book: makeBook({ id: 1 }) },
|
||||
global: globalStubs,
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Favorites')
|
||||
expect(wrapper.text()).not.toContain('Want to Read')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -605,7 +605,8 @@ async function loadSupplemental() {
|
||||
audiobookProgress.value = null
|
||||
}
|
||||
|
||||
collections.value = collectionsRes.ok ? ((await collectionsRes.json()) as CollectionMembership[]) : []
|
||||
const fetchedCollections = collectionsRes.ok ? ((await collectionsRes.json()) as CollectionMembership[]) : []
|
||||
collections.value = fetchedCollections.filter((collection) => (collection.memberCount ?? 0) > 0)
|
||||
|
||||
if (canViewKobo.value) {
|
||||
const fallbackSyncCollections = collections.value.filter((c) => c.syncToKobo && (c.memberCount ?? 0) > 0).map((c) => c.name)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sh
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCollections } from '../composables/useCollections'
|
||||
import IconPicker from '@/components/IconPicker.vue'
|
||||
import type { Collection } from '@projectx/types'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -23,6 +24,7 @@ const { fetchCollectionsWithMembership, createCollection, addBooksToCollection }
|
||||
const localCollections = ref<Collection[]>([])
|
||||
const addedIds = ref<Set<number>>(new Set())
|
||||
const newName = ref('')
|
||||
const newIcon = ref('')
|
||||
const creating = ref(false)
|
||||
const addingTo = ref<number | null>(null)
|
||||
|
||||
@@ -52,11 +54,13 @@ function partialCount(collection: Collection): number {
|
||||
|
||||
async function handleCreate() {
|
||||
const name = newName.value.trim()
|
||||
if (!name) return
|
||||
const icon = newIcon.value.trim()
|
||||
if (!name || !icon) return
|
||||
creating.value = true
|
||||
try {
|
||||
const collection = await createCollection(name)
|
||||
const collection = await createCollection(name, icon)
|
||||
newName.value = ''
|
||||
newIcon.value = ''
|
||||
localCollections.value = [...localCollections.value, collection]
|
||||
try {
|
||||
await addBooksToCollection(collection.id, props.bookIds)
|
||||
@@ -119,9 +123,10 @@ function handleDone() {
|
||||
<!-- Create new collection -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-medium text-foreground uppercase tracking-wider">New Collection</p>
|
||||
<div class="flex gap-2">
|
||||
<div class="space-y-2">
|
||||
<Input v-model="newName" placeholder="Collection name" class="flex-1" @keydown.enter="handleCreate" />
|
||||
<Button :disabled="!newName.trim() || creating" size="sm" @click="handleCreate">
|
||||
<IconPicker v-model="newIcon" placeholder="Choose an icon..." />
|
||||
<Button :disabled="!newName.trim() || !newIcon.trim() || creating" size="sm" class="w-full" @click="handleCreate">
|
||||
<Loader2 v-if="creating" :size="14" class="animate-spin mr-1" />
|
||||
<Plus v-else :size="14" class="mr-1" />
|
||||
Create
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { useCollections } from '../composables/useCollections'
|
||||
@@ -15,13 +15,22 @@ const name = ref('')
|
||||
const icon = ref('')
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const trimmedName = computed(() => name.value.trim())
|
||||
const trimmedIcon = computed(() => icon.value.trim())
|
||||
|
||||
async function submit() {
|
||||
if (!name.value.trim()) return
|
||||
if (!trimmedName.value) {
|
||||
error.value = 'Name is required'
|
||||
return
|
||||
}
|
||||
if (!trimmedIcon.value) {
|
||||
error.value = 'Choose an icon'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const collection = await createCollection(name.value.trim(), icon.value.trim() || undefined)
|
||||
const collection = await createCollection(trimmedName.value, trimmedIcon.value)
|
||||
name.value = ''
|
||||
icon.value = ''
|
||||
emit('close')
|
||||
@@ -59,7 +68,7 @@ async function submit() {
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-sm font-medium text-foreground"> Icon <span class="text-muted-foreground font-normal">(optional)</span> </label>
|
||||
<label class="text-sm font-medium text-foreground">Icon</label>
|
||||
<IconPicker v-model="icon" placeholder="Choose an icon..." />
|
||||
</div>
|
||||
|
||||
@@ -75,7 +84,7 @@ async function submit() {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!name.trim() || saving"
|
||||
:disabled="!trimmedName || !trimmedIcon || saving"
|
||||
class="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ saving ? 'Creating...' : 'Create' }}
|
||||
|
||||
@@ -6,14 +6,15 @@ import IconPicker from '@/components/IconPicker.vue'
|
||||
import type { Collection } from '@projectx/types'
|
||||
|
||||
const props = defineProps<{ open: boolean; collection: Collection }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const emit = defineEmits<{ close: []; deleted: [id: number] }>()
|
||||
|
||||
const { updateCollection } = useCollections()
|
||||
const { updateCollection, deleteCollection } = useCollections()
|
||||
|
||||
const name = ref('')
|
||||
const icon = ref('')
|
||||
const syncToKobo = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
@@ -41,6 +42,21 @@ async function submit() {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm(`Delete collection "${props.collection.name}"? This cannot be undone.`)) return
|
||||
deleting.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await deleteCollection(props.collection.id)
|
||||
emit('deleted', props.collection.id)
|
||||
emit('close')
|
||||
} catch {
|
||||
error.value = 'Failed to delete collection'
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,7 +107,16 @@ async function submit() {
|
||||
|
||||
<p v-if="error" class="text-sm text-destructive">{{ error }}</p>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-1">
|
||||
<div class="flex items-center justify-between gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="saving || deleting"
|
||||
class="h-9 px-4 rounded-md border border-destructive/30 bg-destructive/10 text-sm font-medium text-destructive hover:bg-destructive/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="handleDelete"
|
||||
>
|
||||
{{ deleting ? 'Deleting...' : 'Delete collection' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="emit('close')"
|
||||
@@ -101,7 +126,7 @@ async function submit() {
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="!name.trim() || saving"
|
||||
:disabled="!name.trim() || saving || deleting"
|
||||
class="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{{ saving ? 'Saving...' : 'Save' }}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Collection } from '@projectx/types'
|
||||
|
||||
const apiMock = vi.hoisted(() => vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>())
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
api: apiMock,
|
||||
}))
|
||||
|
||||
function makeCollection(overrides: Partial<Collection> = {}): Collection {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'Favorites',
|
||||
icon: 'FolderOpen',
|
||||
description: null,
|
||||
syncToKobo: false,
|
||||
displayOrder: 0,
|
||||
bookCount: 0,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeResponse(data?: unknown, ok = true): Response {
|
||||
return {
|
||||
ok,
|
||||
json: async () => data,
|
||||
} as Response
|
||||
}
|
||||
|
||||
describe('useCollections', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
apiMock.mockReset()
|
||||
})
|
||||
|
||||
it('creates collections with the provided icon', async () => {
|
||||
const created = makeCollection()
|
||||
apiMock.mockResolvedValueOnce(makeResponse(created))
|
||||
|
||||
const { useCollections } = await import('../useCollections')
|
||||
const { collections, createCollection } = useCollections()
|
||||
|
||||
await createCollection('Favorites', 'FolderOpen')
|
||||
|
||||
const [, request] = apiMock.mock.calls[0] as [string, RequestInit]
|
||||
expect(JSON.parse(String(request.body))).toEqual({
|
||||
name: 'Favorites',
|
||||
icon: 'FolderOpen',
|
||||
})
|
||||
expect(collections.value).toEqual([created])
|
||||
})
|
||||
|
||||
it('removes deleted collections from local state', async () => {
|
||||
const created = makeCollection()
|
||||
apiMock.mockResolvedValueOnce(makeResponse(created)).mockResolvedValueOnce(makeResponse())
|
||||
|
||||
const { useCollections } = await import('../useCollections')
|
||||
const { collections, createCollection, deleteCollection } = useCollections()
|
||||
|
||||
await createCollection(created.name, created.icon ?? 'FolderOpen')
|
||||
await deleteCollection(created.id)
|
||||
|
||||
expect(apiMock).toHaveBeenLastCalledWith(`/api/v1/collections/${created.id}`, { method: 'DELETE' })
|
||||
expect(collections.value).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -28,11 +28,11 @@ export function useCollections() {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function createCollection(name: string, icon?: string, description?: string): Promise<Collection> {
|
||||
async function createCollection(name: string, icon: string, description?: string): Promise<Collection> {
|
||||
const res = await api('/api/v1/collections', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, icon: icon || undefined, description }),
|
||||
body: JSON.stringify({ name, icon, description }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to create collection')
|
||||
const created: Collection = await res.json()
|
||||
@@ -85,6 +85,14 @@ export function useCollections() {
|
||||
if (!res.ok) throw new Error('Failed to reorder collections')
|
||||
}
|
||||
|
||||
async function deleteCollection(id: number): Promise<void> {
|
||||
const res = await api(`/api/v1/collections/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to delete collection')
|
||||
collections.value = collections.value.filter((collection) => collection.id !== id)
|
||||
}
|
||||
|
||||
return {
|
||||
collections,
|
||||
fetchCollections,
|
||||
@@ -94,5 +102,6 @@ export function useCollections() {
|
||||
addBooksToCollection,
|
||||
removeBooksFromCollection,
|
||||
reorderCollections,
|
||||
deleteCollection,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export type MigrationRunState = 'draft' | 'preflight_failed' | 'dry_run_ready' | 'running' | 'failed' | 'completed'
|
||||
|
||||
export interface MigrationSourceCapabilities {
|
||||
sourceType: string
|
||||
sourceVersion: string | null
|
||||
missingTables: string[]
|
||||
warnings: string[]
|
||||
counts: Record<string, number>
|
||||
}
|
||||
|
||||
export interface MigrationSource {
|
||||
id: number
|
||||
type: string
|
||||
name: string
|
||||
connectionConfig: Record<string, unknown>
|
||||
capabilities: MigrationSourceCapabilities | null
|
||||
lastValidatedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface MigrationProfile {
|
||||
id: number
|
||||
sourceId: number
|
||||
name: string
|
||||
userMappings: Array<{ sourceUserId: string; targetUserId: number }>
|
||||
pathMappings: Array<{ sourcePrefix: string; targetPrefix: string }>
|
||||
scope: Record<string, unknown>
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface MigrationPlanArtifact {
|
||||
id: number
|
||||
sourceId: number
|
||||
profileId: number
|
||||
plan: Record<string, unknown>
|
||||
summary: {
|
||||
generatedAt?: string
|
||||
status?: string
|
||||
matchedBooks?: number
|
||||
unresolvedBooks?: number
|
||||
duplicateBookMatches?: number
|
||||
unresolvedByReason?: Record<string, number>
|
||||
mappedUsers?: number
|
||||
perUserPreview?: Array<{ sourceUserId: string; targetUserId: number; username: string; counts: Record<string, number> }>
|
||||
}
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface MigrationRun {
|
||||
id: number
|
||||
sourceId: number
|
||||
profileId: number
|
||||
planArtifactId: number | null
|
||||
state: MigrationRunState
|
||||
currentStage: string | null
|
||||
startedAt: string | null
|
||||
endedAt: string | null
|
||||
errorMessage: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface MigrationRunMetric {
|
||||
id: number
|
||||
runId: number
|
||||
stage: string
|
||||
entityType: string
|
||||
processed: number
|
||||
imported: number
|
||||
skipped: number
|
||||
unresolved: number
|
||||
failed: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface MigrationRunProgress {
|
||||
run: MigrationRun
|
||||
totals: {
|
||||
processed: number
|
||||
imported: number
|
||||
skipped: number
|
||||
unresolved: number
|
||||
failed: number
|
||||
}
|
||||
metrics: MigrationRunMetric[]
|
||||
}
|
||||
|
||||
export interface MigrationRunReport {
|
||||
run: MigrationRun
|
||||
metrics: MigrationRunMetric[]
|
||||
plan: Record<string, unknown> | null
|
||||
summary: MigrationPlanArtifact['summary'] | null
|
||||
}
|
||||
|
||||
export interface MigrationWorkflowState {
|
||||
active: {
|
||||
source: MigrationSource
|
||||
profile: MigrationProfile | null
|
||||
plan: MigrationPlanArtifact | null
|
||||
run: MigrationRun | null
|
||||
} | null
|
||||
hasActiveRun: boolean
|
||||
}
|
||||
|
||||
export interface PathMappingValidation {
|
||||
sourceId: number
|
||||
validatedAt: string
|
||||
summary: {
|
||||
totalSourceBooks: number
|
||||
booksWithFilePath: number
|
||||
mappedByPrefix: number
|
||||
matchedTargetPaths: number
|
||||
unmatchedTargetPaths: number
|
||||
unchangedPaths: number
|
||||
}
|
||||
mappings: Array<{
|
||||
sourcePrefix: string
|
||||
targetPrefix: string
|
||||
affectedBooks: number
|
||||
matchedTargetPaths: number
|
||||
unmatchedTargetPaths: number
|
||||
unmatchedSamples: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
export interface MappingSuggestion {
|
||||
sourceUserId: string
|
||||
username: string
|
||||
name: string | null
|
||||
email: string | null
|
||||
suggestedTargetUserId: number | null
|
||||
confidence: 'high' | 'medium' | 'low' | null
|
||||
candidates: Array<{
|
||||
targetUserId: number
|
||||
username: string
|
||||
name: string
|
||||
email: string | null
|
||||
score: number
|
||||
confidence: 'high' | 'medium' | 'low'
|
||||
}>
|
||||
}
|
||||
|
||||
async function expectJson<T>(response: Response, fallbackMessage: string): Promise<T> {
|
||||
if (response.ok) return (await response.json()) as T
|
||||
const payload = await response.json().catch(() => ({}))
|
||||
const message =
|
||||
payload && typeof payload === 'object' && 'message' in payload && typeof payload.message === 'string' ? payload.message : fallbackMessage
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
export function listSupportedSourceTypes() {
|
||||
return api('/api/v1/migration/supported-types').then((res) => expectJson<string[]>(res, 'Failed to load supported migration source types'))
|
||||
}
|
||||
|
||||
export function getWorkflowState() {
|
||||
return api('/api/v1/migration/state').then((res) => expectJson<MigrationWorkflowState>(res, 'Failed to load migration state'))
|
||||
}
|
||||
|
||||
export function listTargetUsers() {
|
||||
return api('/api/v1/migration/target-users').then((res) =>
|
||||
expectJson<Array<{ id: number; username: string; name: string; email: string | null }>>(res, 'Failed to load target users'),
|
||||
)
|
||||
}
|
||||
|
||||
export function listSourcePathPrefixes(sourceId: number) {
|
||||
return api(`/api/v1/migration/sources/${sourceId}/path-prefixes`).then((res) =>
|
||||
expectJson<{ prefixes: string[] }>(res, 'Failed to load source path prefixes'),
|
||||
)
|
||||
}
|
||||
|
||||
export function listTargetLibraryFolders() {
|
||||
return api('/api/v1/libraries').then(async (res) => {
|
||||
if (!res.ok) throw new Error('Failed to load libraries')
|
||||
const libs = (await res.json()) as Array<{ id: number; name: string; folders: Array<{ id: number; path: string }> }>
|
||||
return libs.flatMap((lib) => lib.folders.map((f) => ({ libraryName: lib.name, path: f.path })))
|
||||
})
|
||||
}
|
||||
|
||||
export function createSource(payload: { type: string; name: string; connectionConfig: Record<string, unknown> }) {
|
||||
return api('/api/v1/migration/sources', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((res) => expectJson<MigrationSource>(res, 'Failed to save migration source'))
|
||||
}
|
||||
|
||||
export function testSource(payload: { type: string; connectionConfig: Record<string, unknown> }) {
|
||||
return api('/api/v1/migration/sources/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((res) => expectJson<Record<string, unknown>>(res, 'Failed to test migration source'))
|
||||
}
|
||||
|
||||
export function validateSourceById(sourceId: number) {
|
||||
return api(`/api/v1/migration/sources/${sourceId}/validate`, { method: 'POST' }).then((res) =>
|
||||
expectJson<Record<string, unknown>>(res, 'Failed to validate migration source'),
|
||||
)
|
||||
}
|
||||
|
||||
export function suggestUserMappings(sourceId: number) {
|
||||
return api(`/api/v1/migration/sources/${sourceId}/user-mapping-suggestions`).then((res) =>
|
||||
expectJson<{ sourceId: number; generatedAt: string; suggestions: MappingSuggestion[] }>(res, 'Failed to load user mapping suggestions'),
|
||||
)
|
||||
}
|
||||
|
||||
export function validatePathMappings(
|
||||
sourceId: number,
|
||||
payload: { pathMappings: Array<{ sourcePrefix: string; targetPrefix: string }>; sampleLimit?: number },
|
||||
) {
|
||||
return api(`/api/v1/migration/sources/${sourceId}/path-mappings/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((res) => expectJson<PathMappingValidation>(res, 'Failed to validate path mappings'))
|
||||
}
|
||||
|
||||
export function createProfile(payload: {
|
||||
sourceId: number
|
||||
name: string
|
||||
userMappings: Array<{ sourceUserId: string; targetUserId: number }>
|
||||
pathMappings?: Array<{ sourcePrefix: string; targetPrefix: string }>
|
||||
scope?: Record<string, unknown>
|
||||
}) {
|
||||
return api('/api/v1/migration/profiles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((res) => expectJson<MigrationProfile>(res, 'Failed to save migration profile'))
|
||||
}
|
||||
|
||||
export function createDryRunPlan(payload: { profileId: number; scopeOverride?: Record<string, unknown> }) {
|
||||
return api('/api/v1/migration/plans/dry-run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((res) => expectJson<MigrationPlanArtifact>(res, 'Failed to run dry-run plan'))
|
||||
}
|
||||
|
||||
export function startLiveRun(payload: { planArtifactId: number; targetKey?: string }) {
|
||||
return api('/api/v1/migration/runs/live', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
}).then((res) => expectJson<MigrationRun>(res, 'Failed to start migration run'))
|
||||
}
|
||||
|
||||
export function getRunProgress(runId: number) {
|
||||
return api(`/api/v1/migration/runs/${runId}/progress`).then((res) => expectJson<MigrationRunProgress>(res, 'Failed to load migration run progress'))
|
||||
}
|
||||
|
||||
export function getRunReport(runId: number) {
|
||||
return api(`/api/v1/migration/runs/${runId}/report`).then((res) => expectJson<MigrationRunReport>(res, 'Failed to load migration run report'))
|
||||
}
|
||||
|
||||
export function exportRunReport(runId: number, format: 'json' | 'csv') {
|
||||
return api(`/api/v1/migration/runs/${runId}/report/export?format=${format}`).then((res) =>
|
||||
expectJson<{ format: 'json' | 'csv'; fileName: string; contentType: string; content: string }>(res, 'Failed to export migration report'),
|
||||
)
|
||||
}
|
||||
@@ -32,15 +32,15 @@ describe('useFoliateInput', () => {
|
||||
})
|
||||
|
||||
it('navigates with document keyboard shortcuts', () => {
|
||||
const prev = vi.fn()
|
||||
const next = vi.fn()
|
||||
const prev = vi.fn<() => void>()
|
||||
const next = vi.fn<() => void>()
|
||||
const view: ViewLike = {
|
||||
prev,
|
||||
next,
|
||||
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
|
||||
}
|
||||
|
||||
const input = useFoliateInput(() => view, undefined, vi.fn(), vi.fn())
|
||||
const input = useFoliateInput(() => view, undefined, vi.fn<() => void>(), vi.fn<() => void>())
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }))
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
|
||||
@@ -54,15 +54,15 @@ describe('useFoliateInput', () => {
|
||||
})
|
||||
|
||||
it('ignores keyboard navigation while typing in editable inputs', () => {
|
||||
const prev = vi.fn()
|
||||
const next = vi.fn()
|
||||
const prev = vi.fn<() => void>()
|
||||
const next = vi.fn<() => void>()
|
||||
const view: ViewLike = {
|
||||
prev,
|
||||
next,
|
||||
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
|
||||
}
|
||||
|
||||
const input = useFoliateInput(() => view, undefined, vi.fn(), vi.fn())
|
||||
const input = useFoliateInput(() => view, undefined, vi.fn<() => void>(), vi.fn<() => void>())
|
||||
|
||||
const textInput = document.createElement('input')
|
||||
document.body.appendChild(textInput)
|
||||
@@ -78,15 +78,15 @@ describe('useFoliateInput', () => {
|
||||
})
|
||||
|
||||
it('handles keyboard navigation from iframe document after attachIframeClicks', () => {
|
||||
const prev = vi.fn()
|
||||
const next = vi.fn()
|
||||
const prev = vi.fn<() => void>()
|
||||
const next = vi.fn<() => void>()
|
||||
const view: ViewLike = {
|
||||
prev,
|
||||
next,
|
||||
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
|
||||
}
|
||||
|
||||
const input = useFoliateInput(() => view, undefined, vi.fn(), vi.fn())
|
||||
const input = useFoliateInput(() => view, undefined, vi.fn<() => void>(), vi.fn<() => void>())
|
||||
const doc = makeDocTarget()
|
||||
|
||||
input.attachIframeClicks(doc)
|
||||
@@ -103,16 +103,16 @@ describe('useFoliateInput', () => {
|
||||
it('routes click-zone window messages to prev/next/middle actions', () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
const prev = vi.fn()
|
||||
const next = vi.fn()
|
||||
const onMiddleTap = vi.fn()
|
||||
const prev = vi.fn<() => void>()
|
||||
const next = vi.fn<() => void>()
|
||||
const onMiddleTap = vi.fn<() => void>()
|
||||
const view: ViewLike = {
|
||||
prev,
|
||||
next,
|
||||
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
|
||||
}
|
||||
|
||||
const input = useFoliateInput(() => view, onMiddleTap, vi.fn(), vi.fn())
|
||||
const input = useFoliateInput(() => view, onMiddleTap, vi.fn<() => void>(), vi.fn<() => void>())
|
||||
const doc = makeDocTarget()
|
||||
input.attachIframeClicks(doc)
|
||||
|
||||
@@ -155,14 +155,14 @@ describe('useFoliateInput', () => {
|
||||
})
|
||||
|
||||
it('stops responding to document keydown after cleanup', () => {
|
||||
const next = vi.fn()
|
||||
const next = vi.fn<() => void>()
|
||||
const view: ViewLike = {
|
||||
prev: vi.fn(),
|
||||
prev: vi.fn<() => void>(),
|
||||
next,
|
||||
getBoundingClientRect: () => ({ left: 0, width: 100 }) as DOMRect,
|
||||
}
|
||||
|
||||
const input = useFoliateInput(() => view, undefined, vi.fn(), vi.fn())
|
||||
const input = useFoliateInput(() => view, undefined, vi.fn<() => void>(), vi.fn<() => void>())
|
||||
input.cleanup()
|
||||
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }))
|
||||
|
||||
@@ -82,7 +82,7 @@ describe('useFoliateSelection', () => {
|
||||
|
||||
const getView = () => null
|
||||
const foliateSelection = useFoliateSelection(getView)
|
||||
const onSelected = vi.fn()
|
||||
const onSelected = vi.fn<(detail: unknown) => void>()
|
||||
foliateSelection.setHandler(onSelected)
|
||||
|
||||
const collapsed = {
|
||||
@@ -126,7 +126,7 @@ describe('useFoliateSelection', () => {
|
||||
getCFI: () => 'epubcfi(/6/10)',
|
||||
}))
|
||||
|
||||
const onSelected = vi.fn()
|
||||
const onSelected = vi.fn<(detail: unknown) => void>()
|
||||
foliateSelection.setHandler(onSelected)
|
||||
|
||||
const doc = makeDoc({ selection })
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useSearch, type FoliateView } from '../useSearch'
|
||||
describe('useSearch', () => {
|
||||
it('returns early for empty query and does not call foliate search', async () => {
|
||||
const view: FoliateView = {
|
||||
search: vi.fn(async function* () {
|
||||
search: vi.fn<(opts: { query: string }) => AsyncGenerator<Record<string, unknown>, void, unknown>>(async function* () {
|
||||
yield {}
|
||||
}),
|
||||
clearSearch: vi.fn(),
|
||||
clearSearch: vi.fn<() => void>(),
|
||||
}
|
||||
|
||||
const store = useSearch()
|
||||
@@ -33,7 +33,7 @@ describe('useSearch', () => {
|
||||
subitems: [{ cfi: 'epubcfi(/6/8)', excerpt: { pre: '', match: 'hit', post: '' } }],
|
||||
}
|
||||
},
|
||||
clearSearch: vi.fn(),
|
||||
clearSearch: vi.fn<() => void>(),
|
||||
}
|
||||
|
||||
const store = useSearch()
|
||||
@@ -62,7 +62,7 @@ describe('useSearch', () => {
|
||||
})
|
||||
|
||||
it('clear resets local search state and clears foliate highlights', () => {
|
||||
const clearSearch = vi.fn()
|
||||
const clearSearch = vi.fn<() => void>()
|
||||
const view: FoliateView = {
|
||||
search: async function* () {
|
||||
yield {}
|
||||
|
||||
@@ -42,15 +42,16 @@ function measure() {
|
||||
|
||||
// ── Page dims ─────────────────────────────────────────────────────────────────
|
||||
const pageDims = ref<PageDim[]>([])
|
||||
const effectiveDims = computed(() => pageDims.value)
|
||||
|
||||
// ── Spread ────────────────────────────────────────────────────────────────────
|
||||
const spread = ref<'none' | 'odd' | 'even'>('none')
|
||||
|
||||
// ── Composables ───────────────────────────────────────────────────────────────
|
||||
const zoom = usePdfZoom(containerW, containerH, pageDims, spread)
|
||||
const zoom = usePdfZoom(containerW, containerH, effectiveDims, spread)
|
||||
const { scale, zoomMode, customScale, zoomLabel, adjustZoom, applyZoomPreset } = zoom
|
||||
|
||||
const layout = usePdfLayout(scrollRef, totalPages, pageDims, scale, containerH, spread)
|
||||
const layout = usePdfLayout(scrollRef, totalPages, effectiveDims, scale, containerH, spread)
|
||||
const { scrollMode, currentPage, pageInput, pageRows, rowHeights, goToPage, onScroll } = layout
|
||||
|
||||
function onDimUpdate(pageNum: number, dim: PageDim) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,9 @@ const sections = computed<Section[]>(() => {
|
||||
if (su || perms.includes('manage_app_settings')) {
|
||||
result.push({ label: 'Maintenance', routeName: 'settings-admin-maintenance' })
|
||||
}
|
||||
if (su || perms.includes('manage_app_settings')) {
|
||||
result.push({ label: 'Migration', routeName: 'settings-admin-migration' })
|
||||
}
|
||||
|
||||
result.push({ label: 'Account', routeName: 'settings-account' })
|
||||
|
||||
|
||||
@@ -152,6 +152,12 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/features/settings/MaintenanceSettings.vue'),
|
||||
meta: { title: 'Maintenance' },
|
||||
},
|
||||
{
|
||||
path: 'admin/migration',
|
||||
name: 'settings-admin-migration',
|
||||
component: () => import('@/features/settings/MigrationSettings.vue'),
|
||||
meta: { maxWidth: 'max-w-7xl', title: 'Migration' },
|
||||
},
|
||||
{
|
||||
path: 'admin/audit-log',
|
||||
name: 'settings-admin-audit-log',
|
||||
|
||||
@@ -116,6 +116,20 @@ async function handleRemoveFromCollection() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollectionDeleted(deletedId: number) {
|
||||
editCollectionOpen.value = false
|
||||
collectionNotFound.value = false
|
||||
|
||||
const nextCollection = collections.value.find((item) => item.id !== deletedId)
|
||||
if (nextCollection) {
|
||||
router.replace({ name: 'collection', params: { id: nextCollection.id } })
|
||||
} else {
|
||||
router.replace({ name: 'dashboard' })
|
||||
}
|
||||
|
||||
toast.success('Collection deleted')
|
||||
}
|
||||
|
||||
function handleEditSelected() {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length === 0) return
|
||||
@@ -199,7 +213,13 @@ watch(
|
||||
@done="exitSelectionMode"
|
||||
/>
|
||||
|
||||
<EditCollectionDialog v-if="collection" :open="editCollectionOpen" :collection="collection" @close="editCollectionOpen = false" />
|
||||
<EditCollectionDialog
|
||||
v-if="collection"
|
||||
:open="editCollectionOpen"
|
||||
:collection="collection"
|
||||
@close="editCollectionOpen = false"
|
||||
@deleted="handleCollectionDeleted"
|
||||
/>
|
||||
<SendBookDialog :open="sendBookOpen" :book-ids="[...selectedIds]" @update:open="sendBookOpen = $event" @sent="exitSelectionMode" />
|
||||
<DeleteBookDialog :open="deleteBookId !== null" :deleting="deletingBook" @confirm="confirmDelete" @cancel="cancelDelete" />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user