Files
chitai/frontend/src/lib/api/book.remote.ts
T
patrick 699a1a7fa2 feat: merge books from the duplicates screen and the toolbar
A workbench with the folded records in a rail, the survivor's fields live and
editable beside them, and per-field actions chosen by what kind of field it is.
Fields the records agree on stay out of the way. Reachable from a duplicate
group and from the selection toolbar, which is the only way to merge a pair the
detector never proposed.
2026-08-16 20:21:19 -04:00

233 lines
6.4 KiB
TypeScript

import { command, form, getRequestEvent, query } from '$app/server';
import { error } from '@sveltejs/kit';
import {
bookCoverUpload,
booksUpload,
bookQuerySchema,
deleteBookFilesSchema,
deleteBooksSchema,
editBookMetadataSchema,
updateBookProgressSchema,
duplicateDismissalSchema,
bookMergeSchema,
type Book,
type BooksUploadResult,
type DuplicateBookGroup,
bookFilesUpload
} from '$lib/schema/index';
import { stringCoerce, type PaginatedResponse } from '$lib/schema/common';
import { createQueryParams } from '$lib/utils';
/**
* The backend's own message for a failed response, rather than its JSON envelope.
*
* A refused duplicate answers 409 with a `detail` worth reading and the offending
* files in `extra`; passing the body through whole puts JSON in front of the reader.
*/
function detailOf(body: string): string {
try {
const parsed = JSON.parse(body);
return typeof parsed?.detail === 'string' ? parsed.detail : body;
} catch {
return body;
}
}
export const getBook = query(stringCoerce, async (id): Promise<Book> => {
const { locals } = getRequestEvent();
const response = await locals.api.get(`/books/${id}`);
if (!response.ok) {
if (response.status == 404) error(404, 'The book does not exist');
error(500, 'An unkown error occurred');
}
return await response.json();
});
export const listBooks = query(bookQuerySchema, async (data): Promise<PaginatedResponse<Book>> => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.get(`/books?${params.toString()}`);
if (!response.ok) error(500, 'An unkown error occurred');
return await response.json();
});
export const updateBookMetadata = form(editBookMetadataSchema, async (data): Promise<Book> => {
const { locals } = getRequestEvent();
const response = await locals.api.patch(`/books/${data.book_id}`, data);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json();
});
export const updateBookCover = form(bookCoverUpload, async ({ book_id, file }) => {
const { locals } = getRequestEvent();
const formData = new FormData();
formData.append('file', file);
const response = await locals.api.putMultipart(`/books/${book_id}/cover`, formData);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json();
});
export const uploadBooks = form(
booksUpload,
async ({ library_id, files }): Promise<BooksUploadResult> => {
const { locals } = getRequestEvent();
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await locals.api.postMultipart(
`/books/fromFiles?library_id=${library_id}`,
formData
);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
return await response.json();
}
);
export const uploadBookFiles = form(bookFilesUpload, async ({ book_id, files }) => {
const { locals } = getRequestEvent();
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await locals.api.postMultipart(`/books/${book_id}/files`, formData);
if (!response.ok) {
// 409 here means the file is already stored under a different book, which is
// something the reader can act on — so the message has to survive the trip.
error(response.status, detailOf(await response.text()));
}
return await response.json();
});
export const deleteBooks = command(deleteBooksSchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.delete(`/books?${params.toString()}`);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
});
export const deleteBookFiles = command(deleteBookFilesSchema, async ({ book_id, ...data }) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.delete(`/books/${book_id}/files?${params.toString()}`);
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
});
/**
* Books already in the library that look like copies of one another.
*
* Metadata only, so every group is a question rather than a verdict — which is why
* the screen it feeds offers a way to disagree.
*/
export const listDuplicateBooks = query(
stringCoerce,
async (libraryId): Promise<DuplicateBookGroup[]> => {
const { locals } = getRequestEvent();
const response = await locals.api.get(`/books/duplicate-books?library_id=${libraryId}`);
if (!response.ok) error(response.status, detailOf(await response.text()));
return await response.json();
}
);
/**
* Fold several books into one and delete the records folded in.
*
* Irreversible, so the caller is expected to have shown what is about to happen.
*/
export const mergeBooks = command(
bookMergeSchema,
async ({ library_id, ...data }): Promise<Book> => {
const { locals } = getRequestEvent();
const response = await locals.api.post(`/books/merge?library_id=${library_id}`, data);
if (!response.ok) error(response.status, detailOf(await response.text()));
return await response.json();
}
);
/** Record that two books are not the same book, so the pair stops being proposed. */
export const dismissDuplicateBooks = command(duplicateDismissalSchema, async (data) => {
const { locals } = getRequestEvent();
const response = await locals.api.post('/books/duplicate-books/dismissals', data);
if (!response.ok) error(response.status, detailOf(await response.text()));
});
/** Undo a dismissal, so the pair is proposed again. */
export const restoreDuplicateBooks = command(duplicateDismissalSchema, async (data) => {
const { locals } = getRequestEvent();
const params = createQueryParams(data);
const response = await locals.api.delete(
`/books/duplicate-books/dismissals?${params.toString()}`
);
if (!response.ok) error(response.status, detailOf(await response.text()));
});
export const updateBookProgress = command(
updateBookProgressSchema,
async ({ book_ids, ...data }) => {
const { locals } = getRequestEvent();
const params = createQueryParams({ book_ids: book_ids });
const response = await locals.api.post(`/books/progress?${params.toString()}`, { ...data });
if (!response.ok) {
const message = await response.text();
error(response.status, message);
}
}
);