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
33 changes: 9 additions & 24 deletions backend/app/services/book_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ async def _run_hc() -> None:
for e in hc_events:
yield e

results = _merge_and_deduplicate(ol_results, hc_results)
results = _merge_results(ol_results, hc_results)

if not results:
if not api_key:
Expand Down Expand Up @@ -288,7 +288,7 @@ async def search(
logger.info("Open Library returned %d result(s) for %r", len(ol_results), query)
logger.info("Hardcover returned %d result(s) for %r", len(hc_results), query)

results = _merge_and_deduplicate(ol_results, hc_results)
results = _merge_results(ol_results, hc_results)

if not results:
if not api_key:
Expand Down Expand Up @@ -829,33 +829,18 @@ def map_hardcover(edition: dict) -> BookImportCandidate | None:
# ── Merge / Deduplicate ───────────────────────────────────────────────────────


def _merge_and_deduplicate(
def _merge_results(
primary: list[BookImportCandidate],
secondary: list[BookImportCandidate],
) -> list[BookImportCandidate]:
"""Merge two candidate lists, deduplicating by (isbn, page_count, language).
"""Merge two candidate lists, preserving every candidate in input order.

Primary list items come first in the result.
Same ISBN with different page_count/language is kept as separate candidates.
When two candidates collide, the one with a cover image is preferred.
The frontend is responsible for grouping variants that represent the same
book (e.g. by ISBN) and letting the user pick the best record. A user can
only own one book per exact ISBN string, so keeping all provider-specific
records lets the user compare data quality before importing.
"""
seen: dict[str, BookImportCandidate] = {}

def _key(c: BookImportCandidate) -> str:
isbn = (c.isbn or "").replace("-", "").replace(" ", "")
pages = str(c.page_count or "")
lang = (c.language or "").upper()
return f"isbn:{isbn}|pages:{pages}|lang:{lang}"

for c in primary + secondary:
k = _key(c)
existing = seen.get(k)
if existing is None:
seen[k] = c
elif existing.cover_url is None and c.cover_url is not None:
seen[k] = c

return list(seen.values())
return primary + secondary


# ── Helpers ───────────────────────────────────────────────────────────────────
Expand Down
27 changes: 14 additions & 13 deletions backend/tests/test_book_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -1006,22 +1006,23 @@ def test_hardcover_dedup_key_full() -> None:
assert key == ("9781234567897", 300, "en")


# ── _merge_and_deduplicate ─────────────────────────────────────────────────────
# ── _merge_results ─────────────────────────────────────────────────────────────

def test_merge_and_deduplicate_cover_preference() -> None:
def test_merge_results_preserves_all_candidates() -> None:
a = BookImportCandidate(title="A", isbn="123", cover_url=None, source="ol")
b = BookImportCandidate(title="B", isbn="123", cover_url="https://x.jpg", source="gb")
result = bi._merge_and_deduplicate([a], [b])
assert len(result) == 1
assert result[0].cover_url == "https://x.jpg"


def test_merge_and_deduplicate_no_cover_override() -> None:
a = BookImportCandidate(title="A", isbn="123", cover_url="https://a.jpg", source="ol")
b = BookImportCandidate(title="B", isbn="123", cover_url="https://b.jpg", source="gb")
result = bi._merge_and_deduplicate([a], [b])
assert len(result) == 1
assert result[0].cover_url == "https://a.jpg"
result = bi._merge_results([a], [b])
assert len(result) == 2
assert result[0] is a
assert result[1] is b


def test_merge_results_preserves_order() -> None:
a = BookImportCandidate(title="A", isbn="123", source="ol")
b = BookImportCandidate(title="B", isbn="123", source="gb")
c = BookImportCandidate(title="C", isbn="456", source="hc")
result = bi._merge_results([a, b], [c])
assert [r.title for r in result] == ["A", "B", "C"]


# ── _pick_isbn ─────────────────────────────────────────────────────────────────
Expand Down
77 changes: 22 additions & 55 deletions backend/tests/test_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,77 +289,44 @@ def test_map_hardcover_language_uppercased() -> None:
assert result.language == "DE"


# ── _merge_and_deduplicate unit tests ────────────────────────────────────────

def _make_candidate(title: str, isbn: str | None = None, pages: int | None = None, lang: str | None = None) -> BookImportCandidate:
"""Create a BookImportCandidate with default values for reuse in dedup tests."""
# ── _merge_results unit tests ─────────────────────────────────────────────────

def _make_candidate(
title: str,
isbn: str | None = None,
pages: int | None = None,
lang: str | None = None,
source: str = "open_library",
) -> BookImportCandidate:
"""Create a BookImportCandidate with default values for reuse in merge tests."""
return BookImportCandidate(
title=title,
author="Author",
isbn=isbn,
page_count=pages,
language=lang,
source="open_library",
source=source,
)


def test_merge_and_dedup_same_isbn_pages_lang() -> None:
a = _make_candidate("Dune", "9780441013593", 412, "EN")
b = _make_candidate("Dune", "9780441013593", 412, "EN")
result = book_import._merge_and_deduplicate([a], [b])
assert len(result) == 1
assert result[0].title == "Dune"


def test_merge_and_dedup_same_isbn_diff_pages() -> None:
def test_merge_results_preserves_all_candidates() -> None:
a = _make_candidate("Dune", "9780441013593", 412, "EN")
b = _make_candidate("Dune HC", "9780441013593", 688, "EN")
result = book_import._merge_and_deduplicate([a], [b])
b = _make_candidate("Dune", "9780441013593", 412, "EN", source="hardcover")
result = book_import._merge_results([a], [b])
assert len(result) == 2
assert result[0] is a
assert result[1] is b


def test_merge_and_dedup_same_isbn_diff_lang() -> None:
a = _make_candidate("Dune", "9780441013593", 412, "EN")
b = _make_candidate("Dune DE", "9780441013593", 412, "DE")
result = book_import._merge_and_deduplicate([a], [b])
assert len(result) == 2


def test_merge_and_dedup_ol_first_order() -> None:
def test_merge_results_preserves_order() -> None:
ol = _make_candidate("OL Book", "9781111111111", 200, "EN")
hc = _make_candidate("HC Book", "9782222222222", 300, "DE")
result = book_import._merge_and_deduplicate([ol], [hc])
assert len(result) == 2
hc = _make_candidate("HC Book", "9782222222222", 300, "DE", source="hardcover")
gb = _make_candidate("GB Book", "9783333333333", 250, "FR", source="google_books")
result = book_import._merge_results([ol], [hc, gb])
assert len(result) == 3
assert result[0].title == "OL Book"
assert result[1].title == "HC Book"


def test_merge_and_dedup_prefers_candidate_with_cover() -> None:
a = _make_candidate("No Cover", "9780441013593", 412, "EN")
b = _make_candidate("Has Cover", "9780441013593", 412, "EN")
b.cover_url = "https://example.com/cover.jpg"
result = book_import._merge_and_deduplicate([a], [b])
assert len(result) == 1
assert result[0].title == "Has Cover"


def test_merge_and_dedup_prefers_cover_when_primary_missing_cover() -> None:
a = _make_candidate("OL No Cover", "9780441013593", 412, "EN")
b = _make_candidate("HC Has Cover", "9780441013593", 412, "EN")
b.cover_url = "https://example.com/cover.jpg"
result = book_import._merge_and_deduplicate([a], [b])
assert len(result) == 1
assert result[0].title == "HC Has Cover"


def test_merge_and_dedup_keeps_primary_cover_when_both_have_cover() -> None:
a = _make_candidate("OL Cover", "9780441013593", 412, "EN")
a.cover_url = "https://ol-cover.jpg"
b = _make_candidate("HC Cover", "9780441013593", 412, "EN")
b.cover_url = "https://hc-cover.jpg"
result = book_import._merge_and_deduplicate([a], [b])
assert len(result) == 1
assert result[0].title == "OL Cover"
assert result[2].title == "GB Book"


# ── _hardcover_dedup_key tests ───────────────────────────────────────────────
Expand Down
21 changes: 20 additions & 1 deletion docs/guide/using-librislog/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,26 @@ Search external sources for book metadata:
- **Google Books** — Requires API key (set in `.env`)
- **Hardcover.app** — Requires API token (set in `.env`)

The search automatically tries Open Library first, then falls back to other sources. For ISBN searches, all available sources are queried in parallel.
Open Library and Hardcover (if an API token is configured) are queried **in parallel** for both title and ISBN searches. Google Books is only used as a **fallback** when the other sources return no results — or on demand via the **Search Google Books too** button, which adds Google Books results to the current results.

While a search is running, the **Search** button changes to **Cancel**, so you can stop the request at any time and refine your query.

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

Results are grouped by this rule:

- **Same ISBN** — if two results carry the same ISBN, they are grouped together. ISBN-10 and ISBN-13 forms of the same ISBN count as equal (e.g. `0441013597` and `9780441013593`).
- **No ISBN — same title + same authors** — results without an ISBN are grouped by a normalized title (case- and whitespace-insensitive) together with the same sorted author names.

Consequences you may notice:

- Two results with the *same title* but **different ISBNs** are **not** grouped — they are different editions (different language, publisher, or page count) and appear as separate entries.
- A result with an ISBN and a result without one are never grouped, even if the title and authors match.
- Results that differ only in metadata (cover, publisher, page count, description) but share an ISBN or title+author are grouped so you can compare them side by side.

Because an ISBN can only be owned once per user, a group with a shared ISBN always represents a single book — importing one variant is enough.

### ISBN Barcode Scan

Expand Down
41 changes: 41 additions & 0 deletions frontend/src/lib/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,44 @@ describe('api.statistics.gamification', () => {
expect(body).toMatchObject({ goal_pages_per_day_enabled: true, goal_pages_per_day: 25 });
});
});

describe('api.import.searchStream', () => {
beforeEach(() => {
apiKey.set(null);
csrfToken.set(null);
});

afterEach(() => {
vi.restoreAllMocks();
});

it('passes abort signal to fetch', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
body: new ReadableStream({ start(controller) { controller.close(); } }),
} as unknown as Response);

const controller = new AbortController();
const gen = api.import.searchStream('dune', 'title', 'auto', controller.signal);
await gen.next();

const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(init.signal).toBe(controller.signal);
});

it('builds the stream URL with query, type and mode', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
body: new ReadableStream({ start(controller) { controller.close(); } }),
} as unknown as Response);

const gen = api.import.searchStream('dune', 'isbn', 'google_only');
await gen.next();

const [url] = fetchMock.mock.calls[0] as [string];
expect(url).toContain('/import/search/stream');
expect(url).toContain('q=dune');
expect(url).toContain('type=isbn');
expect(url).toContain('mode=google_only');
});
});
5 changes: 3 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,11 +483,12 @@ export const api = {
async *searchStream(
q: string,
type: 'title' | 'isbn' = 'title',
mode: ImportSearchMode = 'auto'
mode: ImportSearchMode = 'auto',
signal?: AbortSignal
): AsyncGenerator<SearchStage> {
const res = await fetch(
`${BASE}/import/search/stream?q=${encodeURIComponent(q)}&type=${type}&mode=${mode}`,
{ headers: authHeaders() }
{ headers: authHeaders(), signal }
);
if (!res.ok || !res.body) {
const detail = await res.json().catch(() => ({}));
Expand Down
15 changes: 13 additions & 2 deletions frontend/src/lib/components/AddBookModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@
let cover_url = $state<string | null>(null);
$effect(() => { status = defaultStatus; });

// Close on Escape right away — the backdrop only receives key events
// after it has been clicked, so listen at the window level instead.
// Skip while the nested barcode scanner is open.
$effect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !scannerOpen) open = false;
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});

function reset() {
title = '';
subtitle = '';
Expand Down Expand Up @@ -268,7 +280,6 @@
isbn = detected;
}}
/>
<!-- Click-outside to close -->
<div class="modal-backdrop" role="button" tabindex="-1" onkeydown={(e) => e.key === 'Escape' && (open = false)}></div>
<div class="modal-backdrop"></div>
</div>
{/if}
13 changes: 12 additions & 1 deletion frontend/src/lib/components/AutoSearchCoverModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@
function close() {
onCancel?.();
}

// Close on Escape right away — the modal-backdrop only receives key events
// after it has been clicked, so listen at the window level instead.
$effect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
</script>

{#if open}
Expand All @@ -48,6 +59,6 @@
<button type="button" class="btn btn-ghost" onclick={close}>{$_('common.cancel')}</button>
</div>
</div>
<button type="button" class="modal-backdrop" aria-label={$_('common.close')} onclick={close}></button>
<div class="modal-backdrop"></div>
</div>
{/if}
10 changes: 9 additions & 1 deletion frontend/src/lib/components/AutoSearchCoverModal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,21 @@ describe('AutoSearchCoverModal', () => {
expect(onCancel).toHaveBeenCalledOnce();
});

it('calls onCancel when backdrop clicked', async () => {
it('does not call onCancel when backdrop clicked', async () => {
render(AutoSearchCoverModal, {
props: { open: true, loading: false, candidates: [], error: null, onCancel, onSelect }
});
const backdrop = document.querySelector('.modal-backdrop');
expect(backdrop).toBeTruthy();
await fireEvent.click(backdrop as Element);
expect(onCancel).not.toHaveBeenCalled();
});

it('calls onCancel when Escape is pressed', async () => {
render(AutoSearchCoverModal, {
props: { open: true, loading: false, candidates: [], error: null, onCancel, onSelect }
});
await fireEvent.keyDown(window, { key: 'Escape' });
expect(onCancel).toHaveBeenCalledOnce();
});

Expand Down
20 changes: 12 additions & 8 deletions frontend/src/lib/components/BarcodeScanner.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -339,21 +339,25 @@
}
});

// Close on Escape right away — the backdrop only receives key events
// after it has been clicked, so listen at the window level instead.
$effect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') void closeScanner();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});

onDestroy(() => {
void stopScanner();
});
</script>

{#if open}
<div class="fixed inset-0 z-[400]">
<div
class="absolute inset-0 bg-black/45"
onclick={closeScanner}
onkeydown={(e) => e.key === 'Escape' && closeScanner()}
role="button"
tabindex="0"
aria-label={$_('scanner.close')}
></div>
<div class="absolute inset-0 bg-black/45"></div>

<div class="absolute inset-0 z-[401] flex items-center justify-center p-2 sm:p-4">
<div class="w-full max-w-4xl h-[88dvh] bg-base-100 rounded-xl shadow-2xl flex flex-col overflow-hidden" role="dialog" aria-modal="true" aria-label={$_('scanner.title')}>
Expand Down
Loading