From b73b2808ca506898180b447a841bdfd3ba75a7cb Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sun, 4 Oct 2026 02:00:55 +0200 Subject: [PATCH] Search the library in the import section The search shows the matches from a story site that Bindery does not track yet. The button that lists every such book stays. --- bindery/orchestrator.py | 10 +++++--- bindery/static/js/stories.js | 48 ++++++++++++++++++++++++++++++------ bindery/web.py | 2 +- tests/js/flows.test.mjs | 48 ++++++++++++++++++++++++++++++++++-- tests/test_api_flows.py | 13 ++++++++++ tests/test_deliver.py | 11 ++++++++- 6 files changed, 117 insertions(+), 15 deletions(-) diff --git a/bindery/orchestrator.py b/bindery/orchestrator.py index 7f99310..1d33f13 100644 --- a/bindery/orchestrator.py +++ b/bindery/orchestrator.py @@ -649,10 +649,14 @@ def search_library(query: str) -> list[dict[str, Any]]: return results -def library_candidates() -> list[dict[str, Any]]: - """Library books from a story site that are not tracked yet.""" +def library_candidates(query: str | None = None) -> list[dict[str, Any]]: + """Library books from a story site that are not tracked yet. With `query`, only the books that CWA finds for it.""" tracked_ids = {s["calibre_id"] for s in store.list_stories() if s["calibre_id"]} - return [b.to_dict() for b in cwa().site_books(fff.is_supported_site) if b.id not in tracked_ids] + if query is None: + books = cwa().site_books(fff.is_supported_site) + else: + books = [b for b in cwa().search(query) if fff.is_supported_site(b.publisher) and "epub" in b.formats] + return [b.to_dict() for b in books if b.id not in tracked_ids] def download_fields() -> dict[str, str]: diff --git a/bindery/static/js/stories.js b/bindery/static/js/stories.js index 093a744..0e8285c 100644 --- a/bindery/static/js/stories.js +++ b/bindery/static/js/stories.js @@ -21,11 +21,12 @@ function libraryImport(config) { let candidates = []; let selected = new Set(); let check = true; + let empty = ''; const results = h('div'); const render = () => { if (!candidates.length) { - fill(results, h('p', { class: 'empty' }, 'Every library book from a story site is tracked already.')); + fill(results, h('p', { class: 'empty' }, empty)); return; } const toggle = (id, on) => { @@ -81,7 +82,10 @@ function libraryImport(config) { const books = candidates .filter((c) => selected.has(c.id)) .map(({ id, uuid, title }) => ({ id, uuid, title })); - if (!books.length) return; + if (!books.length) { + toast('Select books first', true); + return; + } const jobs = await api('library/import', 'POST', { books, check }); toast(`Queued ${jobs.length} imports`); fill(results); @@ -92,23 +96,51 @@ function libraryImport(config) { ); }; + // The full list starts with each book selected. A search starts with no book selected. + const show = (books, all) => { + candidates = books; + selected = new Set(all ? books.map((c) => c.id) : []); + empty = all + ? 'Every library book from a story site is tracked already.' + : 'No library books from a story site match. A book that Bindery tracks already does not show.'; + render(); + }; + const query = h('input', { + type: 'search', + placeholder: 'Search the library by title or author', + 'aria-label': 'Search the library', + }); + const search = async () => { + const text = query.value.trim(); + if (text) show(await api(`library/candidates?q=${encodeURIComponent(text)}`), false); + }; + return h( 'div', {}, h( 'p', { class: 'hint' }, - 'Finds library books whose publisher is a story site, for example books from the FanFicFare calibre plugin. ' + + 'Search the library, or show all the stories in it. Both find only books whose publisher is a story site, ' + + 'for example books from the FanFicFare calibre plugin, and that Bindery does not track yet. ' + 'Importing downloads each ePub from CWA and keeps its chapters, including chapters the site no longer has.', ), + h( + 'form', + { + class: 'library-search', + onsubmit: (e) => { + e.preventDefault(); + search().catch(showError); + }, + }, + query, + h('button', { type: 'submit' }, 'Search'), + ), h( 'div', { class: 'controls' }, - button('Find stories in the library', async () => { - candidates = await api('library/candidates'); - selected = new Set(candidates.map((c) => c.id)); - render(); - }), + button('Show all stories in the library', async () => show(await api('library/candidates'), true)), h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'), ), results, diff --git a/bindery/web.py b/bindery/web.py index a8d2a1d..17cb576 100644 --- a/bindery/web.py +++ b/bindery/web.py @@ -492,7 +492,7 @@ def delete_revision(chapter_id: int, rev: int) -> Any: @app.get("/api/library/candidates") def library_candidates() -> Any: - return orchestrator.library_candidates() + return orchestrator.library_candidates(request.args.get("q")) @app.get("/api/library/search") diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index 9b273cd..1d7ce70 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -233,11 +233,11 @@ test('dropping a file on a closed epub import card opens it and fills the file f test('library import sends only selected books and handles an empty candidate list', async () => { await mountStories(); api.on('GET', 'library/candidates', []); - await click(main, 'Find stories in the library'); + await click(main, 'Show all stories in the library'); assert.match(main.textContent, /tracked already/); const books = [{ id: 8, uuid: 'a', title: 'A', authors: ['W'] }, { id: 9, uuid: 'b', title: 'B', authors: [] }]; api.on('GET', 'library/candidates', books); - await click(main, 'Find stories in the library'); + await click(main, 'Show all stories in the library'); change(field(main, 'Select all'), false); await click(main, 'Import selected'); assert.equal(writes().length, 0); @@ -252,6 +252,50 @@ test('library import sends only selected books and handles an empty candidate li assert.deepEqual(api.sent('POST', 'library/import')[0].body, { books: [{ id: 8, uuid: 'a', title: 'A' }], check: false }); }); +test('library import search trims the query, selects no book at first and imports the ticked books', async () => { + await mountStories(); + const card = section(main, 'Import from the library'); + const form = nodes(card).find((el) => el.tagName === 'form'); + assert.equal(form.className, 'library-search'); + assert.equal(find(form, 'button', 'Search').attributes.type, 'submit'); + emit(form, 'submit'); + field(card, 'Search the library').value = ' '; + emit(form, 'submit'); + await settle(); + assert.equal(api.requests.some((r) => r.path.startsWith('library/candidates')), false); + field(card, 'Search the library').value = ' A & B '; + api.on('GET', 'library/candidates?q=A%20%26%20B', []); + emit(form, 'submit'); + await settle(); + assert.match(card.textContent, /No library books from a story site match/); + assert.equal(find(card, 'button', 'Import selected'), undefined); + const books = [{ id: 8, uuid: 'a', title: 'A', authors: ['W'] }, { id: 9, uuid: 'b', title: 'B', authors: [] }]; + api.on('GET', 'library/candidates?q=A%20%26%20B', books); + emit(form, 'submit'); + await settle(); + assert.deepEqual(['Select all', 'A', 'B'].map((label) => field(card, label).checked), [false, false, false]); + await click(card, 'Import selected'); + assert.equal(writes().length, 0); + change(field(card, 'B'), true); + api.on('POST', 'library/import', [{ id: 'job' }]); + await click(card, 'Import selected'); + assert.deepEqual(api.sent('POST', 'library/import')[0].body, { books: [{ id: 9, uuid: 'b', title: 'B' }], check: true }); + // The full list still selects each book. + api.on('GET', 'library/candidates', books); + await click(card, 'Show all stories in the library'); + assert.deepEqual(['Select all', 'A', 'B'].map((label) => field(card, label).checked), [true, true, true]); +}); + +test('a failed library import search shows the error', async () => { + await mountStories(); + const card = section(main, 'Import from the library'); + field(card, 'Search the library').value = 'A'; + api.on('GET', 'library/candidates?q=A', { error: 'CWA is offline' }, 502); + emit(nodes(card).find((el) => el.tagName === 'form'), 'submit'); + await settle(); + assert.equal(find(card, 'button', 'Import selected'), undefined); +}); + test('rules edits, moves, removal and paragraph choices are saved in order', async () => { api.on('GET', 'stories/1/rules', { ...emptyRules, rules: [rule, { ...rule, pattern: 'second' }] }); const editor = rulesEditor('stories/1/rules'); diff --git a/tests/test_api_flows.py b/tests/test_api_flows.py index bae3fbd..1ab6bb6 100644 --- a/tests/test_api_flows.py +++ b/tests/test_api_flows.py @@ -92,11 +92,24 @@ def test_library_search_link_import_and_unlink(client, story, monkeypatch): monkeypatch.setattr(o, "cwa", lambda: cwa) monkeypatch.setattr(config, "CWA_URL", "https://cwa.example") assert client.get("/api/library/candidates").json[0]["id"] == 7 + cwa.search.assert_not_called() + # A search for candidates shows only the story-site books that have an ePub. + cwa.search.return_value = [ + book, + Book(8, "book-8", "Title in PDF", ["Writer"], "www.royalroad.com", {"pdf": "/book.pdf"}), + Book(9, "book-9", "Title of a novel", ["Writer"], "A Publisher", {"epub": "/novel.epub"}), + ] + assert [b["id"] for b in client.get("/api/library/candidates?q=Title").json] == [7] + cwa.search.assert_called_once_with("Title") + assert cwa.site_books.call_count == 1 + cwa.search.reset_mock() + cwa.search.return_value = [book] assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] is None cwa.search.assert_called_once_with("Title") linked = client.post(f"/api/stories/{sid}/link", json={"uuid": "book-7"}, headers=H) assert linked.json["calibre_id"] == 7 assert client.get("/api/library/candidates").json == [] + assert client.get("/api/library/candidates?q=Title").json == [] assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] == "Story" cwa.get_book.side_effect = CwaError("offline") assert client.get(f"/api/stories/{sid}").json["library_error"] == "offline" diff --git a/tests/test_deliver.py b/tests/test_deliver.py index b4016d8..fe25927 100644 --- a/tests/test_deliver.py +++ b/tests/test_deliver.py @@ -151,9 +151,18 @@ def test_library_candidates_and_search(fake): "formats": ["epub"], } ] + # A search shows only the matches from a story site. + fake.books.append(FakeBook(2, "uuid-2", "Test Novel", publisher="A Publisher", epub=b"x")) + fake.books.append(FakeBook(4, "uuid-4", "Other Story", publisher="www.royalroad.com", epub=b"x")) + assert [b["id"] for b in orchestrator.search_library("test")] == [1, 2] + assert orchestrator.library_candidates("test") == candidates + assert [b["id"] for b in orchestrator.library_candidates("story")] == [1, 4] + assert orchestrator.library_candidates("nothing") == [] + assert orchestrator.library_candidates(" ") == [] story_id = add_story() orchestrator.link_story(story_id, "uuid-1") - assert orchestrator.library_candidates() == [] + assert [b["id"] for b in orchestrator.library_candidates()] == [4] + assert orchestrator.library_candidates("test") == [] assert orchestrator.search_library("test")[0]["tracked_as"] == "Test Story" with pytest.raises(KeyError): orchestrator.link_story(story_id, "uuid-nope")