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,
|
deleteBooksSchema,
|
||||||
editBookMetadataSchema,
|
editBookMetadataSchema,
|
||||||
updateBookProgressSchema,
|
updateBookProgressSchema,
|
||||||
|
duplicateDismissalSchema,
|
||||||
type Book,
|
type Book,
|
||||||
type BooksUploadResult,
|
type BooksUploadResult,
|
||||||
|
type DuplicateBookGroup,
|
||||||
bookFilesUpload
|
bookFilesUpload
|
||||||
} from '$lib/schema/index';
|
} from '$lib/schema/index';
|
||||||
import { stringCoerce, type PaginatedResponse } from '$lib/schema/common';
|
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(
|
export const updateBookProgress = command(
|
||||||
updateBookProgressSchema,
|
updateBookProgressSchema,
|
||||||
async ({ book_ids, ...data }) => {
|
async ({ book_ids, ...data }) => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||||
import { getLibraryState } from '$lib/state/library.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 libraryState = getLibraryState();
|
||||||
const bookshelfState = getBookshelfState();
|
const bookshelfState = getBookshelfState();
|
||||||
@@ -33,13 +33,22 @@
|
|||||||
title: 'Home',
|
title: 'Home',
|
||||||
icon: House,
|
icon: House,
|
||||||
routeId: '/(root)/(library)/library/[libraryId]',
|
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',
|
title: 'Library',
|
||||||
icon: LibraryBig,
|
icon: LibraryBig,
|
||||||
routeId: '/(root)/(library)/library/[libraryId]/view',
|
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: [] }
|
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||||
import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
|
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';
|
import { formatFileSize } from '$lib/utils';
|
||||||
|
|
||||||
const queue = getUploadQueueState();
|
const queue = getUploadQueueState();
|
||||||
@@ -49,6 +49,20 @@
|
|||||||
function noteTarget(duplicates: DuplicateFile[]) {
|
function noteTarget(duplicates: DuplicateFile[]) {
|
||||||
return duplicates.length === 1 ? duplicates[0].book_id : null;
|
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>
|
</script>
|
||||||
|
|
||||||
{#if queue.total > 0}
|
{#if queue.total > 0}
|
||||||
@@ -109,6 +123,7 @@
|
|||||||
{#each queue.jobs as job (job.id)}
|
{#each queue.jobs as job (job.id)}
|
||||||
{@const duplicates = job.duplicates ?? []}
|
{@const duplicates = job.duplicates ?? []}
|
||||||
{@const target = noteTarget(duplicates)}
|
{@const target = noteTarget(duplicates)}
|
||||||
|
{@const possible = job.possibleDuplicates ?? []}
|
||||||
<li class="flex min-w-0 items-center gap-2 px-3 py-2">
|
<li class="flex min-w-0 items-center gap-2 px-3 py-2">
|
||||||
<span class="min-w-0 flex-1">
|
<span class="min-w-0 flex-1">
|
||||||
<span class="block truncate text-sm" title={job.label}>{job.label}</span>
|
<span class="block truncate text-sm" title={job.label}>{job.label}</span>
|
||||||
@@ -136,6 +151,26 @@
|
|||||||
<span class="truncate">{duplicateNote(duplicates)}</span>
|
<span class="truncate">{duplicateNote(duplicates)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</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}
|
{:else}
|
||||||
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
{formatFileSize(job.size)}
|
{formatFileSize(job.size)}
|
||||||
|
|||||||
@@ -15,6 +15,27 @@ export type BookProgress = components['schemas']['BookProgressRead'];
|
|||||||
export type DuplicateFile = components['schemas']['DuplicateFileRead'];
|
export type DuplicateFile = components['schemas']['DuplicateFileRead'];
|
||||||
export type BooksUploadResult = components['schemas']['BooksUploadResult'];
|
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({
|
export const bookQuerySchema = commonQuerySchema.extend({
|
||||||
libraries: stringArrayCoerce,
|
libraries: stringArrayCoerce,
|
||||||
authors: stringArrayCoerce,
|
authors: stringArrayCoerce,
|
||||||
@@ -106,4 +127,5 @@ export type BookQuery = z.infer<typeof bookQuerySchema>;
|
|||||||
export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
|
export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
|
||||||
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
|
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
|
||||||
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
|
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
|
||||||
|
export type DuplicateDismissal = z.infer<typeof duplicateDismissalSchema>;
|
||||||
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
|
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
|
||||||
|
|||||||
+174
-4
@@ -22,7 +22,7 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
"/books/duplicates": {
|
"/books/duplicate-files": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -31,8 +31,8 @@ export interface paths {
|
|||||||
};
|
};
|
||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
/** CheckDuplicates */
|
/** CheckDuplicateFiles */
|
||||||
post: operations["BooksDuplicatesCheckDuplicates"];
|
post: operations["BooksDuplicateFilesCheckDuplicateFiles"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@@ -75,6 +75,24 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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}": {
|
"/books/{book_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -127,6 +145,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/books/progress": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -731,6 +766,25 @@ export interface components {
|
|||||||
BooksUploadResult: {
|
BooksUploadResult: {
|
||||||
created: components["schemas"]["BookRead"][];
|
created: components["schemas"]["BookRead"][];
|
||||||
skipped: components["schemas"]["DuplicateFileRead"][];
|
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 */
|
||||||
DuplicateFileRead: {
|
DuplicateFileRead: {
|
||||||
@@ -815,6 +869,12 @@ export interface components {
|
|||||||
refresh_token?: string | null;
|
refresh_token?: string | null;
|
||||||
expires_in?: number | null;
|
expires_in?: number | null;
|
||||||
};
|
};
|
||||||
|
/** PossibleDuplicateRead */
|
||||||
|
PossibleDuplicateRead: {
|
||||||
|
book_id: number;
|
||||||
|
title: string;
|
||||||
|
candidates: components["schemas"]["DuplicateBookRead"][];
|
||||||
|
};
|
||||||
/** PublisherRead */
|
/** PublisherRead */
|
||||||
PublisherRead: {
|
PublisherRead: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -947,7 +1007,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
BooksDuplicatesCheckDuplicates: {
|
BooksDuplicateFilesCheckDuplicateFiles: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
library_id?: number | null;
|
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: {
|
BooksBookIdGetBookById: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
BooksProgressSetBookProgressBatch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getContext, setContext } from 'svelte';
|
import { getContext, setContext } from 'svelte';
|
||||||
import { invalidate } from '$app/navigation';
|
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';
|
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.
|
* and still carries these, since the reader asked for those files too.
|
||||||
*/
|
*/
|
||||||
duplicates?: DuplicateFile[];
|
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 {
|
export interface UploadSummary {
|
||||||
@@ -89,10 +95,17 @@ export class UploadQueueState {
|
|||||||
* Whether the run left something the reader still has to see.
|
* 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
|
* 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(
|
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;
|
#running = false;
|
||||||
@@ -212,7 +225,9 @@ export class UploadQueueState {
|
|||||||
this.#patch(index, {
|
this.#patch(index, {
|
||||||
status: result.created.length === 0 ? 'skipped' : 'done',
|
status: result.created.length === 0 ? 'skipped' : 'done',
|
||||||
book,
|
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) {
|
} catch (error) {
|
||||||
// One bad book must not take the rest of the queue with it.
|
// One bad book must not take the rest of the queue with it.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { invalidate } from '$app/navigation';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { CopyCheck, Fingerprint, Type } from '@lucide/svelte';
|
||||||
|
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import * as Empty from '$lib/components/ui/empty/index.js';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import BookImage from '$lib/components/view/book-image.svelte';
|
||||||
|
import { dismissDuplicateBooks } from '$lib/api/book.remote';
|
||||||
|
import type { DuplicateBookGroup } from '$lib/schema';
|
||||||
|
|
||||||
|
let { data }: { data: { groups: DuplicateBookGroup[] } } = $props();
|
||||||
|
|
||||||
|
// Which groups are being dismissed, so a slow round trip cannot be started twice.
|
||||||
|
let dismissing = $state<number[]>([]);
|
||||||
|
|
||||||
|
/** A group is named by its lowest book id, which the backend orders it by. */
|
||||||
|
function keyOf(group: DuplicateBookGroup) {
|
||||||
|
return group.books[0].book_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonLabel(reason: string) {
|
||||||
|
if (reason === 'identifier') return 'Same identifier';
|
||||||
|
if (reason === 'title-author') return 'Same title and author';
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss every pairing in a group at once.
|
||||||
|
*
|
||||||
|
* A group is held together pair by pair, so saying "these are not duplicates"
|
||||||
|
* about three books means saying it about all three pairs — dismissing only the
|
||||||
|
* first would leave the rest of the group standing and the screen unchanged.
|
||||||
|
*/
|
||||||
|
async function notDuplicates(group: DuplicateBookGroup) {
|
||||||
|
const key = keyOf(group);
|
||||||
|
if (dismissing.includes(key)) return;
|
||||||
|
|
||||||
|
dismissing = [...dismissing, key];
|
||||||
|
|
||||||
|
const ids = group.books.map((book) => book.book_id);
|
||||||
|
const pairs = ids.flatMap((a, index) =>
|
||||||
|
ids.slice(index + 1).map((b) => ({ book_a_id: a, book_b_id: b }))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(pairs.map((pair) => dismissDuplicateBooks(pair)));
|
||||||
|
await invalidate('app:duplicate-books');
|
||||||
|
toast.success('Marked as different books');
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not mark these as different books');
|
||||||
|
} finally {
|
||||||
|
dismissing = dismissing.filter((id) => id !== key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Possible duplicates</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.groups.length === 0}
|
||||||
|
<Empty.Root>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Media variant="icon">
|
||||||
|
<CopyCheck />
|
||||||
|
</Empty.Media>
|
||||||
|
<Empty.Title>Nothing looks duplicated</Empty.Title>
|
||||||
|
<Empty.Description>
|
||||||
|
No two books in this library share an identifier, or a title and an author.
|
||||||
|
</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
</Empty.Root>
|
||||||
|
{:else}
|
||||||
|
{#each data.groups as group (keyOf(group))}
|
||||||
|
{@const busy = dismissing.includes(keyOf(group))}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>{group.books.length} books look like the same book</Card.Title>
|
||||||
|
<Card.Action>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => notDuplicates(group)}
|
||||||
|
>
|
||||||
|
Not duplicates
|
||||||
|
</Button>
|
||||||
|
</Card.Action>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content>
|
||||||
|
<ul class="flex flex-wrap gap-4">
|
||||||
|
{#each group.books as book (book.book_id)}
|
||||||
|
<li class="w-36">
|
||||||
|
<a
|
||||||
|
href={resolve('/(root)/(library)/book/[bookId]', {
|
||||||
|
bookId: String(book.book_id)
|
||||||
|
})}
|
||||||
|
class="group flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block aspect-9/12 w-full overflow-hidden rounded-sm bg-muted shadow-md transition-all group-hover:brightness-75"
|
||||||
|
>
|
||||||
|
{#if book.cover_image}
|
||||||
|
<BookImage src="/api/{book.cover_image}" class="h-full w-full object-cover" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="line-clamp-2 font-serif text-sm group-hover:underline">
|
||||||
|
{book.title}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{book.authors.join(', ')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Why this book is in the group, so the reader can judge the
|
||||||
|
evidence rather than take the grouping on trust. -->
|
||||||
|
<p class="mt-1 flex flex-wrap gap-1">
|
||||||
|
{#each book.matched_on as reason (reason)}
|
||||||
|
<Badge variant="secondary" class="gap-1 text-[10px] font-normal">
|
||||||
|
{#if reason === 'identifier'}
|
||||||
|
<Fingerprint class="size-3" />
|
||||||
|
{:else}
|
||||||
|
<Type class="size-3" />
|
||||||
|
{/if}
|
||||||
|
{reasonLabel(reason)}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user