diff --git a/frontend/src/lib/components/forms/books-upload.svelte b/frontend/src/lib/components/forms/books-upload.svelte index 14ba1ed..fb75948 100644 --- a/frontend/src/lib/components/forms/books-upload.svelte +++ b/frontend/src/lib/components/forms/books-upload.svelte @@ -1,54 +1,31 @@ - - {#if uploadBooks.pending} -
- Uploading {uploadBooks.fields.files.value().length} files... - -
- {:else} - - Upload Books - + + + + Add books + + Files or a folder. A folder becomes one book per directory. + + - // Check if there are any validation issues - const issues = uploadBooks.fields.allIssues(); - if (issues && issues.length > 0) { - return; - } - - // Update library book count - const count = uploadBooks.result.total; - libraryState.libraries.find( - (lib) => uploadBooks.fields.library_id.value() == lib.id.toString() - )!.total += count; - - // Reset the files field - uploadBooks.fields.files.set([]); - rejected = []; - toast.success('Books successfully uploaded!'); - - if (navigateOnUpload) { - navigateToBooks(uploadBooks.result); - } - } catch (error) { - console.error('Failed to upload book: ', error); - toast.error('Failed to upload books'); - } - })} - bind:this={formEl} - enctype="multipart/form-data" - class="flex w-full flex-col gap-2 p-4" - > - - Select Library - +
+
+ Library + {#each libraryState.libraries as library (library.id)} - - {library.name} - + {library.name} {/each} +
- - + - {#if files.length > 0} -
- {files.length} ready to upload - - {displaySize(totalSize)} - -
- {/if} - -
- {#each files as file, idx (file.name)} -
-
- {file.name} - {displaySize(file.size)} -
- -
- {/each} + {#if files.length > 0} +
+ {files.length} ready to upload + + {displaySize(totalSize)} +
+ {/if} - {#if rejected.length > 0} -
-
- - {rejected.length} - {rejected.length === 1 ? 'file was' : 'files were'} skipped +
+ {#each files as file, idx (file.name)} + {@const location = splitPath(file.name)} +
+ +
+ {location.name} + + {#if location.dir} + {location.dir} + {/if} + {displaySize(file.size)} -
- {#if showRejected} -
    - {#each rejected as entry (entry.name)} -
  • - {entry.name} - {entry.reason} -
  • - {/each} -
- {/if} +
- {/if} + {/each} +
-
-
- - Auto upload on file drop - -
-
- - Navigate to book on upload + {#if rejected.length > 0} +
+
+ + {rejected.length} + {rejected.length === 1 ? 'file was' : 'files were'} skipped + +
+ {#if showRejected} +
    + {#each rejected as entry (entry.name)} +
  • + + {splitPath(entry.name).name} + + {entry.reason} +
  • + {/each} +
+ {/if}
- - {/if} + {/if} + +
+
+ + + Start as soon as books are added + + +
+
+ + + Open the book when a single one is added + +
+
+
diff --git a/frontend/src/lib/components/layout/upload-tray.svelte b/frontend/src/lib/components/layout/upload-tray.svelte new file mode 100644 index 0000000..8c66404 --- /dev/null +++ b/frontend/src/lib/components/layout/upload-tray.svelte @@ -0,0 +1,119 @@ + + +{#if queue.total > 0} + + +{/if} diff --git a/frontend/src/lib/state/upload-queue.svelte.ts b/frontend/src/lib/state/upload-queue.svelte.ts new file mode 100644 index 0000000..65eef67 --- /dev/null +++ b/frontend/src/lib/state/upload-queue.svelte.ts @@ -0,0 +1,226 @@ +import { getContext, setContext } from 'svelte'; +import { invalidate } from '$app/navigation'; + +import type { Book, PaginatedResponse } from '$lib/schema'; + +export type UploadStatus = 'queued' | 'uploading' | 'done' | 'failed'; + +export interface UploadJob { + id: string; + /** The book's folder, or the filename for a book that is a single file. */ + label: string; + libraryId: number | string; + files: File[]; + size: number; + status: UploadStatus; + error?: string; + book?: Book; +} + +export interface UploadSummary { + created: number; + failed: number; + firstBook?: Book; +} + +/** + * Matches the grouping in BookService.create_books_from_files: files are grouped + * by their parent directory, except those at the root, which each become their + * own book. Diverging from it would split one book across two records. + */ +function groupByBook(files: File[]): [key: string, files: File[]][] { + // A plain record rather than a Map: this is a throwaway local, and Svelte's + // lint rule steers any Map towards the reactive SvelteMap. + const groups: Record = {}; + + for (const file of files) { + const cut = file.name.lastIndexOf('/'); + const key = cut === -1 ? file.name : file.name.slice(0, cut); + + (groups[key] ??= []).push(file); + } + + return Object.entries(groups); +} + +function label(key: string) { + const cut = key.lastIndexOf('/'); + return cut === -1 ? key : key.slice(cut + 1); +} + +function warnBeforeUnload(event: BeforeUnloadEvent) { + event.preventDefault(); +} + +/** How long a clean run stays on screen before clearing itself. */ +const DISMISS_AFTER_MS = 6000; + +/** + * Uploads books one request at a time and keeps the result on screen. + * + * Lives above the dialog so a run outlives it — the dialog only adds to the + * queue, and the tray in the root layout is what reports on it. This is why + * uploads do not go through a remote function: one request per book is what + * makes progress real, bounds how much any single request buffers, and stops + * one bad file taking the whole import with it. + */ +export class UploadQueueState { + jobs = $state([]); + collapsed = $state(false); + + readonly total = $derived(this.jobs.length); + readonly done = $derived(this.jobs.filter((job) => job.status === 'done').length); + readonly failed = $derived(this.jobs.filter((job) => job.status === 'failed').length); + readonly active = $derived( + this.jobs.some((job) => job.status === 'queued' || job.status === 'uploading') + ); + readonly current = $derived(this.jobs.find((job) => job.status === 'uploading')); + readonly settled = $derived(this.done + this.failed); + + #running = false; + #dismissTimer: ReturnType | undefined; + #held = false; + + /** Adds one job per book and starts the runner if it is not already going. */ + enqueue( + libraryId: number | string, + files: File[], + onFinished?: (summary: UploadSummary) => void + ) { + const added = groupByBook(files).map(([key, group]) => ({ + id: `${libraryId}:${key}`, + label: label(key), + libraryId, + files: group, + size: group.reduce((sum, file) => sum + file.size, 0), + status: 'queued' as UploadStatus + })); + + if (added.length === 0) return; + + clearTimeout(this.#dismissTimer); + + // A finished run stays on screen until dismissed; adding to it starts fresh + // rather than appending to a report the reader has already read. + if (!this.active) this.jobs = []; + + this.jobs = [...this.jobs, ...added]; + this.collapsed = false; + + void this.#run(onFinished); + } + + dismiss() { + if (this.active) return; + clearTimeout(this.#dismissTimer); + this.jobs = []; + } + + /** + * Holds a finished run on screen while the pointer is over it. + * + * Without this the tray can vanish from under a reader who is part-way + * through reading which books were added. + */ + hold() { + this.#held = true; + clearTimeout(this.#dismissTimer); + } + + release() { + this.#held = false; + if (!this.active && this.failed === 0 && this.total > 0) this.#scheduleDismiss(); + } + + /** + * Clears itself only when everything worked. A run with failures stays until + * dismissed — it is the only record of what did not make it in, and the only + * place to retry from. + */ + #scheduleDismiss() { + clearTimeout(this.#dismissTimer); + if (this.#held) return; + + this.#dismissTimer = setTimeout(() => { + if (!this.active && this.failed === 0) this.jobs = []; + }, DISMISS_AFTER_MS); + } + + /** Re-queues everything that failed, so one bad book is not a reason to start over. */ + retryFailed() { + this.jobs = this.jobs.map((job) => + job.status === 'failed' ? { ...job, status: 'queued' as UploadStatus, error: undefined } : job + ); + void this.#run(); + } + + async #run(onFinished?: (summary: UploadSummary) => void) { + if (this.#running) return; + this.#running = true; + + // Nothing here is resumable, so leaving mid-run loses whatever is left. + window.addEventListener('beforeunload', warnBeforeUnload); + + let created = 0; + let firstBook: Book | undefined; + + try { + for (;;) { + const index = this.jobs.findIndex((job) => job.status === 'queued'); + if (index === -1) break; + + this.#patch(index, { status: 'uploading' }); + + try { + const body = new FormData(); + for (const file of this.jobs[index].files) body.append('files', file); + + const response = await fetch( + `/api/books/fromFiles?library_id=${encodeURIComponent(String(this.jobs[index].libraryId))}`, + { method: 'POST', body } + ); + + if (!response.ok) throw new Error(`The server returned ${response.status}`); + + const result: PaginatedResponse = await response.json(); + const book = result.items[0]; + + created += result.total ?? result.items.length; + firstBook ??= book; + + this.#patch(index, { status: 'done', book }); + } catch (error) { + // One bad book must not take the rest of the queue with it. + console.error(`Failed to upload ${this.jobs[index].label}`, error); + this.#patch(index, { + status: 'failed', + error: error instanceof Error ? error.message : 'Upload failed' + }); + } + } + } finally { + this.#running = false; + window.removeEventListener('beforeunload', warnBeforeUnload); + } + + if (created > 0) await invalidate('app:books'); + + if (this.failed === 0) this.#scheduleDismiss(); + + onFinished?.({ created, failed: this.failed, firstBook }); + } + + #patch(index: number, changes: Partial) { + this.jobs[index] = { ...this.jobs[index], ...changes }; + } +} + +const UPLOAD_QUEUE_KEY = Symbol('UPLOAD_QUEUE'); + +export function setUploadQueueState() { + return setContext(UPLOAD_QUEUE_KEY, new UploadQueueState()); +} + +export function getUploadQueueState() { + return getContext>(UPLOAD_QUEUE_KEY); +} diff --git a/frontend/src/routes/(root)/+layout.svelte b/frontend/src/routes/(root)/+layout.svelte index dd34fd2..6f9d39a 100644 --- a/frontend/src/routes/(root)/+layout.svelte +++ b/frontend/src/routes/(root)/+layout.svelte @@ -2,12 +2,14 @@ import { listBookshelves } from '$lib/api'; import AppSidebar from '$lib/components/layout/app-sidebar.svelte'; import SiteHeader from '$lib/components/layout/site-header.svelte'; + import UploadTray from '$lib/components/layout/upload-tray.svelte'; import { Loading } from '$lib/components/ui/command'; import * as Sidebar from '$lib/components/ui/sidebar/index.js'; import type { Library, PaginatedResponse } from '$lib/schema'; import { setBookOperationsState } from '$lib/state/bookOperations.svelte'; import { setBookshelfState } from '$lib/state/bookshelf.svelte'; import { setLibraryState } from '$lib/state/library.svelte.js'; + import { setUploadQueueState } from '$lib/state/upload-queue.svelte'; import { setThemeState } from '$lib/theme/theme.svelte'; import type { ThemeConfig } from '$lib/theme/presets'; @@ -30,6 +32,10 @@ const bookOps = setBookOperationsState(libraryState.activeLibrary!.id); const theme = setThemeState(untrack(() => data.theme)); + // Set here rather than beside the upload dialog so a running import survives + // the dialog closing and any navigation within the app shell. + setUploadQueueState(); + // Inline custom properties live on :root and so are mode-blind. When the // light/dark switch flips, rewrite them for the mode now showing. $effect(() => { @@ -52,3 +58,7 @@
+ + +