feat: import a Calibre library
Reads metadata.db and copies the books into a library — from a zip uploaded on the library settings page, or from a path with `litestar calibre-import`. The source is never touched, and re-running only picks up what is new. Also names the formats mimetypes does not know: a Calibre library is full of MOBI and AZW3, and a null content type used to fail the book endpoint.
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { command, getRequestEvent, query } from '$app/server';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
import { stringCoerce } from '$lib/schema/common';
|
||||
import type { CalibreImport } from '$lib/schema/library';
|
||||
|
||||
/** The backend's own message for a failed response, rather than its JSON envelope. */
|
||||
function detailOf(body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
return typeof parsed?.detail === 'string' ? parsed.detail : body;
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an import has got to.
|
||||
*
|
||||
* A `query` rather than a `command` so it can be refreshed, but it is polled on a timer
|
||||
* rather than cached — the answer changes on its own.
|
||||
*
|
||||
* Starting an import is deliberately **not** here: the archive goes straight to the
|
||||
* backend through the proxy, so it never passes through this process. See the import
|
||||
* screen's `upload`.
|
||||
*/
|
||||
export const getCalibreImport = query(stringCoerce, async (jobId): Promise<CalibreImport> => {
|
||||
const { locals } = getRequestEvent();
|
||||
|
||||
const response = await locals.api.get(`/libraries/imports/${jobId}`);
|
||||
|
||||
if (!response.ok) error(response.status, detailOf(await response.text()));
|
||||
|
||||
return await response.json();
|
||||
});
|
||||
|
||||
/**
|
||||
* Ask an import to stop after the book it is on.
|
||||
*
|
||||
* Not an abort: a book abandoned mid-copy would leave files on disk with no row
|
||||
* describing them. Whatever it has imported stays imported.
|
||||
*/
|
||||
export const cancelCalibreImport = command(stringCoerce, async (jobId): Promise<CalibreImport> => {
|
||||
const { locals } = getRequestEvent();
|
||||
|
||||
const response = await locals.api.delete(`/libraries/imports/${jobId}`);
|
||||
|
||||
if (!response.ok) error(response.status, detailOf(await response.text()));
|
||||
|
||||
return await response.json();
|
||||
});
|
||||
@@ -2,6 +2,7 @@ export * from './auth.remote';
|
||||
export * from './author.remote';
|
||||
export * from './book.remote';
|
||||
export * from './bookshelf.remote';
|
||||
export * from './calibre-import.remote';
|
||||
export * from './library.remote';
|
||||
export * from './publisher.remote';
|
||||
export * from './tag.remote';
|
||||
|
||||
@@ -19,3 +19,6 @@ export const libraryCreateSchema = z.object({
|
||||
|
||||
export type LibraryQuerySchema = typeof libraryQuerySchema;
|
||||
export type LibraryCreateSchema = typeof libraryCreateSchema;
|
||||
|
||||
export type CalibreImport = components['schemas']['CalibreImportRead'];
|
||||
export type ImportFailure = components['schemas']['ImportFailureRead'];
|
||||
|
||||
+181
-1
@@ -230,6 +230,24 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/libraries/imports/{job_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** GetImport */
|
||||
get: operations["LibrariesImportsJobIdGetImport"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/** CancelImport */
|
||||
delete: operations["LibrariesImportsJobIdCancelImport"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/libraries": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -248,6 +266,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/libraries/{library_id}/imports/calibre/upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** UploadCalibreImport */
|
||||
post: operations["LibrariesLibraryIdImportsCalibreUploadUploadCalibreImport"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/access/me": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -791,6 +826,30 @@ export interface components {
|
||||
skipped: components["schemas"]["DuplicateFileRead"][];
|
||||
possible_duplicates?: components["schemas"]["PossibleDuplicateRead"][];
|
||||
};
|
||||
/** CalibreArchiveUpload */
|
||||
CalibreArchiveUpload: {
|
||||
/** Format: binary */
|
||||
archive: string;
|
||||
/** @default false */
|
||||
allow_duplicates: boolean;
|
||||
};
|
||||
/** CalibreImportRead */
|
||||
CalibreImportRead: {
|
||||
id: string;
|
||||
library_id: number;
|
||||
source: string;
|
||||
state: string;
|
||||
total: number;
|
||||
processed: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
current_title?: string | null;
|
||||
failures?: components["schemas"]["ImportFailureRead"][];
|
||||
/** @default 0 */
|
||||
possible_duplicates: number;
|
||||
error?: string | null;
|
||||
};
|
||||
/** DuplicateBookGroupRead */
|
||||
DuplicateBookGroupRead: {
|
||||
books: components["schemas"]["DuplicateBookRead"][];
|
||||
@@ -831,9 +890,15 @@ export interface components {
|
||||
path: string;
|
||||
hash: string;
|
||||
size: number;
|
||||
content_type: string;
|
||||
content_type?: string | null;
|
||||
readonly filename: string;
|
||||
};
|
||||
/** ImportFailureRead */
|
||||
ImportFailureRead: {
|
||||
calibre_id: number;
|
||||
title: string;
|
||||
reason: string;
|
||||
};
|
||||
/** KosyncDeviceCreate */
|
||||
KosyncDeviceCreate: {
|
||||
name: string;
|
||||
@@ -1686,6 +1751,80 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
LibrariesImportsJobIdGetImport: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
job_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Request fulfilled, document follows */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CalibreImportRead"];
|
||||
};
|
||||
};
|
||||
/** @description Bad request syntax or unsupported method */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
status_code: number;
|
||||
detail: string;
|
||||
extra?: null | {
|
||||
[key: string]: unknown;
|
||||
} | unknown[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
LibrariesImportsJobIdCancelImport: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
job_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Request fulfilled, document follows */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CalibreImportRead"];
|
||||
};
|
||||
};
|
||||
/** @description Bad request syntax or unsupported method */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
status_code: number;
|
||||
detail: string;
|
||||
extra?: null | {
|
||||
[key: string]: unknown;
|
||||
} | unknown[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
LibrariesListLibraries: {
|
||||
parameters: {
|
||||
query?: {
|
||||
@@ -1772,6 +1911,47 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
LibrariesLibraryIdImportsCalibreUploadUploadCalibreImport: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
library_id: number;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["CalibreArchiveUpload"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Request accepted, processing continues off-line */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["CalibreImportRead"];
|
||||
};
|
||||
};
|
||||
/** @description Bad request syntax or unsupported method */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
status_code: number;
|
||||
detail: string;
|
||||
extra?: null | {
|
||||
[key: string]: unknown;
|
||||
} | unknown[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
AccessMeGetUserInfo: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
||||
@@ -81,7 +81,10 @@ function isbnDigits(value: string) {
|
||||
}
|
||||
|
||||
function identifierKey(name: string) {
|
||||
return name.trim().toLowerCase().replace(/[\s_]+/g, '-');
|
||||
return name
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-');
|
||||
}
|
||||
|
||||
export function describeIdentifier(name: string, value: string) {
|
||||
@@ -111,4 +114,17 @@ export function getFileType(filename: string) {
|
||||
return extension.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* The formats there is a reader for. Everything else is download-only.
|
||||
*
|
||||
* A library imported from elsewhere carries MOBI, AZW3 and CBZ files, which are
|
||||
* legitimate to store and to download but have nowhere to open — the reader routes
|
||||
* are `read/epub` and `read/pdf` and there is no third one.
|
||||
*/
|
||||
const READABLE_FILE_TYPES = ['EPUB', 'PDF'];
|
||||
|
||||
export function isReadable(filename: string) {
|
||||
return READABLE_FILE_TYPES.includes(getFileType(filename));
|
||||
}
|
||||
|
||||
export const pluck = (array: [], key: string) => array.map((obj) => obj?.[key]);
|
||||
|
||||
@@ -18,7 +18,13 @@
|
||||
PlusIcon,
|
||||
Trash2
|
||||
} from '@lucide/svelte';
|
||||
import { describeIdentifier, formatFileSize, getFileType, sortIdentifiers } from '$lib/utils.js';
|
||||
import {
|
||||
describeIdentifier,
|
||||
formatFileSize,
|
||||
getFileType,
|
||||
isReadable,
|
||||
sortIdentifiers
|
||||
} from '$lib/utils.js';
|
||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
|
||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte.js';
|
||||
import { getLibraryState } from '$lib/state/library.svelte.js';
|
||||
@@ -52,6 +58,11 @@
|
||||
book.progress?.percentage ? Math.round(book.progress.percentage * 100) : 0
|
||||
);
|
||||
|
||||
// Only the files there is a reader for. A book can be stored in a format Chitai
|
||||
// cannot open — a Calibre library is full of MOBI and AZW3 — and offering to read
|
||||
// one opened a window that did nothing at all.
|
||||
const readableFiles = $derived(book.files.filter((file) => isReadable(file.filename)));
|
||||
|
||||
// The primary action states what it will actually do.
|
||||
const readLabel = $derived(
|
||||
book.progress?.completed
|
||||
@@ -86,6 +97,10 @@
|
||||
'inline-flex items-center gap-2 rounded-lg bg-primary-foreground px-4 py-2 text-sm font-semibold text-primary tabular-nums transition-colors hover:bg-primary-foreground/90';
|
||||
const bandGhost =
|
||||
'inline-flex items-center gap-2 rounded-lg border border-primary-foreground/35 px-4 py-2 text-sm font-medium transition-colors hover:bg-primary-foreground/10';
|
||||
|
||||
// With nothing to read, downloading is the only thing left to do — so it takes the
|
||||
// primary slot rather than leaving the band with no filled button on it.
|
||||
const bandDownload = $derived(readableFiles.length ? bandGhost : bandPrimary);
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col overflow-y-auto">
|
||||
@@ -132,9 +147,13 @@
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Primary actions live on the band, not in a side rail -->
|
||||
<!--
|
||||
Primary actions live on the band, not in a side rail. Read and Download
|
||||
are counted separately: everything can be downloaded, only EPUB and PDF
|
||||
can be opened, so a book can have two files and one way to read it.
|
||||
-->
|
||||
<div class="mt-5 flex flex-wrap items-center gap-2">
|
||||
{#if book.files.length > 1}
|
||||
{#if readableFiles.length > 1}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={bandPrimary}>
|
||||
<BookOpenText class="size-4" />
|
||||
@@ -144,7 +163,7 @@
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
|
||||
<DropdownMenu.Separator />
|
||||
{#each book.files as file (file.id)}
|
||||
{#each readableFiles as file (file.id)}
|
||||
<DropdownMenu.Item onclick={() => openBookInReader(file)}>
|
||||
{getFileType(file.filename)}
|
||||
</DropdownMenu.Item>
|
||||
@@ -152,9 +171,20 @@
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else if readableFiles.length === 1}
|
||||
<button
|
||||
type="button"
|
||||
class={bandPrimary}
|
||||
onclick={() => openBookInReader(readableFiles[0])}
|
||||
>
|
||||
<BookOpenText class="size-4" />
|
||||
{readLabel}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if book.files.length > 1}
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger class={bandGhost}>
|
||||
<DropdownMenu.Trigger class={bandDownload}>
|
||||
<Download class="size-4" />
|
||||
Download
|
||||
</DropdownMenu.Trigger>
|
||||
@@ -181,16 +211,7 @@
|
||||
{:else if book.files.length === 1}
|
||||
<button
|
||||
type="button"
|
||||
class={bandPrimary}
|
||||
onclick={() => openBookInReader(book.files[0])}
|
||||
>
|
||||
<BookOpenText class="size-4" />
|
||||
{readLabel}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class={bandGhost}
|
||||
class={bandDownload}
|
||||
onclick={() =>
|
||||
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||
>
|
||||
@@ -491,7 +512,7 @@
|
||||
>
|
||||
<Table.Cell class="text-right">
|
||||
<div class="flex justify-end gap-1">
|
||||
{#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'}
|
||||
{#if isReadable(file.filename)}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||
<Tooltip.Trigger
|
||||
|
||||
@@ -13,14 +13,14 @@
|
||||
libraryState.libraries.find((lib) => String(lib.id) === page.params.libraryId)
|
||||
);
|
||||
|
||||
// Duplicates is the only section today. The strip exists so General and a
|
||||
// danger zone have an obvious place to land; neither is built yet, and an
|
||||
// empty tab is worse than no tab.
|
||||
// The strip exists so General and a danger zone have an obvious place to land;
|
||||
// neither is built yet, and an empty tab is worse than no tab.
|
||||
//
|
||||
// Active state is matched on route id, not pathname, for the reason spelled
|
||||
// out in settings/+layout.svelte.
|
||||
const sections = [
|
||||
{ title: 'Duplicates', routeId: '/(root)/settings/libraries/[libraryId]/duplicates' }
|
||||
{ title: 'Duplicates', routeId: '/(root)/settings/libraries/[libraryId]/duplicates' },
|
||||
{ title: 'Import', routeId: '/(root)/settings/libraries/[libraryId]/import' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { BookOpen, Loader2, TriangleAlert, Upload } from '@lucide/svelte';
|
||||
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import * as Field from '$lib/components/ui/field/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { Progress } from '$lib/components/ui/progress/index.js';
|
||||
import { cancelCalibreImport, getCalibreImport } from '$lib/api/calibre-import.remote';
|
||||
import { formatFileSize } from '$lib/utils';
|
||||
import type { CalibreImport } from '$lib/schema/library';
|
||||
|
||||
const libraryId = $derived(page.params.libraryId!);
|
||||
|
||||
let archive = $state<File | null>(null);
|
||||
let allowDuplicates = $state(false);
|
||||
|
||||
let job = $state<CalibreImport | null>(null);
|
||||
|
||||
let busy = $state(false);
|
||||
let problem = $state<string | null>(null);
|
||||
|
||||
/** How much of the archive has reached the server, 0–1, while it is going up. */
|
||||
let uploaded = $state<number | null>(null);
|
||||
|
||||
/** How often a running import is asked where it has got to. */
|
||||
const POLL_MS = 1000;
|
||||
let poll: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const running = $derived(job?.state === 'running');
|
||||
|
||||
const percent = $derived(
|
||||
job && job.total > 0 ? Math.round((job.processed / job.total) * 100) : 0
|
||||
);
|
||||
|
||||
function stopPolling() {
|
||||
clearInterval(poll);
|
||||
poll = undefined;
|
||||
}
|
||||
|
||||
onDestroy(stopPolling);
|
||||
|
||||
function pick(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
archive = input.files?.[0] ?? null;
|
||||
problem = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend's own message for a failed proxied response.
|
||||
*
|
||||
* The proxy wraps the upstream body in SvelteKit's error envelope, so the useful
|
||||
* `detail` is one or two layers down.
|
||||
*/
|
||||
function detailOf(body: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(body);
|
||||
|
||||
if (typeof parsed?.detail === 'string') return parsed.detail;
|
||||
if (typeof parsed?.message === 'string') return detailOf(parsed.message);
|
||||
} catch {
|
||||
// Not JSON — the raw text is the best there is.
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a running import until it lands.
|
||||
*
|
||||
* Polled rather than pushed: the job lives on the server, so this survives a reload
|
||||
* and does not depend on the tab that started it staying open.
|
||||
*/
|
||||
function follow(jobId: string) {
|
||||
stopPolling();
|
||||
|
||||
poll = setInterval(async () => {
|
||||
try {
|
||||
job = await getCalibreImport(jobId);
|
||||
} catch (error) {
|
||||
problem = error instanceof Error ? error.message : 'Lost track of the import';
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.state !== 'running') {
|
||||
stopPolling();
|
||||
announce(job);
|
||||
}
|
||||
}, POLL_MS);
|
||||
}
|
||||
|
||||
function announce(finished: CalibreImport) {
|
||||
const created = `${finished.created} book${finished.created === 1 ? '' : 's'} imported`;
|
||||
|
||||
if (finished.state === 'failed') toast.error(finished.error ?? 'The import failed');
|
||||
else if (finished.state === 'cancelled') toast.info(`Import stopped — ${created}`);
|
||||
else if (finished.failed > 0) toast.warning(`${created}, ${finished.failed} failed`);
|
||||
else toast.success(created);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the archive and start the import it becomes.
|
||||
*
|
||||
* `XMLHttpRequest` rather than `fetch` for the one thing fetch cannot do: report how
|
||||
* much of the body has gone up. On a large archive that is the only progress there is
|
||||
* for minutes at a time.
|
||||
*
|
||||
* It goes through the proxy so the browser streams straight to the backend — a remote
|
||||
* function would put the whole archive through the SvelteKit process first.
|
||||
*/
|
||||
function send(file: File): Promise<CalibreImport> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
|
||||
request.open('POST', `/api/libraries/${libraryId}/imports/calibre/upload`);
|
||||
|
||||
request.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable) uploaded = event.loaded / event.total;
|
||||
});
|
||||
|
||||
request.addEventListener('load', () => {
|
||||
if (request.status >= 200 && request.status < 300) {
|
||||
resolve(JSON.parse(request.responseText));
|
||||
} else {
|
||||
reject(new Error(detailOf(request.responseText)));
|
||||
}
|
||||
});
|
||||
|
||||
request.addEventListener('error', () => reject(new Error('The upload failed')));
|
||||
request.addEventListener('abort', () => reject(new Error('The upload was stopped')));
|
||||
|
||||
const body = new FormData();
|
||||
body.append('archive', file);
|
||||
body.append('allow_duplicates', String(allowDuplicates));
|
||||
|
||||
request.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!archive) return;
|
||||
|
||||
busy = true;
|
||||
problem = null;
|
||||
uploaded = 0;
|
||||
|
||||
try {
|
||||
job = await send(archive);
|
||||
follow(job.id);
|
||||
} catch (error) {
|
||||
problem = error instanceof Error ? error.message : 'Could not upload the archive';
|
||||
} finally {
|
||||
busy = false;
|
||||
uploaded = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (!job) return;
|
||||
|
||||
try {
|
||||
job = await cancelCalibreImport(job.id);
|
||||
} catch (error) {
|
||||
problem = error instanceof Error ? error.message : 'Could not stop the import';
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
stopPolling();
|
||||
job = null;
|
||||
problem = null;
|
||||
archive = null;
|
||||
uploaded = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Import from Calibre</Card.Title>
|
||||
<Card.Description>
|
||||
Zip your Calibre library folder and upload it here. Nothing is taken from the original — the
|
||||
books are copied in, and uploading the same library again only picks up what is new.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
<Field.Field>
|
||||
<Field.Label for="archive">Zipped Calibre library</Field.Label>
|
||||
<!--
|
||||
The native file button inherits the input's own text styling, which leaves
|
||||
"Browse…" looking like the first half of the sentence "Browse… No file
|
||||
selected." The `file:` variants target ::file-selector-button, so it can be
|
||||
made to read as a button without replacing the input with a custom one.
|
||||
Styled here rather than in `ui/input`, which is generated.
|
||||
-->
|
||||
<Input
|
||||
id="archive"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onchange={pick}
|
||||
disabled={running || busy}
|
||||
class="py-1.5 file:mr-3 file:cursor-pointer file:rounded-sm file:border file:border-input file:bg-secondary file:px-2 file:py-0.5 file:text-secondary-foreground file:hover:bg-secondary/80"
|
||||
/>
|
||||
<Field.Description>
|
||||
The zip has to contain <code class="font-mono text-xs">metadata.db</code> — zip the whole Calibre
|
||||
folder rather than just the books.
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
{#if archive}
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{archive.name} · {formatFileSize(archive.size)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox id="allow-duplicates" bind:checked={allowDuplicates} disabled={running || busy} />
|
||||
<label for="allow-duplicates" class="text-sm">
|
||||
Import books this library already holds
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if uploaded !== null}
|
||||
<div class="flex flex-col gap-1">
|
||||
<Progress value={Math.round(uploaded * 100)} />
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Uploading · {Math.round(uploaded * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if problem}
|
||||
<p class="flex items-center gap-2 text-sm text-destructive">
|
||||
<TriangleAlert class="size-4 shrink-0" />
|
||||
{problem}
|
||||
</p>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
|
||||
<Card.Footer class="gap-2">
|
||||
{#if running}
|
||||
<Button variant="outline" onclick={stop}>Stop after this book</Button>
|
||||
{:else if job}
|
||||
<Button onclick={reset}>Import another</Button>
|
||||
{:else}
|
||||
<!--
|
||||
No preview step: the catalogue cannot be read until the archive is on the
|
||||
server, and by then it has been carried across anyway. Unpacking it is what
|
||||
refuses an archive that is not a Calibre library.
|
||||
-->
|
||||
<Button onclick={start} disabled={busy || !archive}>
|
||||
{#if busy}
|
||||
<Loader2 class="size-4 animate-spin" />
|
||||
{:else}
|
||||
<Upload class="size-4" />
|
||||
{/if}
|
||||
Upload and import
|
||||
</Button>
|
||||
{/if}
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
{#if job}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
{#if running}<Loader2 class="size-4 animate-spin" />{/if}
|
||||
{running ? 'Importing' : job.state === 'failed' ? 'Import failed' : 'Import finished'}
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
{#if running && job.current_title}
|
||||
{job.processed} of {job.total} · {job.current_title}
|
||||
{:else if job.state === 'cancelled'}
|
||||
Stopped after {job.processed} of {job.total}. Everything imported is complete.
|
||||
{:else if job.state === 'failed'}
|
||||
{job.error ?? 'The import stopped before it finished.'}
|
||||
{:else}
|
||||
{job.processed} of {job.total} books considered.
|
||||
{/if}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
<Progress value={percent} />
|
||||
|
||||
<div class="grid grid-cols-3 gap-3 text-sm">
|
||||
<div>
|
||||
<p class="font-mono text-lg tabular-nums">{job.created}</p>
|
||||
<p class="text-muted-foreground">imported</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-mono text-lg tabular-nums">{job.skipped}</p>
|
||||
<p class="text-muted-foreground">already here</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-mono text-lg tabular-nums">{job.failed}</p>
|
||||
<p class="text-muted-foreground">failed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if job.created > 0}
|
||||
<a
|
||||
href={resolve('/(root)/(library)/library/[libraryId]/view', { libraryId })}
|
||||
class="inline-flex w-fit items-center gap-2 text-sm hover:underline"
|
||||
>
|
||||
<BookOpen class="size-4" />
|
||||
See the books
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<!--
|
||||
An import into a library that already holds books is the likeliest way to
|
||||
end up with two records for one book, and that screen already handles them.
|
||||
-->
|
||||
{#if job.possible_duplicates > 0}
|
||||
<div class="rounded-lg border p-3 text-sm">
|
||||
<p>
|
||||
{job.possible_duplicates} imported book{job.possible_duplicates === 1 ? '' : 's'}
|
||||
look{job.possible_duplicates === 1 ? 's' : ''} like something this library already had.
|
||||
They were imported all the same — a metadata match is a guess.
|
||||
</p>
|
||||
<a
|
||||
href={resolve('/(root)/settings/libraries/[libraryId]/duplicates', { libraryId })}
|
||||
class="mt-2 inline-block font-medium hover:underline"
|
||||
>
|
||||
Review duplicates
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if job.failures?.length}
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="text-sm font-medium">Books that could not be imported</p>
|
||||
<div class="overflow-x-auto rounded-lg border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head class="w-[80px]">Calibre</Table.Head>
|
||||
<Table.Head>Title</Table.Head>
|
||||
<Table.Head>Reason</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each job.failures as failure (failure.calibre_id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-xs">#{failure.calibre_id}</Table.Cell>
|
||||
<Table.Cell>{failure.title}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{failure.reason}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -30,6 +30,20 @@ async function handleResponse(response: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The body to forward, as fetch init options.
|
||||
*
|
||||
* Passed through as a stream rather than read into memory first: this process should
|
||||
* never hold a whole upload, which for a zipped Calibre library could be many
|
||||
* gigabytes. A request with no body contributes nothing, since `duplex` without a body
|
||||
* is rejected.
|
||||
*/
|
||||
function bodyOf(request: Request) {
|
||||
if (!request.body) return {};
|
||||
|
||||
return { body: request.body, duplex: 'half' } as RequestInit;
|
||||
}
|
||||
|
||||
// Shared function to prepare the request with authentication
|
||||
function prepareRequest(locals: App.Locals, request: Request) {
|
||||
const token = locals.authToken || 'server-default-token';
|
||||
@@ -76,13 +90,14 @@ export const POST: RequestHandler = async ({ params, locals, fetch, request, url
|
||||
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
||||
const headers = prepareRequest(locals, request);
|
||||
|
||||
// Get the request body
|
||||
const body = await request.arrayBuffer();
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body
|
||||
// Streamed, not buffered. `arrayBuffer()` held the whole upload in this
|
||||
// process before forwarding a byte of it, which is survivable for one book
|
||||
// and not for a zipped Calibre library. `duplex: 'half'` is required by the
|
||||
// fetch spec whenever the body is a stream.
|
||||
...bodyOf(request)
|
||||
});
|
||||
|
||||
return handleResponse(response);
|
||||
@@ -100,13 +115,10 @@ export const PATCH: RequestHandler = async ({ params, locals, fetch, request, ur
|
||||
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
||||
const headers = prepareRequest(locals, request);
|
||||
|
||||
// Get the request body
|
||||
const body = await request.arrayBuffer();
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body
|
||||
...bodyOf(request)
|
||||
});
|
||||
|
||||
return handleResponse(response);
|
||||
|
||||
Reference in New Issue
Block a user