feat(server): add Booklore migration with source adapter and executor

This commit is contained in:
neon
2026-04-03 20:48:11 -06:00
parent f3bab75aea
commit 70e76600e0
65 changed files with 8068 additions and 58 deletions
@@ -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' })
+6
View File
@@ -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',
+21 -1
View File
@@ -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" />