From fc6b97bf3882bbd5fdd01730c02d285cfdc8b3a7 Mon Sep 17 00:00:00 2001 From: patrick Date: Sat, 15 Aug 2026 21:55:20 -0400 Subject: [PATCH] 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. --- frontend/src/lib/api/book.remote.ts | 43 +++++ .../src/lib/components/layout/nav-main.svelte | 15 +- .../lib/components/layout/upload-tray.svelte | 37 +++- frontend/src/lib/schema/book.ts | 22 +++ frontend/src/lib/schema/openapi/schema.d.ts | 178 +++++++++++++++++- frontend/src/lib/state/upload-queue.svelte.ts | 23 ++- .../[libraryId]/duplicates/+page.server.ts | 10 + .../[libraryId]/duplicates/+page.svelte | 149 +++++++++++++++ 8 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 frontend/src/routes/(root)/(library)/library/[libraryId]/duplicates/+page.server.ts create mode 100644 frontend/src/routes/(root)/(library)/library/[libraryId]/duplicates/+page.svelte diff --git a/frontend/src/lib/api/book.remote.ts b/frontend/src/lib/api/book.remote.ts index fed4dcc..fd31d61 100644 --- a/frontend/src/lib/api/book.remote.ts +++ b/frontend/src/lib/api/book.remote.ts @@ -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 => { + 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 }) => { diff --git a/frontend/src/lib/components/layout/nav-main.svelte b/frontend/src/lib/components/layout/nav-main.svelte index cd6098a..9e99bbb 100644 --- a/frontend/src/lib/components/layout/nav-main.svelte +++ b/frontend/src/lib/components/layout/nav-main.svelte @@ -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: [] } ]; diff --git a/frontend/src/lib/components/layout/upload-tray.svelte b/frontend/src/lib/components/layout/upload-tray.svelte index 5835ecf..cf8c1ca 100644 --- a/frontend/src/lib/components/layout/upload-tray.svelte +++ b/frontend/src/lib/components/layout/upload-tray.svelte @@ -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}`; + } {#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 ?? []}
  • {job.label} @@ -136,6 +151,26 @@ {duplicateNote(duplicates)} {/if} + {:else if possible.length > 0} + + + {#if possible.length === 1} + + {possibleNote(possible)} + + {:else} + {possibleNote(possible)} + {/if} + {:else} {formatFileSize(job.size)} diff --git a/frontend/src/lib/schema/book.ts b/frontend/src/lib/schema/book.ts index 5eecd86..b46ade1 100644 --- a/frontend/src/lib/schema/book.ts +++ b/frontend/src/lib/schema/book.ts @@ -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; export type BookEditMetadata = z.infer; export type DeleteBook = z.infer; export type DeleteBookFiles = z.infer; +export type DuplicateDismissal = z.infer; export type UpdateBookProgress = z.infer; diff --git a/frontend/src/lib/schema/openapi/schema.d.ts b/frontend/src/lib/schema/openapi/schema.d.ts index 4ce8f8e..4f23743 100644 --- a/frontend/src/lib/schema/openapi/schema.d.ts +++ b/frontend/src/lib/schema/openapi/schema.d.ts @@ -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: { diff --git a/frontend/src/lib/state/upload-queue.svelte.ts b/frontend/src/lib/state/upload-queue.svelte.ts index 3b36a8a..6c394f3 100644 --- a/frontend/src/lib/state/upload-queue.svelte.ts +++ b/frontend/src/lib/state/upload-queue.svelte.ts @@ -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. diff --git a/frontend/src/routes/(root)/(library)/library/[libraryId]/duplicates/+page.server.ts b/frontend/src/routes/(root)/(library)/library/[libraryId]/duplicates/+page.server.ts new file mode 100644 index 0000000..2403f5a --- /dev/null +++ b/frontend/src/routes/(root)/(library)/library/[libraryId]/duplicates/+page.server.ts @@ -0,0 +1,10 @@ +import { listDuplicateBooks } from '$lib/api/book.remote.js'; + +export async function load({ params, depends }) { + // Dismissing a group re-runs this, so the card leaves the screen. + depends('app:duplicate-books'); + + return { + groups: await listDuplicateBooks(params.libraryId) + }; +} diff --git a/frontend/src/routes/(root)/(library)/library/[libraryId]/duplicates/+page.svelte b/frontend/src/routes/(root)/(library)/library/[libraryId]/duplicates/+page.svelte new file mode 100644 index 0000000..4df7cec --- /dev/null +++ b/frontend/src/routes/(root)/(library)/library/[libraryId]/duplicates/+page.svelte @@ -0,0 +1,149 @@ + + +
    +
    +

    Possible duplicates

    +

    + Books whose metadata matches. A second edition, a translation and a different scan of one book + all look like this, so nothing here has been changed or removed — this is a list to read, not + a problem to fix. +

    +
    + + {#if data.groups.length === 0} + + + + + + Nothing looks duplicated + + No two books in this library share an identifier, or a title and an author. + + + + {:else} + {#each data.groups as group (keyOf(group))} + {@const busy = dismissing.includes(keyOf(group))} + + + {group.books.length} books look like the same book + + + + + + + + + + {/each} + {/if} +