fix(client): report failed Book Dock uploads

The Book Dock discarded every upload failure, so a rejected file looked like nothing happened.
List failed files with a translated reason, offer retry where it can help, and stop a failed
token refresh, abort or timeout from stalling the upload queue. Dropped files of unsupported
types are reported instead of ignored.

Refs #1739
This commit is contained in:
neon
2026-10-13 00:24:07 +00:00
parent 9dbe2980dd
commit 070201e163
10 changed files with 989 additions and 65 deletions
@@ -19,7 +19,7 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { isUploading, addFiles, clearCompleted } = useBookDockUpload()
const { isUploading, addFiles } = useBookDockUpload()
const { hasPermission } = usePermissions()
const fileInput = ref<HTMLInputElement | null>(null)
@@ -29,7 +29,6 @@ const processingStateChanging = ref(false)
const canManageBookDock = computed(() => hasPermission(Permission.ManageBookDock))
function openFilePicker() {
clearCompleted()
fileInput.value?.click()
}
@@ -0,0 +1,82 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { AlertCircle, RotateCw, X } from '@lucide/vue'
import { useBookDockUpload } from '../composables/useBookDockUpload'
import { describeUploadFailure, isRetryableUploadFailure } from '../lib/upload-failure'
const { t } = useI18n()
const { failures, retryFailure, dismissFailure, dismissAllFailures } = useBookDockUpload()
const rows = computed(() =>
failures.value.map((item) => {
const reason = item.failure ? describeUploadFailure(item.failure) : { key: 'bookDock.uploadFailures.reason.network' }
return {
id: item.id,
name: item.file.name,
reason: 'text' in reason ? reason.text : t(reason.key, reason.params ?? {}),
retryable: item.failure ? isRetryableUploadFailure(item.failure) : false,
}
}),
)
function handleRetry(id: string) {
retryFailure(id)
}
function handleDismiss(id: string) {
dismissFailure(id)
}
</script>
<template>
<section
v-if="rows.length > 0"
aria-labelledby="book-dock-upload-failures-heading"
data-testid="book-dock-upload-failures"
class="overflow-hidden rounded-xl border border-destructive/30 bg-destructive/5"
>
<div class="flex items-center gap-2 px-4 py-2.5">
<AlertCircle class="size-4 shrink-0 text-destructive" aria-hidden="true" />
<h2 id="book-dock-upload-failures-heading" class="min-w-0 flex-1 text-sm font-medium text-foreground">
<span role="alert">{{ t('bookDock.uploadFailures.heading', { count: rows.length }) }}</span>
</h2>
<button
v-if="rows.length > 1"
type="button"
data-testid="book-dock-upload-failures-dismiss-all"
class="h-7 shrink-0 rounded-lg px-2.5 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
@click="dismissAllFailures"
>
{{ t('bookDock.uploadFailures.dismissAll') }}
</button>
</div>
<ul class="max-h-64 divide-y divide-destructive/15 overflow-y-auto border-t border-destructive/20">
<li v-for="row in rows" :key="row.id" class="flex items-center gap-3 px-4 py-2">
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-foreground" :title="row.name">{{ row.name }}</p>
<p class="text-xs text-destructive">{{ row.reason }}</p>
</div>
<button
v-if="row.retryable"
type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:title="t('bookDock.uploadFailures.retry', { name: row.name })"
:aria-label="t('bookDock.uploadFailures.retry', { name: row.name })"
@click="handleRetry(row.id)"
>
<RotateCw class="size-3.5" aria-hidden="true" />
</button>
<button
type="button"
class="grid size-7 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
:title="t('bookDock.uploadFailures.dismiss', { name: row.name })"
:aria-label="t('bookDock.uploadFailures.dismiss', { name: row.name })"
@click="handleDismiss(row.id)"
>
<X class="size-3.5" aria-hidden="true" />
</button>
</li>
</ul>
</section>
</template>
@@ -0,0 +1,147 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import BookDockUploadFailures from '../BookDockUploadFailures.vue'
import { useBookDockUpload } from '../../composables/useBookDockUpload'
const { getValidTokenMock } = vi.hoisted(() => ({ getValidTokenMock: vi.fn<() => Promise<string | null>>() }))
vi.mock('@/lib/api', () => ({
api: vi.fn<() => Promise<Response>>(),
getValidToken: getValidTokenMock,
}))
class FakeXhr {
static sent: FakeXhr[] = []
status = 0
responseText = ''
upload = { onprogress: null }
onloadend: (() => void) | null = null
open() {}
setRequestHeader() {}
send() {
FakeXhr.sent.push(this)
}
respond(status: number, body: object) {
this.status = status
this.responseText = JSON.stringify(body)
this.onloadend?.()
}
}
function book(name: string): File {
return new File([new Uint8Array(8)], name)
}
async function failWithServerError(name: string, status: number, message: string) {
const upload = useBookDockUpload()
upload.addFiles([book(name)])
await flushPromises()
FakeXhr.sent.at(-1)?.respond(status, { statusCode: status, message, path: '/api/v1/book-dock/upload', timestamp: 'now', requestId: 'req-1' })
await flushPromises()
}
beforeEach(() => {
useBookDockUpload().clearAll()
FakeXhr.sent = []
getValidTokenMock.mockReset()
getValidTokenMock.mockResolvedValue('token')
vi.stubGlobal('XMLHttpRequest', FakeXhr)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('BookDockUploadFailures', () => {
it('renders nothing while no upload has failed', () => {
const wrapper = mount(BookDockUploadFailures)
expect(wrapper.find('[data-testid="book-dock-upload-failures"]').exists()).toBe(false)
})
it('names each failed file with its reason', async () => {
const wrapper = mount(BookDockUploadFailures)
useBookDockUpload().addFiles([book('notes.docx'), book('README')])
await flushPromises()
const rows = wrapper.findAll('li').map((row) => row.text())
expect(rows).toEqual([expect.stringContaining('notes.docx'), expect.stringContaining('README')])
expect(rows[0]).toContain('Unsupported file type: .docx')
expect(rows[1]).toContain('The file has no extension, so its format is unknown')
})
it('shows the server message when the server gave a reason the client cannot translate', async () => {
const wrapper = mount(BookDockUploadFailures)
await failWithServerError('dune.epub', 500, 'EACCES: permission denied, open /books/dune.epub')
expect(wrapper.get('li').text()).toContain('EACCES: permission denied, open /books/dune.epub')
})
it('announces the failure count through an alert inside the heading', async () => {
const wrapper = mount(BookDockUploadFailures)
useBookDockUpload().addFiles([book('a.docx')])
await flushPromises()
expect(wrapper.get('h2 [role="alert"]').text()).toBe('Could not upload 1 file')
useBookDockUpload().addFiles([book('b.docx')])
await flushPromises()
expect(wrapper.get('h2 [role="alert"]').text()).toBe('Could not upload 2 files')
expect(wrapper.get('section').attributes('aria-labelledby')).toBe(wrapper.get('h2').attributes('id'))
})
it('offers retry only where retrying can help', async () => {
const wrapper = mount(BookDockUploadFailures)
useBookDockUpload().addFiles([book('notes.docx')])
await failWithServerError('dune.epub', 503, 'Service unavailable')
expect(wrapper.findAll('button[aria-label^="Retry uploading"]').map((b) => b.attributes('aria-label'))).toEqual(['Retry uploading dune.epub'])
})
it('re-sends the file when retry is pressed', async () => {
const wrapper = mount(BookDockUploadFailures)
await failWithServerError('dune.epub', 503, 'Service unavailable')
await wrapper.get('button[aria-label="Retry uploading dune.epub"]').trigger('click')
await flushPromises()
expect(FakeXhr.sent).toHaveLength(2)
expect(wrapper.find('[data-testid="book-dock-upload-failures"]').exists()).toBe(false)
})
it('dismisses a single failure', async () => {
const wrapper = mount(BookDockUploadFailures)
useBookDockUpload().addFiles([book('a.docx'), book('b.docx')])
await flushPromises()
await wrapper.get('button[aria-label="Dismiss a.docx"]').trigger('click')
expect(wrapper.findAll('li').map((row) => row.find('p').text())).toEqual(['b.docx'])
})
it('offers dismiss all only when more than one upload failed', async () => {
const wrapper = mount(BookDockUploadFailures)
useBookDockUpload().addFiles([book('a.docx')])
await flushPromises()
expect(wrapper.find('[data-testid="book-dock-upload-failures-dismiss-all"]').exists()).toBe(false)
useBookDockUpload().addFiles([book('b.docx')])
await flushPromises()
await wrapper.get('[data-testid="book-dock-upload-failures-dismiss-all"]').trigger('click')
expect(wrapper.find('[data-testid="book-dock-upload-failures"]').exists()).toBe(false)
})
it('gives every icon button an accessible name that includes the file', async () => {
const wrapper = mount(BookDockUploadFailures)
useBookDockUpload().addFiles([book('notes.docx')])
await flushPromises()
const iconButtons = wrapper.findAll('li button')
expect(iconButtons.map((b) => b.attributes('aria-label'))).toEqual(['Dismiss notes.docx'])
expect(iconButtons.every((b) => b.attributes('type') === 'button')).toBe(true)
})
})
@@ -0,0 +1,430 @@
import { flushPromises } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { getValidTokenMock } = vi.hoisted(() => ({ getValidTokenMock: vi.fn<() => Promise<string | null>>() }))
vi.mock('@/lib/api', () => ({
api: vi.fn<() => Promise<Response>>(),
getValidToken: getValidTokenMock,
}))
const MB = 1024 * 1024
class FakeXhr {
static sent: FakeXhr[] = []
method = ''
url = ''
headers: Record<string, string> = {}
body: FormData | null = null
status = 0
responseText = ''
upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null }
onloadend: (() => void) | null = null
open(method: string, url: string) {
this.method = method
this.url = url
}
setRequestHeader(name: string, value: string) {
this.headers[name] = value
}
send(body: FormData) {
this.body = body
FakeXhr.sent.push(this)
}
respond(status: number, body: string | object) {
this.status = status
this.responseText = typeof body === 'string' ? body : JSON.stringify(body)
this.onloadend?.()
}
/** What a browser reports for a dropped connection, an abort and a timeout alike. */
drop() {
this.status = 0
this.onloadend?.()
}
progress(loaded: number, total: number) {
this.upload.onprogress?.({ lengthComputable: true, loaded, total } as ProgressEvent)
}
}
function bookOrbitError(statusCode: number, message: string, errorCode?: string) {
return {
statusCode,
message,
path: '/api/v1/book-dock/upload',
timestamp: '2026-10-12T00:00:00.000Z',
requestId: 'req-1',
...(errorCode ? { errorCode } : {}),
}
}
function book(name: string, sizeBytes = 16): File {
return new File([new Uint8Array(sizeBytes)], name, { type: 'application/epub+zip' })
}
async function loadComposable() {
const module = await import('../useBookDockUpload')
const { useAppInfo } = await import('@/features/settings/composables/useAppInfo')
return { upload: module.useBookDockUpload(), useBookDockUpload: module.useBookDockUpload, appInfo: useAppInfo() }
}
beforeEach(() => {
vi.resetModules()
FakeXhr.sent = []
getValidTokenMock.mockReset()
getValidTokenMock.mockResolvedValue('token')
vi.stubGlobal('XMLHttpRequest', FakeXhr)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('useBookDockUpload client-side validation', () => {
it('records an unsupported file as a failure without sending it', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('notes.docx')])
await flushPromises()
expect(FakeXhr.sent).toHaveLength(0)
expect(upload.failures.value.map((f) => f.failure)).toEqual([{ kind: 'unsupported', extension: 'docx' }])
expect(upload.isUploading.value).toBe(false)
})
it('records a file with no extension as unsupported', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('README')])
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'unsupported', extension: '' })
})
it('accepts a supported extension regardless of case', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('Dune.EPUB')])
await flushPromises()
expect(FakeXhr.sent).toHaveLength(1)
expect(upload.failures.value).toHaveLength(0)
})
it('rejects a file over the configured upload limit before sending it', async () => {
const { upload, appInfo } = await loadComposable()
appInfo.maxUploadSizeMb.value = 1
upload.addFiles([book('huge.epub', 2 * MB)])
await flushPromises()
expect(FakeXhr.sent).toHaveLength(0)
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'tooLarge', limitBytes: MB })
})
it('sends the valid files of a mixed selection and reports only the invalid one', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('good.epub'), book('cover.jpg')])
await flushPromises()
expect(FakeXhr.sent).toHaveLength(1)
expect(upload.failures.value.map((f) => f.file.name)).toEqual(['cover.jpg'])
})
})
describe('useBookDockUpload requests', () => {
it('posts the file to the Book Dock with the session token', async () => {
const { upload } = await loadComposable()
const file = book('dune.epub')
upload.addFiles([file])
await flushPromises()
const [xhr] = FakeXhr.sent
expect(xhr?.method).toBe('POST')
expect(xhr?.url).toBe('/api/v1/book-dock/upload')
expect(xhr?.headers.Authorization).toBe('Bearer token')
expect(xhr?.body?.get('file')).toBe(file)
expect(upload.isUploading.value).toBe(true)
})
it('omits the authorization header when there is no session token', async () => {
getValidTokenMock.mockResolvedValue(null)
const { upload } = await loadComposable()
upload.addFiles([book('dune.epub')])
await flushPromises()
expect(FakeXhr.sent[0]?.headers).toEqual({})
})
it('reports upload progress while the file is sending', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('dune.epub')])
await flushPromises()
FakeXhr.sent[0]?.progress(25, 100)
expect(upload.files.value[0]?.status).toBe('uploading')
expect(upload.files.value[0]?.progress).toBe(25)
})
it('drops a file from the queue once the server accepts it', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('dune.epub')])
await flushPromises()
FakeXhr.sent[0]?.respond(201, { id: 7, fileName: 'dune.epub' })
await flushPromises()
expect(upload.files.value).toHaveLength(0)
expect(upload.failures.value).toHaveLength(0)
expect(upload.isUploading.value).toBe(false)
})
it('uploads at most three files at a time and finishes the rest', async () => {
const { upload } = await loadComposable()
upload.addFiles(['a', 'b', 'c', 'd', 'e'].map((name) => book(`${name}.epub`)))
await flushPromises()
expect(FakeXhr.sent).toHaveLength(3)
for (const xhr of FakeXhr.sent) xhr.respond(201, {})
await flushPromises()
expect(FakeXhr.sent).toHaveLength(5)
for (const xhr of FakeXhr.sent.slice(3)) xhr.respond(201, {})
await flushPromises()
expect(upload.files.value).toHaveLength(0)
expect(upload.isUploading.value).toBe(false)
})
it('keeps going with the rest of a batch after one upload fails', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('a.epub'), book('b.epub'), book('c.epub')])
await flushPromises()
FakeXhr.sent[0]?.respond(500, bookOrbitError(500, 'Internal server error'))
FakeXhr.sent[1]?.respond(201, {})
FakeXhr.sent[2]?.respond(201, {})
await flushPromises()
expect(upload.failures.value.map((f) => f.file.name)).toEqual(['a.epub'])
expect(upload.files.value).toHaveLength(1)
expect(upload.isUploading.value).toBe(false)
})
})
describe('useBookDockUpload server failures', () => {
it('keeps the error code and message from a BookOrbit rejection', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('dune.epub')])
await flushPromises()
FakeXhr.sent[0]?.respond(413, bookOrbitError(413, 'File exceeds the 1 MB upload limit', 'UPLOAD_TOO_LARGE'))
await flushPromises()
expect(upload.failures.value[0]?.failure).toEqual({
kind: 'server',
status: 413,
errorCode: 'UPLOAD_TOO_LARGE',
message: 'File exceeds the 1 MB upload limit',
fromBookOrbit: true,
})
})
it('takes the first message from a validation error list', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('dune.epub')])
await flushPromises()
FakeXhr.sent[0]?.respond(400, { ...bookOrbitError(400, ''), message: ['file must be provided', 'second'] })
await flushPromises()
expect(upload.failures.value[0]?.failure).toMatchObject({ kind: 'server', status: 400, message: 'file must be provided' })
})
it('marks an HTML error page as coming from in front of BookOrbit', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('dune.epub')])
await flushPromises()
FakeXhr.sent[0]?.respond(413, '<html><body><h1>413 Request Entity Too Large</h1></body></html>')
await flushPromises()
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'server', status: 413, errorCode: null, message: null, fromBookOrbit: false })
})
it('does not trust a JSON body that lacks the BookOrbit error envelope', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('dune.epub')])
await flushPromises()
FakeXhr.sent[0]?.respond(502, { error: 'bad gateway' })
await flushPromises()
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'server', status: 502, errorCode: null, message: null, fromBookOrbit: false })
})
it('treats a blank message as no message', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('dune.epub')])
await flushPromises()
FakeXhr.sent[0]?.respond(500, bookOrbitError(500, ' '))
await flushPromises()
expect(upload.failures.value[0]?.failure).toMatchObject({ message: null, fromBookOrbit: true })
})
})
describe('useBookDockUpload requests that never complete', () => {
it('records a dropped connection as a network failure and keeps the queue moving', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('a.epub')])
await flushPromises()
FakeXhr.sent[0]?.drop()
await flushPromises()
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'network' })
expect(upload.isUploading.value).toBe(false)
upload.addFiles([book('b.epub')])
await flushPromises()
expect(FakeXhr.sent).toHaveLength(2)
})
it('fails the file instead of hanging when the session token cannot be refreshed', async () => {
getValidTokenMock.mockRejectedValueOnce(new Error('refresh failed'))
const { upload } = await loadComposable()
upload.addFiles([book('a.epub')])
await flushPromises()
expect(FakeXhr.sent).toHaveLength(0)
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'network' })
expect(upload.isUploading.value).toBe(false)
upload.addFiles([book('b.epub')])
await flushPromises()
expect(FakeXhr.sent).toHaveLength(1)
})
it('fails the file when the browser refuses to send the request', async () => {
vi.spyOn(FakeXhr.prototype, 'send').mockImplementationOnce(() => {
throw new DOMException('The object is in an invalid state.', 'InvalidStateError')
})
const { upload } = await loadComposable()
upload.addFiles([book('a.epub')])
await flushPromises()
expect(upload.failures.value[0]?.failure).toEqual({ kind: 'network' })
expect(upload.isUploading.value).toBe(false)
})
})
describe('useBookDockUpload retry and dismiss', () => {
it('retries a transient failure and clears it once the upload succeeds', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('a.epub')])
await flushPromises()
FakeXhr.sent[0]?.respond(503, bookOrbitError(503, 'Service unavailable'))
await flushPromises()
const id = upload.failures.value[0]!.id
upload.retryFailure(id)
await flushPromises()
expect(FakeXhr.sent).toHaveLength(2)
expect(upload.failures.value).toHaveLength(0)
FakeXhr.sent[1]?.respond(201, {})
await flushPromises()
expect(upload.files.value).toHaveLength(0)
})
it('picks up a retry while another upload is still in flight', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('a.epub')])
await flushPromises()
FakeXhr.sent[0]?.drop()
await flushPromises()
upload.addFiles([book('b.epub')])
await flushPromises()
upload.retryFailure(upload.failures.value[0]!.id)
FakeXhr.sent[1]?.respond(201, {})
await flushPromises()
expect(FakeXhr.sent).toHaveLength(3)
expect(FakeXhr.sent[2]?.body?.get('file')).toMatchObject({ name: 'a.epub' })
})
it('refuses to retry failures that would fail the same way again', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('notes.docx'), book('a.epub')])
await flushPromises()
FakeXhr.sent[0]?.respond(413, bookOrbitError(413, 'File exceeds the 1 MB upload limit', 'UPLOAD_TOO_LARGE'))
await flushPromises()
for (const failure of upload.failures.value) upload.retryFailure(failure.id)
await flushPromises()
expect(FakeXhr.sent).toHaveLength(1)
expect(upload.failures.value).toHaveLength(2)
})
it('ignores a retry for an unknown id', async () => {
const { upload } = await loadComposable()
upload.retryFailure('missing')
await flushPromises()
expect(FakeXhr.sent).toHaveLength(0)
expect(upload.isUploading.value).toBe(false)
})
it('dismisses one failure or all of them', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('a.docx'), book('b.docx'), book('c.docx')])
upload.dismissFailure(upload.failures.value[0]!.id)
expect(upload.failures.value.map((f) => f.file.name)).toEqual(['b.docx', 'c.docx'])
upload.dismissAllFailures()
expect(upload.failures.value).toHaveLength(0)
})
it('never dismisses an upload that is still in flight', async () => {
const { upload } = await loadComposable()
upload.addFiles([book('a.epub'), book('b.docx')])
await flushPromises()
const inFlight = upload.files.value.find((f) => f.status === 'uploading')!
upload.dismissFailure(inFlight.id)
upload.dismissAllFailures()
expect(upload.files.value.map((f) => f.id)).toEqual([inFlight.id])
})
it('shares failures between every caller, such as the toolbar and the page', async () => {
const { upload, useBookDockUpload } = await loadComposable()
const other = useBookDockUpload()
upload.addFiles([book('notes.docx')])
expect(other.failures.value).toHaveLength(1)
other.dismissAllFailures()
expect(upload.failures.value).toHaveLength(0)
})
})
@@ -1,8 +1,9 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { getValidToken } from '@/lib/api'
import type { BookDockFile } from '@bookorbit/types'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { isRetryableUploadFailure, type BookDockUploadFailure } from '../lib/upload-failure'
export const SUPPORTED_FORMATS = ['epub', 'kepub', 'pdf', 'mobi', 'azw3', 'cbz', 'cbr', 'cb7', 'fb2', 'm4b', 'm4a', 'mp3', 'opus', 'ogg', 'flac']
export const SUPPORTED_FORMATS_ACCEPT = SUPPORTED_FORMATS.map((f) => `.${f}`).join(',')
@@ -14,80 +15,114 @@ export interface UploadItem {
file: File
status: FileUploadStatus
progress: number
error?: string
failure?: BookDockUploadFailure
bookDockFile?: BookDockFile
}
const CONCURRENCY = 3
function validateFile(file: File): string | null {
const ext = file.name.split('.').pop()?.toLowerCase() ?? ''
if (!SUPPORTED_FORMATS.includes(ext)) return `Unsupported type .${ext}`
function fileExtension(name: string): string {
const dot = name.lastIndexOf('.')
return dot === -1 ? '' : name.slice(dot + 1).toLowerCase()
}
function validateFile(file: File): BookDockUploadFailure | null {
const extension = fileExtension(file.name)
if (!SUPPORTED_FORMATS.includes(extension)) return { kind: 'unsupported', extension }
const { maxUploadSizeMb } = useAppInfo()
const limitBytes = maxUploadSizeMb.value * 1024 * 1024
if (file.size > limitBytes) return `File exceeds ${maxUploadSizeMb.value} MB limit`
if (file.size > limitBytes) return { kind: 'tooLarge', limitBytes }
return null
}
function responseFailure(xhr: XMLHttpRequest): BookDockUploadFailure {
if (xhr.status === 0) return { kind: 'network' }
let body: unknown = null
try {
body = JSON.parse(xhr.responseText)
} catch {
// A proxy's HTML error page carries no structured reason.
}
const record = typeof body === 'object' && body !== null ? (body as Record<string, unknown>) : null
const rawMessage = Array.isArray(record?.message) ? record.message[0] : record?.message
const message = typeof rawMessage === 'string' && rawMessage.trim() ? rawMessage.trim() : null
return {
kind: 'server',
status: xhr.status,
errorCode: typeof record?.errorCode === 'string' ? record.errorCode : null,
message,
fromBookOrbit: typeof record?.statusCode === 'number' && typeof record?.requestId === 'string',
}
}
function fail(item: UploadItem, failure: BookDockUploadFailure) {
item.status = 'error'
item.failure = failure
}
async function uploadSingle(item: UploadItem): Promise<void> {
const token = await getValidToken()
return new Promise((resolve) => {
const formData = new FormData()
formData.append('file', item.file)
item.status = 'uploading'
item.progress = 0
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/v1/book-dock/upload')
let token: string | null
try {
token = await getValidToken()
} catch {
fail(item, { kind: 'network' })
return
}
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
await new Promise<void>((resolve) => {
try {
const xhr = new XMLHttpRequest()
xhr.open('POST', '/api/v1/book-dock/upload')
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`)
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) item.progress = Math.round((e.loaded / e.total) * 100)
}
xhr.onload = () => {
if (xhr.status === 201) {
item.status = 'done'
item.progress = 100
try {
item.bookDockFile = JSON.parse(xhr.responseText) as BookDockFile
} catch {
// response parsing optional
}
} else {
item.status = 'error'
try {
item.error = (JSON.parse(xhr.responseText) as { message?: string }).message ?? 'Upload failed'
} catch {
item.error = `Upload failed (${xhr.status})`
}
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) item.progress = Math.round((e.loaded / e.total) * 100)
}
// loadend follows load, error, abort and timeout alike, so a request that ends any other way
// than load cannot leave the queue waiting forever.
xhr.onloadend = () => {
if (xhr.status === 201) {
item.status = 'done'
item.progress = 100
try {
item.bookDockFile = JSON.parse(xhr.responseText) as BookDockFile
} catch {
// response parsing optional
}
} else {
fail(item, responseFailure(xhr))
}
resolve()
}
const formData = new FormData()
formData.append('file', item.file)
xhr.send(formData)
} catch {
fail(item, { kind: 'network' })
resolve()
}
xhr.onerror = () => {
item.status = 'error'
item.error = 'Network error'
resolve()
}
item.status = 'uploading'
xhr.send(formData)
})
}
const files = ref<UploadItem[]>([])
const isUploading = ref(false)
const failures = computed(() => files.value.filter((f) => f.status === 'error'))
export function useBookDockUpload() {
function addFiles(fileList: FileList | File[]) {
for (const file of fileList) {
const error = validateFile(file)
const failure = validateFile(file)
const item: UploadItem = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
file,
status: error ? 'error' : 'pending',
status: failure ? 'error' : 'pending',
progress: 0,
error: error ?? undefined,
failure: failure ?? undefined,
}
files.value.push(item)
}
@@ -96,26 +131,45 @@ export function useBookDockUpload() {
function processQueue() {
if (isUploading.value) return
if (!files.value.some((f) => f.status === 'pending')) return
isUploading.value = true
void drainQueue()
}
async function drainQueue() {
while (true) {
const pending = files.value.filter((f) => f.status === 'pending').slice(0, CONCURRENCY)
if (pending.length === 0) break
await Promise.all(pending.map(uploadSingle))
try {
while (true) {
const pending = files.value.filter((f) => f.status === 'pending').slice(0, CONCURRENCY)
if (pending.length === 0) break
await Promise.all(pending.map(uploadSingle))
}
} finally {
// Nothing reads a finished upload, and holding its File keeps it alive for the session.
files.value = files.value.filter((f) => f.status !== 'done')
isUploading.value = false
}
isUploading.value = false
}
function clearCompleted() {
files.value = files.value.filter((f) => f.status !== 'done')
function retryFailure(id: string) {
const item = files.value.find((f) => f.id === id)
if (!item || item.status !== 'error' || !item.failure || !isRetryableUploadFailure(item.failure)) return
item.status = 'pending'
item.failure = undefined
item.progress = 0
processQueue()
}
function dismissFailure(id: string) {
files.value = files.value.filter((f) => f.id !== id || f.status !== 'error')
}
function dismissAllFailures() {
files.value = files.value.filter((f) => f.status !== 'error')
}
function clearAll() {
files.value = []
}
return { files, isUploading, addFiles, clearCompleted, clearAll }
return { files, failures, isUploading, addFiles, retryFailure, dismissFailure, dismissAllFailures, clearAll }
}
@@ -0,0 +1,112 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { UploadErrorCode } from '@bookorbit/types'
import { i18n } from '@/i18n'
import en from '@/locales/en.json'
import { describeUploadFailure, isRetryableUploadFailure, type BookDockUploadFailure, type UploadFailureReason } from '../upload-failure'
const MB = 1024 * 1024
function server(overrides: Partial<Extract<BookDockUploadFailure, { kind: 'server' }>> = {}): BookDockUploadFailure {
return { kind: 'server', status: 500, errorCode: null, message: null, fromBookOrbit: true, ...overrides }
}
function render(reason: UploadFailureReason): string {
if ('text' in reason) return reason.text
return i18n.global.t(reason.key, reason.params ?? {})
}
function hasSourceMessage(key: string): boolean {
let node: unknown = en
for (const part of key.split('.')) {
if (typeof node !== 'object' || node === null) return false
node = (node as Record<string, unknown>)[part]
}
return typeof node === 'string'
}
describe('describeUploadFailure', () => {
it('names the rejected extension', () => {
expect(render(describeUploadFailure({ kind: 'unsupported', extension: 'docx' }))).toBe('Unsupported file type: .docx')
})
it('explains a file without any extension instead of printing a bare dot', () => {
expect(render(describeUploadFailure({ kind: 'unsupported', extension: '' }))).toBe('The file has no extension, so its format is unknown')
})
it('formats the client-side limit as a localized size', () => {
expect(render(describeUploadFailure({ kind: 'tooLarge', limitBytes: 500 * MB }))).toBe('Larger than the 500 MB upload limit')
expect(render(describeUploadFailure({ kind: 'tooLarge', limitBytes: 4096 * MB }))).toBe('Larger than the 4 GB upload limit')
})
it('reports a network failure', () => {
expect(render(describeUploadFailure({ kind: 'network' }))).toBe('Could not reach the server')
})
it('translates the upload error codes the server sends', () => {
expect(
render(describeUploadFailure(server({ status: 413, errorCode: UploadErrorCode.TooLarge, message: 'File exceeds the 1 MB upload limit' }))),
).toBe('Larger than the server upload limit')
expect(render(describeUploadFailure(server({ status: 507, errorCode: UploadErrorCode.StorageFull, message: 'no space' })))).toBe(
'The server is out of storage space',
)
})
it('treats an uncoded 413 from BookOrbit as its own limit', () => {
expect(render(describeUploadFailure(server({ status: 413, message: 'request file too large' })))).toBe('Larger than the server upload limit')
})
it('blames a reverse proxy for a 413 that did not come from BookOrbit', () => {
expect(render(describeUploadFailure(server({ status: 413, fromBookOrbit: false })))).toBe(
'Rejected as too large before it reached BookOrbit, likely by a reverse proxy',
)
})
it('shows the server message when no translated reason fits', () => {
expect(describeUploadFailure(server({ status: 500, message: 'EACCES: permission denied' }))).toEqual({ text: 'EACCES: permission denied' })
})
it('falls back to the status when the response carried no message', () => {
expect(render(describeUploadFailure(server({ status: 502, fromBookOrbit: false })))).toBe('Upload failed (HTTP 502)')
})
it('ignores an error code it has no translation for', () => {
expect(describeUploadFailure(server({ status: 409, errorCode: 'SOMETHING_NEW', message: 'Already there' }))).toEqual({ text: 'Already there' })
})
it('only returns keys that exist in the English source catalog', () => {
const failures: BookDockUploadFailure[] = [
{ kind: 'unsupported', extension: 'txt' },
{ kind: 'unsupported', extension: '' },
{ kind: 'tooLarge', limitBytes: MB },
{ kind: 'network' },
server({ status: 413, errorCode: UploadErrorCode.TooLarge }),
server({ status: 507, errorCode: UploadErrorCode.StorageFull }),
server({ status: 413, fromBookOrbit: false }),
server({ status: 418 }),
]
const missing = failures.map(describeUploadFailure).filter((reason) => !('key' in reason) || !hasSourceMessage(reason.key))
expect(missing).toEqual([])
})
})
describe('isRetryableUploadFailure', () => {
it.each<[string, BookDockUploadFailure, boolean]>([
['network failure', { kind: 'network' }, true],
['server error', server({ status: 500 }), true],
['proxy gateway error', server({ status: 502, fromBookOrbit: false }), true],
['request timeout', server({ status: 408 }), true],
['rate limit', server({ status: 429 }), true],
['expired session', server({ status: 401 }), true],
['full disk', server({ status: 507, errorCode: UploadErrorCode.StorageFull }), true],
['unsupported extension', { kind: 'unsupported', extension: 'docx' }, false],
['over the client limit', { kind: 'tooLarge', limitBytes: MB }, false],
['over the server limit', server({ status: 413, errorCode: UploadErrorCode.TooLarge }), false],
['over a proxy limit', server({ status: 413, fromBookOrbit: false }), false],
['bad request', server({ status: 400 }), false],
['forbidden', server({ status: 403 }), false],
['conflict', server({ status: 409 }), false],
])('%s', (_label, failure, expected) => {
expect(isRetryableUploadFailure(failure)).toBe(expected)
})
})
@@ -0,0 +1,55 @@
import { UploadErrorCode } from '@bookorbit/types'
import { formatBytes } from '@/lib/formatting'
export type BookDockUploadFailure =
| { kind: 'unsupported'; extension: string }
| { kind: 'tooLarge'; limitBytes: number }
| {
kind: 'server'
status: number
errorCode: string | null
message: string | null
/** False when the error page came from something in front of BookOrbit, such as a reverse proxy. */
fromBookOrbit: boolean
}
| { kind: 'network' }
/** A translation key, or server-authored text shown as-is when no translated reason fits. */
export type UploadFailureReason = { key: string; params?: Record<string, string> } | { text: string }
const REASON_KEY_BY_ERROR_CODE: Partial<Record<string, string>> = {
[UploadErrorCode.TooLarge]: 'bookDock.uploadFailures.reason.tooLargeServer',
[UploadErrorCode.StorageFull]: 'bookDock.uploadFailures.reason.storageFull',
}
const RETRYABLE_STATUSES = new Set([401, 408, 429])
/** A retry can only help when the failure was transient; a rejected file fails the same way again. */
export function isRetryableUploadFailure(failure: BookDockUploadFailure): boolean {
if (failure.kind === 'network') return true
if (failure.kind !== 'server') return false
return failure.status >= 500 || RETRYABLE_STATUSES.has(failure.status)
}
export function describeUploadFailure(failure: BookDockUploadFailure): UploadFailureReason {
switch (failure.kind) {
case 'unsupported':
return failure.extension
? { key: 'bookDock.uploadFailures.reason.unsupported', params: { extension: failure.extension } }
: { key: 'bookDock.uploadFailures.reason.noExtension' }
case 'tooLarge':
return { key: 'bookDock.uploadFailures.reason.tooLarge', params: { limit: formatBytes(failure.limitBytes) } }
case 'network':
return { key: 'bookDock.uploadFailures.reason.network' }
case 'server': {
const key = failure.errorCode ? REASON_KEY_BY_ERROR_CODE[failure.errorCode] : undefined
if (key) return { key }
if (failure.status === 413) {
return { key: failure.fromBookOrbit ? 'bookDock.uploadFailures.reason.tooLargeServer' : 'bookDock.uploadFailures.reason.tooLargeProxy' }
}
if (failure.message) return { text: failure.message }
return { key: 'bookDock.uploadFailures.reason.status', params: { status: String(failure.status) } }
}
}
}
+16
View File
@@ -7054,6 +7054,22 @@
"nFailed": "{count} failed",
"nTotal": "{count} total"
},
"uploadFailures": {
"heading": "{count, plural, one {Could not upload # file} other {Could not upload # files}}",
"dismissAll": "Dismiss all",
"retry": "Retry uploading {name}",
"dismiss": "Dismiss {name}",
"reason": {
"unsupported": "Unsupported file type: .{extension}",
"noExtension": "The file has no extension, so its format is unknown",
"tooLarge": "Larger than the {limit} upload limit",
"tooLargeServer": "Larger than the server upload limit",
"tooLargeProxy": "Rejected as too large before it reached BookOrbit, likely by a reverse proxy",
"storageFull": "The server is out of storage space",
"network": "Could not reach the server",
"status": "Upload failed (HTTP {status})"
}
},
"fileList": {
"emptyTitle": "No files in Book Dock",
"emptyMessageDefault": "Upload files or drop them in the Book Dock folder",
+5 -9
View File
@@ -14,6 +14,7 @@ import { useBookDockUpload, SUPPORTED_FORMATS, SUPPORTED_FORMATS_ACCEPT } from '
import { useBookDockConflicts } from '@/features/book-dock/composables/useBookDockConflicts'
import { refetchBookDockMetadata } from '@/features/book-dock/api/book-dock.api'
import BookDockActionCluster from '@/features/book-dock/components/BookDockActionCluster.vue'
import BookDockUploadFailures from '@/features/book-dock/components/BookDockUploadFailures.vue'
import BookDockFilterBar from '@/features/book-dock/components/BookDockFilterBar.vue'
import BookDockFileListView from '@/features/book-dock/components/BookDockFileListView.vue'
import BookDockListBar from '@/features/book-dock/components/BookDockListBar.vue'
@@ -435,16 +436,9 @@ function onDrop(e: DragEvent) {
e.preventDefault()
dragCounter = 0
dragOver.value = false
// Unsupported files go through too, so the upload failures panel can say why they were refused.
const files = e.dataTransfer?.files
if (files?.length) {
const valid = [...files].filter((f) => {
const ext = f.name.split('.').pop()?.toLowerCase() ?? ''
return SUPPORTED_FORMATS.includes(ext)
})
if (valid.length) {
addFiles(valid)
}
}
if (files?.length) addFiles(files)
}
watch(isUploading, (uploading, was) => {
@@ -571,6 +565,8 @@ onUnmounted(() => {
</div>
</div>
<BookDockUploadFailures />
<BookDockFilterBar :active-view="activeView" :summary="summary" @view="setView" @search="setSearch" />
<!-- Statistics bar -->
@@ -27,8 +27,10 @@ vi.mock('@/features/book-dock/composables/useBookDockSummary', () => ({
vi.mock('@/features/book-dock/composables/useBookDockStatistics', () => ({
useBookDockStatistics: () => ({ statistics: ref(null), fetchStatistics: vi.fn<() => void>() }),
}))
const { addFilesMock } = vi.hoisted(() => ({ addFilesMock: vi.fn<(files: FileList | File[]) => void>() }))
vi.mock('@/features/book-dock/composables/useBookDockUpload', () => ({
useBookDockUpload: () => ({ addFiles: vi.fn<() => void>(), isUploading: ref(false) }),
useBookDockUpload: () => ({ addFiles: addFilesMock, isUploading: ref(false) }),
SUPPORTED_FORMATS: ['epub'],
SUPPORTED_FORMATS_ACCEPT: '.epub',
}))
@@ -56,3 +58,34 @@ describe('BookDockView pagination', () => {
wrapper.unmount()
})
})
describe('BookDockView uploads', () => {
it('shows the upload failures panel on the page', () => {
const wrapper = shallowMount(BookDockView)
expect(wrapper.findComponent({ name: 'BookDockUploadFailures' }).exists()).toBe(true)
wrapper.unmount()
})
it('hands every dropped file to the uploader, including ones it will refuse, so the refusal is reported', async () => {
addFilesMock.mockClear()
const wrapper = shallowMount(BookDockView)
const files = [new File(['x'], 'dune.epub'), new File(['x'], 'notes.docx')]
await wrapper.get('main').trigger('drop', { dataTransfer: { files } })
expect(addFilesMock).toHaveBeenCalledTimes(1)
expect([...addFilesMock.mock.calls[0]![0]].map((file) => file.name)).toEqual(['dune.epub', 'notes.docx'])
wrapper.unmount()
})
it('ignores a drop that carries no files', async () => {
addFilesMock.mockClear()
const wrapper = shallowMount(BookDockView)
await wrapper.get('main').trigger('drop', { dataTransfer: { files: [] } })
expect(addFilesMock).not.toHaveBeenCalled()
wrapper.unmount()
})
})