Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/using-librislog/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion docs/releases.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ LibrisLog v1.8.0 brings camera selection and zoom control to the barcode scanner

<Badge type="warning" text="Unreleased" /> <Badge type="tip" text="Feature release" />

**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
Expand All @@ -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.

Expand Down
102 changes: 84 additions & 18 deletions frontend/src/lib/components/AddBookModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
let scannedIsbn = $state<string | null>(null);
let basket = $state<BasketItem[]>([]);
let basketImporting = $state(false);
let searchSessionIds = $state<number[]>([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('');
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -354,24 +373,71 @@
</button>
</div>
</form>
{:else if activeTab === 'import'}
<ImportSearch
defaultStatus={defaultStatus}
basket={basket}
onAddToBasket={addToBasket}
onOpenScanner={() => {
scannerOpen = true;
}}
scannedIsbn={scannedIsbn}
onScannedHandled={() => {
scannedIsbn = null;
}}
onImport={(book) => {
onAdded?.(book);
open = false;
reset();
}}
/>
{:else if activeTab === 'import'}
<div class="flex items-center justify-between gap-3 mb-3 rounded-lg bg-base-200/60 p-3">
<p class="text-sm text-base-content/70">{$_('import.parallelSearchDescription')}</p>
<button class="btn btn-outline btn-sm shrink-0" type="button" onclick={addSearchSession}>
{$_('import.newParallelSearch')}
</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<label class="flex flex-col gap-1 text-sm">
<span>{$_('book.medium')}</span>
<select class="select select-bordered select-sm" name="import-medium" bind:value={medium}>
<option value="">{$_('book.selectMedium')}</option>
{#each MEDIUM_OPTIONS as opt}
<option value={opt.value}>{$_(opt.label)}</option>
{/each}
</select>
</label>
<label class="flex flex-col gap-1 text-sm">
<span>{$_('book.acquisitionStatus')} <span class="text-error">*</span></span>
<select class="select select-bordered select-sm" name="import-acquisition-status" bind:value={acquisitionStatus}>
<option value="" disabled>{$_('book.selectAcquisitionStatus')}</option>
{#each ACQUISITION_OPTIONS as opt}
<option value={opt.value}>{$_(opt.label)}</option>
{/each}
</select>
</label>
</div>
<div class="flex flex-col gap-4">
{#each searchSessionIds as sessionId, index (sessionId)}
<section class={`rounded-xl border p-3 ${searchPanelStyles[index % searchPanelStyles.length]}`}>
{#if searchSessionIds.length > 1}
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-semibold">{$_('import.parallelSearchLabel', { values: { number: sessionId } })}</h4>
<button
class="btn btn-ghost btn-xs"
type="button"
onclick={() => removeSearchSession(sessionId)}
aria-label={$_('import.removeParallelSearch')}
>
{$_('import.removeParallelSearch')}
</button>
</div>
{/if}
<ImportSearch
defaultStatus={defaultStatus}
showMetadataControls={false}
acquisitionStatus={acquisitionStatus}
medium={medium}
focusOnMount={sessionId !== 1 && sessionId === searchSessionIds[0]}
basket={basket}
onAddToBasket={addToBasket}
onOpenScanner={() => {
scannerOpen = true;
}}
scannedIsbn={index === 0 ? scannedIsbn : null}
onScannedHandled={index === 0 ? () => { scannedIsbn = null; } : undefined}
onImport={(book) => {
onAdded?.(book);
open = false;
reset();
}}
/>
</section>
{/each}
</div>
<div class="mt-3 text-center">
<a href="/data?tab=import" class="link link-primary text-sm">{$_('addModal.importFromFile')}</a>
</div>
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/lib/components/AddBookModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
57 changes: 34 additions & 23 deletions frontend/src/lib/components/ImportSearch.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
onImport,
onOpenScanner,
scannedIsbn = null,
onScannedHandled
onScannedHandled,
showMetadataControls = true,
acquisitionStatus = $bindable<AcquisitionStatus | ''>(''),
medium = $bindable<Medium | ''>(''),
focusOnMount = false
}: {
defaultStatus?: ReadingStatus;
basket?: BasketItem[];
Expand All @@ -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<HTMLInputElement | null>(null);
let searchType = $state<'title' | 'isbn'>('title');
let results = $state<BookImportCandidate[]>([]);
let stages = $state<SearchStage[]>([]);
Expand All @@ -46,8 +55,6 @@
let lastHandledScannedIsbn = $state<string | null>(null);
let importedIsbns = $state<Set<string>>(new Set());
let importedTitleAuthors = $state<Set<string>>(new Set());
let acquisitionStatus = $state<AcquisitionStatus | ''>('');
let medium = $state<Medium | ''>('');
let searchAbortController: AbortController | null = null;
let expandedGroups = $state<Record<string, boolean>>({});
let selectedVariantByGroup = $state<Record<string, number>>({});
Expand All @@ -61,6 +68,7 @@
];

onMount(async () => {
if (focusOnMount) searchInput?.focus();
secureContext = isSecureContext();
cameraSupported =
typeof navigator !== 'undefined' &&
Expand Down Expand Up @@ -317,6 +325,7 @@
<div class="flex flex-col gap-3 sm:pr-4">
<div class="flex flex-col sm:flex-row sm:items-center gap-2 grow basis-[0] min-w-[240px]">
<input
bind:this={searchInput}
type="text"
name="import-query"
class="input input-bordered w-full sm:w-auto sm:grow sm:min-w-0"
Expand Down Expand Up @@ -391,26 +400,28 @@
</p>
{/if}

<label class="flex flex-col gap-1 text-sm">
<span>{$_('book.medium')}</span>
<select class="select select-bordered select-sm" name="medium" bind:value={medium}>
<option value="">{$_('book.selectMedium')}</option>
{#each MEDIUM_OPTIONS as opt}
<option value={opt.value}>{$_(opt.label)}</option>
{/each}
</select>
</label>

<label class="flex flex-col gap-1 text-sm">
<span>{$_('book.acquisitionStatus')} <span class="text-error">*</span></span>
<select class="select select-bordered select-sm" name="acquisition_status" bind:value={acquisitionStatus}>
<option value="" disabled>{$_('book.selectAcquisitionStatus')}</option>
<option value="owned">{$_('acquisition.owned')}</option>
<option value="borrowed">{$_('acquisition.borrowed')}</option>
<option value="digital_access">{$_('acquisition.digital_access')}</option>
<option value="to_acquire">{$_('acquisition.to_acquire')}</option>
</select>
</label>
{#if showMetadataControls}
<label class="flex flex-col gap-1 text-sm">
<span>{$_('book.medium')}</span>
<select class="select select-bordered select-sm" name="medium" bind:value={medium}>
<option value="">{$_('book.selectMedium')}</option>
{#each MEDIUM_OPTIONS as opt}
<option value={opt.value}>{$_(opt.label)}</option>
{/each}
</select>
</label>

<label class="flex flex-col gap-1 text-sm">
<span>{$_('book.acquisitionStatus')} <span class="text-error">*</span></span>
<select class="select select-bordered select-sm" name="acquisition_status" bind:value={acquisitionStatus}>
<option value="" disabled>{$_('book.selectAcquisitionStatus')}</option>
<option value="owned">{$_('acquisition.owned')}</option>
<option value="borrowed">{$_('acquisition.borrowed')}</option>
<option value="digital_access">{$_('acquisition.digital_access')}</option>
<option value="to_acquire">{$_('acquisition.to_acquire')}</option>
</select>
</label>
{/if}

{#if results.length === 0 && !searching && stages.length === 0}
<p class="text-base-content/50 text-sm text-center py-4">{$_('import.noResultsYet')}</p>
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/lib/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,11 @@
"importBasket": "导入购物篮",
"importingBasket": "导入中...",
"basketRemove": "从购物篮中移除",
"basketImportSuccess": "成功导入 {count} 本图书。"
"basketImportSuccess": "成功导入 {count} 本图书。",
"parallelSearchDescription": "同时运行多个独立搜索。每个搜索会保留自己的结果,并可将图书添加到共享书篮。",
"newParallelSearch": "新的并行搜索",
"parallelSearchLabel": "搜索 {number}",
"removeParallelSearch": "移除搜索"
},
"scanner": {
"title": "扫描 ISBN 条码",
Expand Down