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.
|
||||
|
||||
@@ -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