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:
2026-08-15 21:55:20 -04:00
parent 5047277845
commit fc6b97bf38
8 changed files with 465 additions and 12 deletions
+43
View File
@@ -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)}
+22
View File
@@ -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
View File
@@ -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: {
+19 -4
View File
@@ -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.