diff --git a/docs/guide/using-librislog/library.md b/docs/guide/using-librislog/library.md index 6a3198a3..c36f5969 100644 --- a/docs/guide/using-librislog/library.md +++ b/docs/guide/using-librislog/library.md @@ -69,6 +69,8 @@ Open Library and Hardcover (if an API token is configured) are queried **in para While a search is running, the **Search** button changes to **Cancel**, so you can stop the request at any time and refine your query. +The search dialog also supports multiple parallel searches. Click **New parallel search** to open another independent search panel. Each panel keeps its own results while selected books can be added to the shared basket. Choose the possession and medium once above the panels; those values apply to books added from any search. + #### How results are grouped Different providers often describe the same book slightly differently (title language, page count, publisher, cover). Instead of dropping these variants, LibrisLog keeps every result and groups the ones that represent the same book. Each group shows a **"N results"** badge with a **Show editions** toggle: expand it to review the individual records and pick the one you want to import. diff --git a/docs/releases.md b/docs/releases.md index 2079cac6..d20ca5bc 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -39,7 +39,7 @@ 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, 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. +**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, supports multiple parallel import searches, makes running searches cancelable, adds configurable reading-date automation, 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. 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 @@ -54,11 +54,14 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner - 🔗 **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 +- 🔎 **Parallel import searches**: open multiple independent search panels in the Add Book dialog and run different queries concurrently. Each panel keeps its own results and can add selected books to the shared import basket +- 📅 **Configurable reading-date automation**: choose independently whether moving a book to Currently Reading, Read, or Did Not Finish should fill a missing start or finish date automatically. Existing dates are preserved, and disabling automation allows intentionally unknown dates without additional transition popups. See the [Profile guide](/guide/using-librislog/profile#reading-date-automation) **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 - 🕐 **Timezone-aware progress date editing**: editing a progress entry's date in the book detail view now interprets the value in your profile timezone instead of the browser's, so entries stay on the correct calendar day and streaks remain accurate - 🏷️ **Better contrast for selected suggestion items**: the selected entry in tag and author suggestion dropdowns now has stronger contrast and a visible border in all themes +- ⚠️ **Undated read imports remain usable**: import previews show a non-blocking warning when a book is marked Read without a finish date, instead of treating the intentionally missing date as an import error **Breaking changes:** None. diff --git a/frontend/src/lib/components/AddBookModal.svelte b/frontend/src/lib/components/AddBookModal.svelte index 7314431f..a176ea5f 100644 --- a/frontend/src/lib/components/AddBookModal.svelte +++ b/frontend/src/lib/components/AddBookModal.svelte @@ -27,6 +27,14 @@ let scannedIsbn = $state(null); let basket = $state([]); let basketImporting = $state(false); + let searchSessionIds = $state([1]); + let nextSearchSessionId = 2; + const searchPanelStyles = [ + 'border-base-300 border-l-4 border-l-primary bg-primary/10', + 'border-base-300 border-l-4 border-l-secondary bg-secondary/10', + 'border-base-300 border-l-4 border-l-accent bg-accent/10', + 'border-base-300 border-l-4 border-l-info bg-info/10' + ]; // Manual form state let title = $state(''); @@ -78,6 +86,17 @@ cover_url = null; activeTab = 'manual'; basket = []; + searchSessionIds = [1]; + nextSearchSessionId = 2; + } + + function addSearchSession() { + searchSessionIds = [nextSearchSessionId++, ...searchSessionIds]; + } + + function removeSearchSession(id: number) { + if (searchSessionIds.length === 1) return; + searchSessionIds = searchSessionIds.filter((sessionId) => sessionId !== id); } function addToBasket(item: BasketItem) { @@ -354,24 +373,71 @@ - {:else if activeTab === 'import'} - { - scannerOpen = true; - }} - scannedIsbn={scannedIsbn} - onScannedHandled={() => { - scannedIsbn = null; - }} - onImport={(book) => { - onAdded?.(book); - open = false; - reset(); - }} - /> + {:else if activeTab === 'import'} +
+

{$_('import.parallelSearchDescription')}

+ +
+
+ + +
+
+ {#each searchSessionIds as sessionId, index (sessionId)} +
+ {#if searchSessionIds.length > 1} +
+

{$_('import.parallelSearchLabel', { values: { number: sessionId } })}

+ +
+ {/if} + { + scannerOpen = true; + }} + scannedIsbn={index === 0 ? scannedIsbn : null} + onScannedHandled={index === 0 ? () => { scannedIsbn = null; } : undefined} + onImport={(book) => { + onAdded?.(book); + open = false; + reset(); + }} + /> +
+ {/each} +
diff --git a/frontend/src/lib/components/AddBookModal.test.ts b/frontend/src/lib/components/AddBookModal.test.ts index e1b089f9..2feb67ed 100644 --- a/frontend/src/lib/components/AddBookModal.test.ts +++ b/frontend/src/lib/components/AddBookModal.test.ts @@ -100,6 +100,40 @@ describe('AddBookModal', () => { expect(importTab).toHaveClass('tab-active'); }); + it('can open multiple independent search panels', async () => { + render(AddBookModal, { props: { open: true } }); + await fireEvent.click(screen.getByRole('tab', { name: 'Search & Import' })); + await fireEvent.click(screen.getByRole('button', { name: 'New parallel search' })); + + expect(screen.getAllByPlaceholderText(/Search by title or author/)).toHaveLength(2); + expect(screen.getByRole('heading', { name: 'Search 1' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Search 2' })).toBeInTheDocument(); + expect(screen.getAllByRole('heading', { name: /Search [12]/ }).map((heading) => heading.textContent)).toEqual([ + 'Search 2', + 'Search 1' + ]); + await waitFor(() => expect(document.activeElement).toBe(screen.getAllByPlaceholderText(/Search by title or author/)[0])); + }); + + it('starts searches in separate panels without waiting for each other', async () => { + mockSearchStream.mockImplementation(async function* (query: string) { + yield { stage: 'complete', results: [] } as SearchStage; + }); + render(AddBookModal, { props: { open: true } }); + await fireEvent.click(screen.getByRole('tab', { name: 'Search & Import' })); + await fireEvent.click(screen.getByRole('button', { name: 'New parallel search' })); + + const inputs = screen.getAllByPlaceholderText(/Search by title or author/); + await fireEvent.input(inputs[0], { target: { value: 'Dune' } }); + await fireEvent.input(inputs[1], { target: { value: 'Foundation' } }); + const searchButtons = screen.getAllByRole('button', { name: 'Search' }); + await fireEvent.click(searchButtons[0]); + await fireEvent.click(searchButtons[1]); + + await waitFor(() => expect(mockSearchStream).toHaveBeenCalledTimes(2)); + expect(mockSearchStream.mock.calls.map((call) => call[0])).toEqual(['Dune', 'Foundation']); + }); + it('closes modal when close button clicked', async () => { render(AddBookModal, { props: { open: true } }); const closeBtn = screen.getByRole('button', { name: /close/i }); diff --git a/frontend/src/lib/components/ImportSearch.svelte b/frontend/src/lib/components/ImportSearch.svelte index c446161b..0e24241f 100644 --- a/frontend/src/lib/components/ImportSearch.svelte +++ b/frontend/src/lib/components/ImportSearch.svelte @@ -21,7 +21,11 @@ onImport, onOpenScanner, scannedIsbn = null, - onScannedHandled + onScannedHandled, + showMetadataControls = true, + acquisitionStatus = $bindable(''), + medium = $bindable(''), + focusOnMount = false }: { defaultStatus?: ReadingStatus; basket?: BasketItem[]; @@ -30,9 +34,14 @@ onOpenScanner?: () => void; scannedIsbn?: string | null; onScannedHandled?: () => void; + showMetadataControls?: boolean; + acquisitionStatus?: AcquisitionStatus | ''; + medium?: Medium | ''; + focusOnMount?: boolean; } = $props(); let query = $state(''); + let searchInput = $state(null); let searchType = $state<'title' | 'isbn'>('title'); let results = $state([]); let stages = $state([]); @@ -46,8 +55,6 @@ let lastHandledScannedIsbn = $state(null); let importedIsbns = $state>(new Set()); let importedTitleAuthors = $state>(new Set()); - let acquisitionStatus = $state(''); - let medium = $state(''); let searchAbortController: AbortController | null = null; let expandedGroups = $state>({}); let selectedVariantByGroup = $state>({}); @@ -61,6 +68,7 @@ ]; onMount(async () => { + if (focusOnMount) searchInput?.focus(); secureContext = isSecureContext(); cameraSupported = typeof navigator !== 'undefined' && @@ -317,6 +325,7 @@
{/if} - - - + {#if showMetadataControls} + + + + {/if} {#if results.length === 0 && !searching && stages.length === 0}

{$_('import.noResultsYet')}

diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 4fbe6579..30ffa27c 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -261,7 +261,11 @@ "importBasket": "Warenkorb importieren", "importingBasket": "Importiere...", "basketRemove": "Aus dem Warenkorb entfernen", - "basketImportSuccess": "{count} Bücher erfolgreich importiert." + "basketImportSuccess": "{count} Bücher erfolgreich importiert.", + "parallelSearchDescription": "Führe mehrere unabhängige Suchen gleichzeitig aus. Jede Suche behält ihre Ergebnisse und kann Bücher in den gemeinsamen Korb legen.", + "newParallelSearch": "Neue parallele Suche", + "parallelSearchLabel": "Suche {number}", + "removeParallelSearch": "Suche entfernen" }, "scanner": { "title": "ISBN-Barcode scannen", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 1c8484e2..c9808a3a 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -261,7 +261,11 @@ "importBasket": "Import Basket", "importingBasket": "Importing...", "basketRemove": "Remove from basket", - "basketImportSuccess": "Imported {count} books successfully." + "basketImportSuccess": "Imported {count} books successfully.", + "parallelSearchDescription": "Run several independent searches at the same time. Each search keeps its own results and can add books to the shared basket.", + "newParallelSearch": "New parallel search", + "parallelSearchLabel": "Search {number}", + "removeParallelSearch": "Remove search" }, "scanner": { "title": "Scan ISBN Barcode", diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 2fe8b115..c05a57b6 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -261,7 +261,11 @@ "importBasket": "Importar cesta", "importingBasket": "Importando...", "basketRemove": "Quitar de la cesta", - "basketImportSuccess": "{count} libros importados correctamente." + "basketImportSuccess": "{count} libros importados correctamente.", + "parallelSearchDescription": "Ejecuta varias búsquedas independientes al mismo tiempo. Cada búsqueda conserva sus resultados y puede añadir libros a la cesta compartida.", + "newParallelSearch": "Nueva búsqueda paralela", + "parallelSearchLabel": "Búsqueda {number}", + "removeParallelSearch": "Eliminar búsqueda" }, "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 6f03d092..f8e60e50 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -261,7 +261,11 @@ "importBasket": "Importer le panier", "importingBasket": "Importation...", "basketRemove": "Retirer du panier", - "basketImportSuccess": "{count} livres importés avec succès." + "basketImportSuccess": "{count} livres importés avec succès.", + "parallelSearchDescription": "Exécutez plusieurs recherches indépendantes en même temps. Chaque recherche conserve ses résultats et peut ajouter des livres au panier partagé.", + "newParallelSearch": "Nouvelle recherche parallèle", + "parallelSearchLabel": "Recherche {number}", + "removeParallelSearch": "Supprimer la recherche" }, "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 810a4968..e8142d2b 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -261,7 +261,11 @@ "importBasket": "导入购物篮", "importingBasket": "导入中...", "basketRemove": "从购物篮中移除", - "basketImportSuccess": "成功导入 {count} 本图书。" + "basketImportSuccess": "成功导入 {count} 本图书。", + "parallelSearchDescription": "同时运行多个独立搜索。每个搜索会保留自己的结果,并可将图书添加到共享书篮。", + "newParallelSearch": "新的并行搜索", + "parallelSearchLabel": "搜索 {number}", + "removeParallelSearch": "移除搜索" }, "scanner": { "title": "扫描 ISBN 条码",