diff --git a/docs/releases.md b/docs/releases.md index 1aa433e9..2079cac6 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -39,10 +39,10 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner -**Summary:** Adds shareable read-only public profile pages with configurable access and content, groups duplicate import-search results into expandable edition groups, makes running searches cancelable, introduces an adaptive date input with a native picker, adds optional book media and medium statistics, supports localized medium and possession searches, detects insecure camera contexts, and fixes timezone handling in the daily page statistics and progress log editing. +**Summary:** Adds shareable read-only public profile pages with configurable access and content, groups duplicate import-search results into expandable edition groups, lets you collect search results in an import basket and import them all at once, makes running searches cancelable, introduces an adaptive date input with a native picker, adds optional book media and medium statistics, supports localized medium and possession searches, detects insecure camera contexts, and fixes timezone handling in the daily page statistics and progress log editing. **Features** -- 📚 **Edition groups in the import search**: results from different providers that describe the same book (same ISBN, or same title and authors) are now grouped into expandable entries with an "N results" badge. Compare the variants side by side and import the one you want; no result is dropped anymore. See the [Library guide](/guide/using-librislog/library#how-results-are-grouped) for the exact grouping rules +- 📚 **Edition groups in the import search**: results from different providers that describe the same book (same ISBN, or same title and authors) are now grouped into expandable entries with an "N results" badge. Compare the variants side by side and import the one you want; no result is dropped anymore. The selected edition is highlighted with a border and a "Selected" badge, and every edition row shows a pointer cursor, hover feedback, and a keyboard focus ring. See the [Library guide](/guide/using-librislog/library#how-results-are-grouped) for the exact grouping rules - 🗂️ **Optional book medium**: classify books as Print, eBook, Audiobook, Comic / Graphic Novel, or Magazine / Newspaper from manual entry, search import, and book editing. Mediums can be filtered in the library, searched with `medium:`, imported/exported, and reviewed in the statistics distribution - 🌍 **Localized search values**: `medium:` and `possession:` searches accept both their original enum keys and localized display values, such as `medium:Hörbuch` and `possession:Im Besitz` - 🛑 **Cancelable book search**: while an import search is running, the Search button becomes a Cancel button, so you can stop the request and refine your query @@ -53,6 +53,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner - 🎥 **Active camera name in the scanner**: the barcode scanner now shows the name of the active camera in a badge next to the switch button, so you always know which lens is being used - 🔗 **Heimdall dashboard integration**: new documentation for the LibrisLog enhanced app, which shows your reading statistics directly on [Heimdall](https://github.com/linuxserver/Heimdall) tiles - 🔗 **Shareable public profile pages**: create named, read-only profile URLs from the Profile page. Configure each link independently for public or logged-in-only access, selected profile sections and statistics, language, and an optional expiration date. Shared pages include responsive book cards, a mobile-safe reading timeline with incremental loading and hidden-book hints, full-library search with incremental loading, selectable 12-month/3-year/all-time trend ranges with value tooltips, distribution and rating panels, and the owner's generated avatar. Existing links can be copied, opened, edited, or revoked. The full URL token is only revealed on demand and is shown once after creation. See the [Profile guide](/guide/using-librislog/profile#urlprofile-sharing) for setup and security details +- 🧺 **Import basket**: search results now offer an **Add to Basket** action next to the existing **Add** button. Collected books appear in a new **Basket** tab with a live count badge, where you can review them, remove individual entries, and import everything in one go. Each entry remembers the reading status, possession status, and medium that were selected when it was added. If some books fail during a basket import, the successful ones are imported and the failed ones stay in the basket so you can retry or remove them. The same book cannot be added twice **Bug fixes** - 🗓️ **Timezone-correct daily page statistics**: pages read between two progress updates are now attributed to calendar days in the user's timezone instead of fixed 24h slots, so the pages-per-day view matches your local days. Your heatmap may shift slightly after the upgrade diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ecb65371..2a7e478b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,6 +31,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^7.0.1", "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.7", "@types/hammerjs": "^2.0.46", "@types/node": "^26.2.0", "@vitest/coverage-v8": "^4.1.7", @@ -1033,6 +1034,20 @@ "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.7", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.7.tgz", + "integrity": "sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 16116bc9..27ad7106 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,6 +23,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^7.0.1", "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.7", "@types/hammerjs": "^2.0.46", "@types/node": "^26.2.0", "@vitest/coverage-v8": "^4.1.7", diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index 5493de08..7314431f 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -1,14 +1,15 @@ + +
+ {#if basket.length === 0} +
+ +

{$_('import.basketEmpty')}

+
+ {:else} +
    + {#each basket as item} +
  • + {#if item.candidate.cover_url} + {$_('book.cover')} + {:else} +
    + {/if} +
    +

    {item.candidate.title}

    + {#if item.candidate.authors?.length} +

    {formatAuthors(item.candidate.authors, item.candidate.author)}

    + {/if} +
    + {item.candidate.source} + {#if item.candidate.published_year} + · + {item.candidate.published_year} + {/if} + {acquisitionLabel(item.acquisitionStatus)} + {#if item.medium} + {mediumLabel(item.medium)} + {/if} +
    +
    + +
  • + {/each} +
+
+ + {$_('import.basketCount', { values: { count: basket.length } })} + + +
+ {/if} +
\ No newline at end of file diff --git a/frontend/src/lib/components/ImportSearch.svelte b/frontend/src/lib/components/ImportSearch.svelte index 9f3d37d1..c446161b 100644 --- a/frontend/src/lib/components/ImportSearch.svelte +++ b/frontend/src/lib/components/ImportSearch.svelte @@ -1,5 +1,5 @@
@@ -399,6 +423,7 @@ {@const selected = selectedCandidate(group)} {@const groupImported = isGroupAlreadyImported(group)} {@const importingKey = `${group.key}:${group.variants.indexOf(selected)}`} + {@const inBasket = isInBasket(selected)}
  • {#if group.coverUrl} @@ -444,7 +469,7 @@ class="btn btn-xs {groupImported ? 'btn-success btn-outline' : 'btn-primary'}" disabled={groupImported || importing === importingKey || !acquisitionStatus} title={groupImported ? $_('import.alreadyImported') : ''} - onclick={() => importBook(group, 'want_to_read')} + onclick={() => importBook(group, defaultStatus)} > {importing === importingKey ? $_('common.loadingEllipsis') @@ -452,6 +477,14 @@ ? $_('import.imported') : $_('app.add')} + {#if group.variants.length > 1}
    + {#if isSelected} + ✓ {$_('import.editionSelected')} + {/if} {#if variantImported} {$_('import.alreadyImported')} {/if} diff --git a/frontend/src/lib/components/ImportSearch.test.ts b/frontend/src/lib/components/ImportSearch.test.ts index 2560e797..6a4f7c9e 100644 --- a/frontend/src/lib/components/ImportSearch.test.ts +++ b/frontend/src/lib/components/ImportSearch.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/svelte'; import ImportSearch from './ImportSearch.svelte'; import { api } from '$lib/api'; -import type { BookImportCandidate, SearchStage } from '$lib/types'; +import type { BasketItem, BookImportCandidate, SearchStage } from '$lib/types'; const mockSearchStream = vi.fn(); const mockImportBook = vi.fn(); @@ -532,7 +532,11 @@ describe('ImportSearch', () => { await waitFor(() => { expect(screen.getByRole('button', { name: /^hardcover/ })).toBeInTheDocument(); }); - await fireEvent.click(screen.getByRole('button', { name: /^hardcover/ })); + const hardcoverButton = screen.getByRole('button', { name: /^hardcover/ }); + await fireEvent.click(hardcoverButton); + expect(hardcoverButton).toHaveAttribute('aria-pressed', 'true'); + expect(hardcoverButton).toHaveClass('border-primary', 'bg-primary/10', 'cursor-pointer'); + expect(hardcoverButton).toHaveTextContent('Selected'); // Google supplement appends a third variant. await fireEvent.click(screen.getByRole('button', { name: 'Search Google Books too' })); @@ -589,4 +593,172 @@ describe('ImportSearch', () => { expect(screen.queryByText('Dune')).not.toBeInTheDocument(); }); }); + + describe('basket', () => { + const onAddToBasket = vi.fn(); + + beforeEach(() => { + onAddToBasket.mockClear(); + }); + + function renderWithBasket(basket: BasketItem[] = []) { + return render(ImportSearch, { props: { basket, onAddToBasket } }); + } + + it('shows an Add to Basket button per result group', async () => { + const dune = candidate(1, 'Dune', { + isbn: '9780441013593', + authors: ['Frank Herbert'], + source: 'open_library' + }); + mockSearchStream.mockImplementation(makeStreamFinishing([dune])); + + renderWithBasket(); + await typeQueryAndSearch(); + + await waitFor(() => { + expect(screen.getByText('Dune')).toBeInTheDocument(); + }); + expect(screen.getAllByRole('button', { name: 'Add to Basket' })).toHaveLength(1); + }); + + it('adds the selected variant to the basket', async () => { + const ol = candidate(1, 'Dune', { + isbn: '9780441013593', + authors: ['Frank Herbert'], + source: 'open_library' + }); + const hc = candidate(2, 'Dune', { + isbn: '9780441013593', + authors: ['Frank Herbert'], + source: 'hardcover', + page_count: 464, + published_year: 1965 + }); + mockSearchStream.mockImplementation(makeStreamFinishing([ol, hc])); + + renderWithBasket(); + await typeQueryAndSearch(); + + await waitFor(() => { + expect(screen.getByText('Dune')).toBeInTheDocument(); + }); + + // Select the hardcover variant. + await fireEvent.click(screen.getByRole('button', { name: 'Show editions' })); + await waitFor(() => { + expect(screen.getByRole('button', { name: /^hardcover/ })).toBeInTheDocument(); + }); + await fireEvent.click(screen.getByRole('button', { name: /^hardcover/ })); + + // Set acquisition status to enable the Add to Basket button. + // happy-dom's :checked fallback always resolves to the first + // non-disabled option (here 'owned'), so we trigger that path. + // Select metadata override is verified via E2E tests. + const acquisitionSelect = screen.getByRole('combobox', { name: /Possession/i }); + await fireEvent.change(acquisitionSelect, { target: { value: 'owned' } }); + + await fireEvent.click(screen.getByRole('button', { name: 'Add to Basket' })); + + await waitFor(() => { + expect(onAddToBasket).toHaveBeenCalledTimes(1); + }); + const item = onAddToBasket.mock.calls[0][0] as BasketItem; + expect(item.candidate.source).toBe('hardcover'); + expect(item.readingStatus).toBe('want_to_read'); + expect(item.acquisitionStatus).toBe('owned'); + }); + + it('disables Add to Basket when no acquisition status is selected', async () => { + const dune = candidate(1, 'Dune', { + isbn: '9780441013593', + authors: ['Frank Herbert'], + source: 'open_library' + }); + mockSearchStream.mockImplementation(makeStreamFinishing([dune])); + + renderWithBasket(); + await typeQueryAndSearch(); + + await waitFor(() => { + expect(screen.getByText('Dune')).toBeInTheDocument(); + }); + expect(screen.getByRole('button', { name: 'Add to Basket' })).toBeDisabled(); + }); + + it('uses defaultStatus prop for reading status', async () => { + const dune = candidate(1, 'Dune', { + isbn: '9780441013593', + authors: ['Frank Herbert'], + source: 'open_library' + }); + mockSearchStream.mockImplementation(makeStreamFinishing([dune])); + + render(ImportSearch, { props: { defaultStatus: 'currently_reading', basket: [], onAddToBasket } }); + await typeQueryAndSearch(); + + await waitFor(() => { + expect(screen.getByText('Dune')).toBeInTheDocument(); + }); + const acquisitionSelect = screen.getByRole('combobox', { name: /Possession/i }); + await fireEvent.change(acquisitionSelect, { target: { value: 'owned' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Add to Basket' })); + + await waitFor(() => { + expect(onAddToBasket).toHaveBeenCalledTimes(1); + }); + const item = onAddToBasket.mock.calls[0][0] as BasketItem; + expect(item.readingStatus).toBe('currently_reading'); + }); + + it('shows In basket and disables the button when already in basket', async () => { + const dune = candidate(1, 'Dune', { + isbn: '9780441013593', + authors: ['Frank Herbert'], + source: 'open_library' + }); + mockSearchStream.mockImplementation(makeStreamFinishing([dune])); + + const existingItem: BasketItem = { + id: 'x', + candidate: dune, + readingStatus: 'want_to_read', + acquisitionStatus: 'owned', + medium: null + }; + renderWithBasket([existingItem]); + await typeQueryAndSearch(); + + await waitFor(() => { + expect(screen.getByText('Dune')).toBeInTheDocument(); + }); + const btn = screen.getByRole('button', { name: 'In basket' }); + expect(btn).toBeDisabled(); + }); + + it('still allows direct Add to import and close the dialog', async () => { + const dune = candidate(1, 'Dune', { + isbn: '9780441013593', + authors: ['Frank Herbert'], + source: 'open_library' + }); + mockImportBook.mockResolvedValue({ id: 1, title: 'Dune' }); + mockSearchStream.mockImplementation(makeStreamFinishing([dune])); + const onImport = vi.fn(); + + render(ImportSearch, { props: { basket: [], onImport } }); + await typeQueryAndSearch(); + + await waitFor(() => { + expect(screen.getByText('Dune')).toBeInTheDocument(); + }); + const acquisitionSelect = screen.getByRole('combobox', { name: /Possession/i }); + await fireEvent.change(acquisitionSelect, { target: { value: 'owned' } }); + await fireEvent.click(screen.getByRole('button', { name: 'Add' })); + + await waitFor(() => { + expect(onImport).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index b9d018f2..91b12dd2 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -221,7 +221,8 @@ "searchImport": "Suchen & Importieren", "adding": "Füge hinzu...", "failedAdd": "Buch konnte nicht hinzugefügt werden", - "importFromFile": "Aus Datei importieren" + "importFromFile": "Aus Datei importieren", + "basket": "Warenkorb" }, "import": { "searchByTitleOrAuthor": "Nach Titel oder Autor suchen...", @@ -252,7 +253,16 @@ "groupResultPlural": "Ergebnisse", "groupExpand": "Ausgaben anzeigen", "groupCollapse": "Ausgaben ausblenden", - "groupChooseEdition": "Ausgabe wählen" + "groupChooseEdition": "Ausgabe wählen", + "editionSelected": "Ausgewählt", + "addToBasket": "Zum Warenkorb hinzufügen", + "inBasket": "Im Warenkorb", + "basketEmpty": "Dein Warenkorb ist leer. Suche nach Büchern, um sie hinzuzufügen.", + "basketCount": "{count} {count, plural, one {Buch} other {Bücher}}", + "importBasket": "Warenkorb importieren", + "importingBasket": "Importiere...", + "basketRemove": "Aus dem Warenkorb entfernen", + "basketImportSuccess": "{count} Bücher erfolgreich importiert." }, "scanner": { "title": "ISBN-Barcode scannen", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 860f29ca..bdd88a9b 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -221,7 +221,8 @@ "searchImport": "Search & Import", "adding": "Adding...", "failedAdd": "Failed to add book", - "importFromFile": "Import from file" + "importFromFile": "Import from file", + "basket": "Basket" }, "import": { "searchByTitleOrAuthor": "Search by title or author...", @@ -252,7 +253,16 @@ "groupResultPlural": "results", "groupExpand": "Show editions", "groupCollapse": "Hide editions", - "groupChooseEdition": "Choose edition" + "groupChooseEdition": "Choose edition", + "editionSelected": "Selected", + "addToBasket": "Add to Basket", + "inBasket": "In basket", + "basketEmpty": "Your basket is empty. Search for books to add.", + "basketCount": "{count} {count, plural, one {book} other {books}}", + "importBasket": "Import Basket", + "importingBasket": "Importing...", + "basketRemove": "Remove from basket", + "basketImportSuccess": "Imported {count} books successfully." }, "scanner": { "title": "Scan ISBN Barcode", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 18f1057f..8d74fe30 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -221,7 +221,8 @@ "searchImport": "Buscar e importar", "adding": "Añadiendo...", "failedAdd": "Error al añadir libro", - "importFromFile": "Importar desde archivo" + "importFromFile": "Importar desde archivo", + "basket": "Cesta" }, "import": { "searchByTitleOrAuthor": "Buscar por título o autor...", @@ -252,7 +253,16 @@ "groupResultPlural": "resultados", "groupExpand": "Mostrar ediciones", "groupCollapse": "Ocultar ediciones", - "groupChooseEdition": "Elegir edición" + "groupChooseEdition": "Elegir edición", + "editionSelected": "Seleccionada", + "addToBasket": "Añadir a la cesta", + "inBasket": "En la cesta", + "basketEmpty": "Tu cesta está vacía. Busca libros para añadirlos.", + "basketCount": "{count} {count, plural, one {libro} other {libros}}", + "importBasket": "Importar cesta", + "importingBasket": "Importando...", + "basketRemove": "Quitar de la cesta", + "basketImportSuccess": "{count} libros importados correctamente." }, "scanner": { "title": "Escanear código de barras ISBN", diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 0c5b76f4..cdb6a4ef 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -221,7 +221,8 @@ "searchImport": "Rechercher et importer", "adding": "Ajout...", "failedAdd": "Échec de l'ajout du livre", - "importFromFile": "Importer depuis un fichier" + "importFromFile": "Importer depuis un fichier", + "basket": "Panier" }, "import": { "searchByTitleOrAuthor": "Rechercher par titre ou auteur...", @@ -252,7 +253,16 @@ "groupResultPlural": "résultats", "groupExpand": "Afficher les éditions", "groupCollapse": "Masquer les éditions", - "groupChooseEdition": "Choisir l'édition" + "groupChooseEdition": "Choisir l'édition", + "editionSelected": "Sélectionnée", + "addToBasket": "Ajouter au panier", + "inBasket": "Dans le panier", + "basketEmpty": "Votre panier est vide. Recherchez des livres à ajouter.", + "basketCount": "{count} {count, plural, one {livre} other {livres}}", + "importBasket": "Importer le panier", + "importingBasket": "Importation...", + "basketRemove": "Retirer du panier", + "basketImportSuccess": "{count} livres importés avec succès." }, "scanner": { "title": "Scanner un code-barres ISBN", diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 136244b8..81980c4f 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -221,7 +221,8 @@ "searchImport": "搜索并导入", "adding": "添加中...", "failedAdd": "添加图书失败", - "importFromFile": "从文件导入" + "importFromFile": "从文件导入", + "basket": "购物篮" }, "import": { "searchByTitleOrAuthor": "按标题或作者搜索...", @@ -252,7 +253,16 @@ "groupResultPlural": "个结果", "groupExpand": "显示版本", "groupCollapse": "隐藏版本", - "groupChooseEdition": "选择版本" + "groupChooseEdition": "选择版本", + "editionSelected": "已选择", + "addToBasket": "添加到购物篮", + "inBasket": "已在购物篮", + "basketEmpty": "购物篮为空。搜索图书以添加。", + "basketCount": "{count} 本图书", + "importBasket": "导入购物篮", + "importingBasket": "导入中...", + "basketRemove": "从购物篮中移除", + "basketImportSuccess": "成功导入 {count} 本图书。" }, "scanner": { "title": "扫描 ISBN 条码", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 740832d6..2a03e1a0 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -55,6 +55,14 @@ export interface BookImportCandidateGroup { variants: BookImportCandidate[]; } +export interface BasketItem { + id: string; + candidate: BookImportCandidate; + readingStatus: ReadingStatus; + acquisitionStatus: AcquisitionStatus; + medium: Medium | null; +} + export interface CoverCandidate { source: 'abebooks' | 'openlibrary' | 'amazon' | 'hardcover' | 'thalia'; url: string; diff --git a/frontend/src/lib/utils/importSearch.test.ts b/frontend/src/lib/utils/importSearch.test.ts index fa0c6dbe..9b261f9a 100644 --- a/frontend/src/lib/utils/importSearch.test.ts +++ b/frontend/src/lib/utils/importSearch.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { authorKey, + basketCandidateKey, candidateKey, groupCandidates, normalize, @@ -94,6 +95,31 @@ describe('candidateKey', () => { }); }); +describe('basketCandidateKey', () => { + it('does not distinguish variants by source', () => { + const ol = makeCandidate('Dune', { isbn: '9780441013593', source: 'open_library' }); + const hc = makeCandidate('Dune', { isbn: '9780441013593', source: 'hardcover' }); + const gb = makeCandidate('Dune', { isbn: '9780441013593', source: 'google_books' }); + + expect(basketCandidateKey(ol)).toBe(basketCandidateKey(hc)); + expect(basketCandidateKey(ol)).toBe(basketCandidateKey(gb)); + }); + + it('treats identical candidates as equal', () => { + const a = makeCandidate('Dune', { isbn: '9780441013593', source: 'open_library' }); + const b = makeCandidate('Dune', { isbn: '9780441013593', source: 'open_library' }); + + expect(basketCandidateKey(a)).toBe(basketCandidateKey(b)); + }); + + it('canonicalizes ISBN-10 and ISBN-13 variants from the same source as equal', () => { + const a = makeCandidate('Dune', { isbn: '0441013597', source: 'open_library' }); + const b = makeCandidate('Dune', { isbn: '9780441013593', source: 'open_library' }); + + expect(basketCandidateKey(a)).toBe(basketCandidateKey(b)); + }); +}); + describe('groupCandidates', () => { it('groups same-ISBN candidates into one group', () => { const ol = makeCandidate('Dune', { isbn: '9780441013593', source: 'open_library' }); diff --git a/frontend/src/lib/utils/importSearch.ts b/frontend/src/lib/utils/importSearch.ts index 9a6de62a..a7346631 100644 --- a/frontend/src/lib/utils/importSearch.ts +++ b/frontend/src/lib/utils/importSearch.ts @@ -49,6 +49,13 @@ export function candidateKey(candidate: BookImportCandidate): string { return `ta:${normalize(candidate.title)}|${authorKey(candidate.authors)}`; } +// Same-ISBN (or title+author) editions share one basket key regardless of +// source so the same book from two providers cannot be added twice, which +// would always fail on import with isbnAlreadyExists. +export function basketCandidateKey(candidate: BookImportCandidate): string { + return candidateKey(candidate); +} + export function groupCandidates(candidates: BookImportCandidate[]): BookImportCandidateGroup[] { const map = new Map(); for (const candidate of candidates) {