-
{variant.source}
+
{sourceLabel(variant.source)}
{[
variant.published_year,
diff --git a/frontend/src/lib/components/ImportSearch.test.ts b/frontend/src/lib/components/ImportSearch.test.ts
index 6a4f7c9e..12fa8ad5 100644
--- a/frontend/src/lib/components/ImportSearch.test.ts
+++ b/frontend/src/lib/components/ImportSearch.test.ts
@@ -96,6 +96,13 @@ function makeStreamFinishing(results: BookImportCandidate[]) {
};
}
+function makeStreamWithStages(stages: SearchStage[]) {
+ return async function* (_q: unknown, _t: unknown, _m: unknown, _signal?: AbortSignal) {
+ for (const stage of stages) yield stage;
+ yield stageComplete([]);
+ };
+}
+
async function typeQueryAndSearch() {
const input = screen.getByPlaceholderText(/Search by title or author/);
await fireEvent.input(input, { target: { value: 'dune' } });
@@ -112,6 +119,95 @@ describe('ImportSearch', () => {
cleanup();
});
+ describe('source stage reporting', () => {
+ it('renders a row per reported source with its human readable name', async () => {
+ mockSearchStream.mockImplementation(
+ makeStreamWithStages([
+ { stage: 'open_library', status: 'done', count: 0 },
+ { stage: 'bnf', status: 'done', count: 1 },
+ { stage: 'inventaire', status: 'done', count: 1 },
+ { stage: 'library_of_congress', status: 'error', reason: 'backend_error' },
+ { stage: 'hardcover', status: 'skipped', reason: 'no_api_token' }
+ ])
+ );
+
+ render(ImportSearch, {});
+ await typeQueryAndSearch();
+
+ await waitFor(() => {
+ expect(screen.getByText('BnF - 1 result')).toBeInTheDocument();
+ expect(screen.getByText('Inventaire - 1 result')).toBeInTheDocument();
+ expect(screen.getByText('Open Library - 0 results')).toBeInTheDocument();
+ expect(screen.getByText(/Library of Congress backend error/)).toBeInTheDocument();
+ expect(screen.getByText(/Hardcover skipped/)).toBeInTheDocument();
+ });
+ });
+
+ it('shows a searching row for a source before its result arrives', async () => {
+ mockSearchStream.mockImplementation(
+ makeStreamWithStages([
+ { stage: 'inventaire', status: 'searching' },
+ { stage: 'inventaire', status: 'done', count: 2 }
+ ])
+ );
+
+ render(ImportSearch, {});
+ await typeQueryAndSearch();
+
+ await waitFor(() => {
+ expect(screen.getByText('Inventaire - 2 results')).toBeInTheDocument();
+ });
+ // The searching row was replaced by the terminal one, not stacked on top of it.
+ expect(screen.queryByText('Searching Inventaire...')).not.toBeInTheDocument();
+ });
+
+ it('does not crash on an unknown source stage', async () => {
+ mockSearchStream.mockImplementation(
+ makeStreamWithStages([
+ { stage: 'some_new_source', status: 'done', count: 1 } as unknown as SearchStage
+ ])
+ );
+
+ render(ImportSearch, {});
+ await typeQueryAndSearch();
+
+ await waitFor(() => {
+ expect(screen.getByText('some_new_source - 1 result')).toBeInTheDocument();
+ });
+ });
+
+ it('never renders a raw i18n key for a searching stage', async () => {
+ // The test translator falls back to the message id, so a missing key would
+ // otherwise show up as "import.sourceSearching" with no visible failure.
+ mockSearchStream.mockImplementation(
+ makeStreamWithStages([{ stage: 'library_of_congress', status: 'searching' }])
+ );
+
+ render(ImportSearch, {});
+ await typeQueryAndSearch();
+
+ await waitFor(() => {
+ expect(screen.getByText('Searching Library of Congress...')).toBeInTheDocument();
+ });
+ expect(screen.queryByText(/import\./)).not.toBeInTheDocument();
+ });
+
+ it('reports ISBN-only sources as skipped on a title search', async () => {
+ mockSearchStream.mockImplementation(
+ makeStreamWithStages([
+ { stage: 'inventaire', status: 'skipped', reason: 'title_search_unsupported' }
+ ])
+ );
+
+ render(ImportSearch, {});
+ await typeQueryAndSearch();
+
+ await waitFor(() => {
+ expect(screen.getByText('Inventaire skipped (ISBN searches only)')).toBeInTheDocument();
+ });
+ });
+ });
+
describe('cancelable search', () => {
it('turns the Search button into Cancel while searching', async () => {
mockSearchStream.mockImplementation(makeStream());
@@ -267,8 +363,8 @@ describe('ImportSearch', () => {
await fireEvent.click(screen.getByRole('button', { name: 'Show editions' }));
await waitFor(() => {
- expect(screen.getByText('hardcover')).toBeInTheDocument();
- expect(screen.getAllByText('open_library').length).toBeGreaterThanOrEqual(1);
+ expect(screen.getByText('Hardcover')).toBeInTheDocument();
+ expect(screen.getAllByText('Open Library').length).toBeGreaterThanOrEqual(1);
});
});
@@ -296,9 +392,9 @@ describe('ImportSearch', () => {
// Select the hardcover variant and then import.
await fireEvent.click(screen.getByRole('button', { name: 'Show editions' }));
await waitFor(() => {
- expect(screen.getByText('hardcover')).toBeInTheDocument();
+ expect(screen.getByText('Hardcover')).toBeInTheDocument();
});
- const variantButtons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('hardcover'));
+ const variantButtons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('Hardcover'));
await fireEvent.click(variantButtons[0]);
const acquisitionSelect = screen.getByRole('combobox', { name: /Possession/i });
@@ -530,9 +626,9 @@ describe('ImportSearch', () => {
// Select the hardcover variant.
await fireEvent.click(screen.getByRole('button', { name: 'Show editions' }));
await waitFor(() => {
- expect(screen.getByRole('button', { name: /^hardcover/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /^Hardcover/ })).toBeInTheDocument();
});
- const hardcoverButton = 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');
@@ -647,9 +743,9 @@ describe('ImportSearch', () => {
// Select the hardcover variant.
await fireEvent.click(screen.getByRole('button', { name: 'Show editions' }));
await waitFor(() => {
- expect(screen.getByRole('button', { name: /^hardcover/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /^Hardcover/ })).toBeInTheDocument();
});
- await fireEvent.click(screen.getByRole('button', { name: /^hardcover/ }));
+ 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
diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json
index 097f5d31..0fa71c69 100644
--- a/frontend/src/lib/i18n/locales/de.json
+++ b/frontend/src/lib/i18n/locales/de.json
@@ -241,11 +241,16 @@
"searchFailed": "Suche fehlgeschlagen",
"scannedIsbn": "ISBN gescannt: {isbn}",
"or": "oder",
- "sourceHardcoverSearching": "Durchsuche Hardcover...",
"sourceHardcoverSkipped": "Hardcover übersprungen (kein API-Token konfiguriert)",
- "sourceSkipped": "Google Books übersprungen (kein API-Schlüssel konfiguriert)",
- "sourceOpenLibrarySearching": "Durchsuche Open Library...",
- "sourceGoogleSearching": "Durchsuche Google Books...",
+ "sourceSkipped": "{source} übersprungen (nicht konfiguriert)",
+ "sourceSearching": "{source} wird durchsucht...",
+ "sourceTitleSearchUnsupported": "{source} übersprungen (nur ISBN-Suchen)",
+ "sourceNameOpenLibrary": "Open Library",
+ "sourceNameBnf": "BnF",
+ "sourceNameInventaire": "Inventaire",
+ "sourceNameLibraryOfCongress": "Library of Congress",
+ "sourceNameHardcover": "Hardcover",
+ "sourceNameGoogleBooks": "Google Books",
"sourceBackendError": "{source}-Backendfehler (Backend-Logs prüfen)",
"sourceError": "Suche fehlgeschlagen: {message}",
"resultCount": "{source} - {count} Ergebnis{suffix}",
diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json
index 9ef3a124..112ee882 100644
--- a/frontend/src/lib/i18n/locales/en.json
+++ b/frontend/src/lib/i18n/locales/en.json
@@ -241,11 +241,16 @@
"searchFailed": "Search failed",
"scannedIsbn": "Scanned ISBN: {isbn}",
"or": "or",
- "sourceHardcoverSearching": "Searching Hardcover...",
"sourceHardcoverSkipped": "Hardcover skipped (no API token configured)",
- "sourceSkipped": "Google Books skipped (no API key configured)",
- "sourceOpenLibrarySearching": "Searching Open Library...",
- "sourceGoogleSearching": "Searching Google Books...",
+ "sourceSkipped": "{source} skipped (not configured)",
+ "sourceSearching": "Searching {source}...",
+ "sourceTitleSearchUnsupported": "{source} skipped (ISBN searches only)",
+ "sourceNameOpenLibrary": "Open Library",
+ "sourceNameBnf": "BnF",
+ "sourceNameInventaire": "Inventaire",
+ "sourceNameLibraryOfCongress": "Library of Congress",
+ "sourceNameHardcover": "Hardcover",
+ "sourceNameGoogleBooks": "Google Books",
"sourceBackendError": "{source} backend error (check backend logs)",
"sourceError": "Search failed: {message}",
"resultCount": "{source} - {count} result{suffix}",
diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json
index f538a330..22d10414 100644
--- a/frontend/src/lib/i18n/locales/es.json
+++ b/frontend/src/lib/i18n/locales/es.json
@@ -241,11 +241,16 @@
"searchFailed": "Búsqueda fallida",
"scannedIsbn": "ISBN escaneado: {isbn}",
"or": "o",
- "sourceHardcoverSearching": "Buscando en Hardcover...",
"sourceHardcoverSkipped": "Hardcover omitido (sin token de API configurado)",
- "sourceSkipped": "Google Books omitido (sin clave de API configurada)",
- "sourceOpenLibrarySearching": "Buscando en Open Library...",
- "sourceGoogleSearching": "Buscando en Google Books...",
+ "sourceSkipped": "{source} omitido (no configurado)",
+ "sourceSearching": "Buscando en {source}...",
+ "sourceTitleSearchUnsupported": "{source} omitido (solo búsquedas por ISBN)",
+ "sourceNameOpenLibrary": "Open Library",
+ "sourceNameBnf": "BnF",
+ "sourceNameInventaire": "Inventaire",
+ "sourceNameLibraryOfCongress": "Biblioteca del Congreso",
+ "sourceNameHardcover": "Hardcover",
+ "sourceNameGoogleBooks": "Google Books",
"sourceBackendError": "Error de backend de {source} (revisa los registros del servidor)",
"sourceError": "Búsqueda fallida: {message}",
"resultCount": "{source} - {count} resultado{suffix}",
diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json
index 060e218f..88db0ffd 100644
--- a/frontend/src/lib/i18n/locales/fr.json
+++ b/frontend/src/lib/i18n/locales/fr.json
@@ -241,11 +241,16 @@
"searchFailed": "Échec de la recherche",
"scannedIsbn": "ISBN scanné : {isbn}",
"or": "ou",
- "sourceHardcoverSearching": "Recherche sur Hardcover...",
"sourceHardcoverSkipped": "Hardcover ignoré (aucun jeton API configuré)",
- "sourceSkipped": "Google Books ignoré (aucune clé API configurée)",
- "sourceOpenLibrarySearching": "Recherche sur Open Library...",
- "sourceGoogleSearching": "Recherche sur Google Books...",
+ "sourceSkipped": "{source} ignoré (non configuré)",
+ "sourceSearching": "Recherche sur {source}...",
+ "sourceTitleSearchUnsupported": "{source} ignoré (recherches par ISBN uniquement)",
+ "sourceNameOpenLibrary": "Open Library",
+ "sourceNameBnf": "BnF",
+ "sourceNameInventaire": "Inventaire",
+ "sourceNameLibraryOfCongress": "Bibliothèque du Congrès",
+ "sourceNameHardcover": "Hardcover",
+ "sourceNameGoogleBooks": "Google Books",
"sourceBackendError": "Erreur backend {source} (vérifie les journaux du serveur)",
"sourceError": "Échec de la recherche : {message}",
"resultCount": "{source} - {count} résultat{suffix}",
diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json
index f65f73a4..4b866bcd 100644
--- a/frontend/src/lib/i18n/locales/zh.json
+++ b/frontend/src/lib/i18n/locales/zh.json
@@ -241,11 +241,16 @@
"searchFailed": "搜索失败",
"scannedIsbn": "已扫描 ISBN:{isbn}",
"or": "或",
- "sourceHardcoverSearching": "正在搜索 Hardcover...",
"sourceHardcoverSkipped": "Hardcover 已跳过(未配置 API 令牌)",
- "sourceSkipped": "Google Books 已跳过(未配置 API 密钥)",
- "sourceOpenLibrarySearching": "正在搜索 Open Library...",
- "sourceGoogleSearching": "正在搜索 Google Books...",
+ "sourceSkipped": "{source} 已跳过(未配置)",
+ "sourceSearching": "正在搜索 {source}...",
+ "sourceTitleSearchUnsupported": "{source} 已跳过(仅支持 ISBN 搜索)",
+ "sourceNameOpenLibrary": "Open Library",
+ "sourceNameBnf": "BnF",
+ "sourceNameInventaire": "Inventaire",
+ "sourceNameLibraryOfCongress": "美国国会图书馆",
+ "sourceNameHardcover": "Hardcover",
+ "sourceNameGoogleBooks": "Google Books",
"sourceBackendError": "{source} 后端错误(请检查后端日志)",
"sourceError": "搜索失败:{message}",
"resultCount": "{source} - {count} 个结果",
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index 16903d78..906b8612 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -357,18 +357,23 @@ export interface BookProgress {
current_page: number;
}
+/** Search sources the backend can report progress for. */
+export type ImportSource =
+ | 'open_library'
+ | 'bnf'
+ | 'inventaire'
+ | 'library_of_congress'
+ | 'hardcover'
+ | 'google_books';
+
+export type ImportSourceStage =
+ | { stage: ImportSource; status: 'searching' }
+ | { stage: ImportSource; status: 'done'; count: number }
+ | { stage: ImportSource; status: 'skipped'; reason: string }
+ | { stage: ImportSource; status: 'error'; reason: string };
+
export type SearchStage =
- | { stage: 'open_library'; status: 'searching' }
- | { stage: 'open_library'; status: 'done'; count: number }
- | { stage: 'open_library'; status: 'error'; reason: string }
- | { stage: 'hardcover'; status: 'searching' }
- | { stage: 'hardcover'; status: 'done'; count: number }
- | { stage: 'hardcover'; status: 'skipped'; reason: string }
- | { stage: 'hardcover'; status: 'error'; reason: string }
- | { stage: 'google_books'; status: 'searching' }
- | { stage: 'google_books'; status: 'done'; count: number }
- | { stage: 'google_books'; status: 'skipped'; reason: string }
- | { stage: 'google_books'; status: 'error'; reason: string }
+ | ImportSourceStage
| { stage: 'complete'; results: BookImportCandidate[] }
| { stage: 'error'; message: string };