feat: review and dismiss duplicate books
Adds the per-library review screen, a muted note in the upload tray for a book that may already be held, and the remote functions behind them. The tray note is deliberately weaker than the file-level one, which means something stronger.
This commit is contained in:
@@ -8,8 +8,10 @@ import {
|
||||
deleteBooksSchema,
|
||||
editBookMetadataSchema,
|
||||
updateBookProgressSchema,
|
||||
duplicateDismissalSchema,
|
||||
type Book,
|
||||
type BooksUploadResult,
|
||||
type DuplicateBookGroup,
|
||||
bookFilesUpload
|
||||
} from '$lib/schema/index';
|
||||
import { stringCoerce, type PaginatedResponse } from '$lib/schema/common';
|
||||
@@ -153,6 +155,47 @@ export const deleteBookFiles = command(deleteBookFilesSchema, async ({ book_id,
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
);
|
||||
|
||||
/** 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 }) => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||
import { getLibraryState } from '$lib/state/library.svelte';
|
||||
import { House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
|
||||
import { CopyCheck, House, LibraryBig, Rows3, ChevronRightIcon } from '@lucide/svelte';
|
||||
|
||||
const libraryState = getLibraryState();
|
||||
const bookshelfState = getBookshelfState();
|
||||
@@ -33,13 +33,22 @@
|
||||
title: 'Home',
|
||||
icon: House,
|
||||
routeId: '/(root)/(library)/library/[libraryId]',
|
||||
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') })
|
||||
path: (id?: number) =>
|
||||
resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') })
|
||||
},
|
||||
{
|
||||
title: 'Library',
|
||||
icon: LibraryBig,
|
||||
routeId: '/(root)/(library)/library/[libraryId]/view',
|
||||
path: (id?: number) => resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
|
||||
path: (id?: number) =>
|
||||
resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
|
||||
},
|
||||
{
|
||||
title: 'Duplicates',
|
||||
icon: CopyCheck,
|
||||
routeId: '/(root)/(library)/library/[libraryId]/duplicates',
|
||||
path: (id?: number) =>
|
||||
resolve('/(root)/(library)/library/[libraryId]/duplicates', { libraryId: String(id ?? '') })
|
||||
},
|
||||
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
|
||||
];
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||
import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
|
||||
import type { DuplicateFile } from '$lib/schema';
|
||||
import type { DuplicateBook, DuplicateFile } from '$lib/schema';
|
||||
import { formatFileSize } from '$lib/utils';
|
||||
|
||||
const queue = getUploadQueueState();
|
||||
@@ -49,6 +49,20 @@
|
||||
function noteTarget(duplicates: DuplicateFile[]) {
|
||||
return duplicates.length === 1 ? duplicates[0].book_id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the reader needs to know about a book that went in and may already be here.
|
||||
*
|
||||
* Worded weaker than the file-level "Already in …" on purpose. That one means the
|
||||
* library holds these exact bytes; this one means the metadata agrees, which a
|
||||
* second edition, a translation and a re-scan all do. Nothing was refused.
|
||||
*/
|
||||
function possibleNote(candidates: DuplicateBook[]) {
|
||||
if (candidates.length > 1)
|
||||
return `Might already be in your library, ${candidates.length} times`;
|
||||
|
||||
return `Might already be in your library — ${candidates[0].title}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if queue.total > 0}
|
||||
@@ -109,6 +123,7 @@
|
||||
{#each queue.jobs as job (job.id)}
|
||||
{@const duplicates = job.duplicates ?? []}
|
||||
{@const target = noteTarget(duplicates)}
|
||||
{@const possible = job.possibleDuplicates ?? []}
|
||||
<li class="flex min-w-0 items-center gap-2 px-3 py-2">
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm" title={job.label}>{job.label}</span>
|
||||
@@ -136,6 +151,26 @@
|
||||
<span class="truncate">{duplicateNote(duplicates)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else if possible.length > 0}
|
||||
<!--
|
||||
A guess, not a fact, so it says so and stops there: the book was
|
||||
stored, and the library's duplicates screen is where a reader
|
||||
decides what to do about it.
|
||||
-->
|
||||
<span class="block min-w-0 text-[10px] text-muted-foreground">
|
||||
{#if possible.length === 1}
|
||||
<a
|
||||
href={resolve('/(root)/(library)/book/[bookId]', {
|
||||
bookId: String(possible[0].book_id)
|
||||
})}
|
||||
class="truncate underline underline-offset-2"
|
||||
>
|
||||
{possibleNote(possible)}
|
||||
</a>
|
||||
{:else}
|
||||
<span class="truncate">{possibleNote(possible)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{formatFileSize(job.size)}
|
||||
|
||||
@@ -15,6 +15,27 @@ export type BookProgress = components['schemas']['BookProgressRead'];
|
||||
export type DuplicateFile = components['schemas']['DuplicateFileRead'];
|
||||
export type BooksUploadResult = components['schemas']['BooksUploadResult'];
|
||||
|
||||
/**
|
||||
* A stored book that may be the same book as another one.
|
||||
*
|
||||
* Weaker than `DuplicateFile`, and deliberately so: that one means the library holds
|
||||
* these exact bytes, this one means the metadata agrees. A second edition and a
|
||||
* translation both look like this, so nothing is ever refused on the strength of it.
|
||||
*/
|
||||
export type DuplicateBook = components['schemas']['DuplicateBookRead'];
|
||||
|
||||
/** A book that was imported, together with what it might be a second copy of. */
|
||||
export type PossibleDuplicate = components['schemas']['PossibleDuplicateRead'];
|
||||
|
||||
/** Books the library holds that all look like copies of one book. */
|
||||
export type DuplicateBookGroup = components['schemas']['DuplicateBookGroupRead'];
|
||||
|
||||
/** Mirrors DuplicateDismissal in backend/src/chitai/schemas/book.py */
|
||||
export const duplicateDismissalSchema = z.object({
|
||||
book_a_id: z.coerce.number(),
|
||||
book_b_id: z.coerce.number()
|
||||
});
|
||||
|
||||
export const bookQuerySchema = commonQuerySchema.extend({
|
||||
libraries: stringArrayCoerce,
|
||||
authors: stringArrayCoerce,
|
||||
@@ -106,4 +127,5 @@ export type BookQuery = z.infer<typeof bookQuerySchema>;
|
||||
export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
|
||||
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
|
||||
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
|
||||
export type DuplicateDismissal = z.infer<typeof duplicateDismissalSchema>;
|
||||
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
|
||||
|
||||
+174
-4
@@ -22,7 +22,7 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/books/duplicates": {
|
||||
"/books/duplicate-files": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
@@ -31,8 +31,8 @@ export interface paths {
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** CheckDuplicates */
|
||||
post: operations["BooksDuplicatesCheckDuplicates"];
|
||||
/** CheckDuplicateFiles */
|
||||
post: operations["BooksDuplicateFilesCheckDuplicateFiles"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
@@ -75,6 +75,24 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/books/duplicate-books/dismissals": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** DismissDuplicateBooks */
|
||||
post: operations["BooksDuplicateBooksDismissalsDismissDuplicateBooks"];
|
||||
/** RestoreDuplicateBooks */
|
||||
delete: operations["BooksDuplicateBooksDismissalsRestoreDuplicateBooks"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/books/{book_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -127,6 +145,23 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/books/duplicate-books": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** ListDuplicateBooks */
|
||||
get: operations["BooksDuplicateBooksListDuplicateBooks"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/books/progress": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -731,6 +766,25 @@ export interface components {
|
||||
BooksUploadResult: {
|
||||
created: components["schemas"]["BookRead"][];
|
||||
skipped: components["schemas"]["DuplicateFileRead"][];
|
||||
possible_duplicates?: components["schemas"]["PossibleDuplicateRead"][];
|
||||
};
|
||||
/** DuplicateBookGroupRead */
|
||||
DuplicateBookGroupRead: {
|
||||
books: components["schemas"]["DuplicateBookRead"][];
|
||||
};
|
||||
/** DuplicateBookRead */
|
||||
DuplicateBookRead: {
|
||||
book_id: number;
|
||||
title: string;
|
||||
authors: string[];
|
||||
library_id: number;
|
||||
cover_image?: string | null;
|
||||
matched_on: string[];
|
||||
};
|
||||
/** DuplicateDismissal */
|
||||
DuplicateDismissal: {
|
||||
book_a_id: number;
|
||||
book_b_id: number;
|
||||
};
|
||||
/** DuplicateFileRead */
|
||||
DuplicateFileRead: {
|
||||
@@ -815,6 +869,12 @@ export interface components {
|
||||
refresh_token?: string | null;
|
||||
expires_in?: number | null;
|
||||
};
|
||||
/** PossibleDuplicateRead */
|
||||
PossibleDuplicateRead: {
|
||||
book_id: number;
|
||||
title: string;
|
||||
candidates: components["schemas"]["DuplicateBookRead"][];
|
||||
};
|
||||
/** PublisherRead */
|
||||
PublisherRead: {
|
||||
id: number;
|
||||
@@ -947,7 +1007,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
BooksDuplicatesCheckDuplicates: {
|
||||
BooksDuplicateFilesCheckDuplicateFiles: {
|
||||
parameters: {
|
||||
query?: {
|
||||
library_id?: number | null;
|
||||
@@ -1166,6 +1226,79 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
BooksDuplicateBooksDismissalsDismissDuplicateBooks: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["DuplicateDismissal"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Request fulfilled, nothing follows */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @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[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
BooksDuplicateBooksDismissalsRestoreDuplicateBooks: {
|
||||
parameters: {
|
||||
query: {
|
||||
book_a_id: number;
|
||||
book_b_id: number;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Request fulfilled, nothing follows */
|
||||
204: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content?: never;
|
||||
};
|
||||
/** @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[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
BooksBookIdGetBookById: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1328,6 +1461,43 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
BooksDuplicateBooksListDuplicateBooks: {
|
||||
parameters: {
|
||||
query?: {
|
||||
library_id?: number | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Request fulfilled, document follows */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["DuplicateBookGroupRead"][];
|
||||
};
|
||||
};
|
||||
/** @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[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
BooksProgressSetBookProgressBatch: {
|
||||
parameters: {
|
||||
query: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getContext, setContext } from 'svelte';
|
||||
import { invalidate } from '$app/navigation';
|
||||
|
||||
import type { Book, BooksUploadResult, DuplicateFile } from '$lib/schema';
|
||||
import type { Book, BooksUploadResult, DuplicateBook, DuplicateFile } from '$lib/schema';
|
||||
|
||||
export type UploadStatus = 'queued' | 'uploading' | 'done' | 'skipped' | 'failed';
|
||||
|
||||
@@ -21,6 +21,12 @@ export interface UploadJob {
|
||||
* and still carries these, since the reader asked for those files too.
|
||||
*/
|
||||
duplicates?: DuplicateFile[];
|
||||
/**
|
||||
* Books already in the library that the one this job created might be a second
|
||||
* copy of. Much weaker than `duplicates`: the bytes are new and only the metadata
|
||||
* agrees, which a second edition and a translation both do. The book was stored.
|
||||
*/
|
||||
possibleDuplicates?: DuplicateBook[];
|
||||
}
|
||||
|
||||
export interface UploadSummary {
|
||||
@@ -89,10 +95,17 @@ export class UploadQueueState {
|
||||
* Whether the run left something the reader still has to see.
|
||||
*
|
||||
* A skipped book is not a failure, but it is the only place that says the file
|
||||
* was already here — and the only place to override it from.
|
||||
* was already here — and the only place to override it from. A book that may
|
||||
* already be in the library counts too: it is a note the reader has to actually
|
||||
* read, and a tray that clears itself after six seconds is one they never will.
|
||||
*/
|
||||
readonly needsAttention = $derived(
|
||||
this.jobs.some((job) => job.status === 'failed' || (job.duplicates?.length ?? 0) > 0)
|
||||
this.jobs.some(
|
||||
(job) =>
|
||||
job.status === 'failed' ||
|
||||
(job.duplicates?.length ?? 0) > 0 ||
|
||||
(job.possibleDuplicates?.length ?? 0) > 0
|
||||
)
|
||||
);
|
||||
|
||||
#running = false;
|
||||
@@ -212,7 +225,9 @@ export class UploadQueueState {
|
||||
this.#patch(index, {
|
||||
status: result.created.length === 0 ? 'skipped' : 'done',
|
||||
book,
|
||||
duplicates: result.skipped
|
||||
duplicates: result.skipped,
|
||||
// One job is one book, so it has at most one set of candidates.
|
||||
possibleDuplicates: result.possible_duplicates?.[0]?.candidates
|
||||
});
|
||||
} catch (error) {
|
||||
// One bad book must not take the rest of the queue with it.
|
||||
|
||||
Reference in New Issue
Block a user