diff --git a/bindery/orchestrator.py b/bindery/orchestrator.py index 6e0b8a5..ea16199 100644 --- a/bindery/orchestrator.py +++ b/bindery/orchestrator.py @@ -651,14 +651,10 @@ def search_library(query: str) -> list[dict[str, Any]]: return results -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.""" +def library_candidates() -> list[dict[str, Any]]: + """Library books from a story site that are not tracked yet.""" tracked_ids = {s["calibre_id"] for s in store.list_stories() if s["calibre_id"]} - 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] + return [b.to_dict() for b in cwa().site_books(fff.is_supported_site) 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 2949f53..154123f 100644 --- a/bindery/static/js/stories.js +++ b/bindery/static/js/stories.js @@ -23,6 +23,8 @@ function libraryImport(config) { let selected = new Set(); let check = true; let empty = ''; + // The load of the list, kept for each search, because CWA takes a while to send the list. + let list; const results = h('div'); const render = () => { @@ -89,6 +91,8 @@ function libraryImport(config) { } const jobs = await api('library/import', 'POST', { books, check }); toast(`Queued ${jobs.length} imports`); + const queued = new Set(books.map((b) => b.id)); + list = list?.then((all) => all.filter((c) => !queued.has(c.id))); fill(results); refreshJobs(); }, @@ -108,13 +112,24 @@ function libraryImport(config) { }; const query = h('input', { type: 'search', - placeholder: 'Search the library by title or author', + placeholder: 'Search the library by title, author or site', 'aria-label': 'Search the library', }); - const search = async () => { - const text = query.value.trim(); - if (text) show(await api(`library/candidates?q=${encodeURIComponent(text)}`), false); + const load = () => { + list ??= api('library/candidates').catch((e) => { + list = undefined; + throw e; + }); + return list; }; + // A book matches if its title, authors and site hold each word of the search. + const search = async () => { + const words = query.value.toLowerCase().split(/\s+/).filter(Boolean); + if (!words.length) return; + const books = await load(); + show(books.filter((c) => words.every((w) => [c.title, ...c.authors, c.publisher].join(' ').toLowerCase().includes(w))), false); + }; + const submit = h('button', { type: 'submit' }, 'Search'); return h( 'div', @@ -132,16 +147,23 @@ function libraryImport(config) { class: 'library-search', onsubmit: (e) => { e.preventDefault(); - search().catch(showError); + if (submit.disabled) return; + submit.disabled = true; + search() + .catch(showError) + .finally(() => (submit.disabled = false)); }, }, query, - h('button', { type: 'submit' }, 'Search'), + submit, ), h( 'div', { class: 'controls' }, - button('Show all stories in the library', async () => show(await api('library/candidates'), true)), + button('Show all stories in the library', async () => { + list = undefined; + show(await load(), 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 17cb576..a8d2a1d 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(request.args.get("q")) + return orchestrator.library_candidates() @app.get("/api/library/search") diff --git a/tests/js/flows.test.mjs b/tests/js/flows.test.mjs index 0a59183..3fb630a 100644 --- a/tests/js/flows.test.mjs +++ b/tests/js/flows.test.mjs @@ -252,48 +252,89 @@ 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 () => { +test('library import search filters the loaded list, 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'); + const submit = find(form, 'button', 'Search'); 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(submit.attributes.type, 'submit'); + const search = async (text) => { + field(card, 'Search the library').value = text; + emit(form, 'submit'); + await settle(); + }; + const shown = () => ['Ash & Bone', 'Bright Sky', 'Ash Road'].filter((title) => field(card, title)); + await search(' '); 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(); + const books = [ + { id: 8, uuid: 'a', title: 'Ash & Bone', authors: ['Wren'], publisher: 'www.royalroad.com' }, + { id: 9, uuid: 'b', title: 'Bright Sky', authors: ['Ashley Poe', 'Wren'], publisher: 'archiveofourown.org' }, + { id: 10, uuid: 'c', title: 'Ash Road', authors: [], publisher: 'www.royalroad.com' }, + ]; + api.on('GET', 'library/candidates', books); + await search('nothing'); 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]); + // Each word must be in the title, the authors or the site. Capitals do not matter. + await search(' ASH '); + assert.deepEqual(shown(), ['Ash & Bone', 'Bright Sky', 'Ash Road']); + await search('ash royalroad'); + assert.deepEqual(shown(), ['Ash & Bone', 'Ash Road']); + await search('wren sky'); + assert.deepEqual(shown(), ['Bright Sky']); + assert.equal(api.sent('GET', 'library/candidates').length, 1, 'One load serves each search'); + await search('ash'); + assert.deepEqual(['Select all', ...shown()].map((label) => field(card, label).checked), [false, false, false, false]); await click(card, 'Import selected'); assert.equal(writes().length, 0); - change(field(card, 'B'), true); + change(field(card, 'Bright Sky'), 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); + assert.deepEqual(api.sent('POST', 'library/import')[0].body, { books: [{ id: 9, uuid: 'b', title: 'Bright Sky' }], check: true }); + // A queued book leaves the list, without another load. + await search('ash'); + assert.deepEqual(shown(), ['Ash & Bone', 'Ash Road']); + assert.equal(api.sent('GET', 'library/candidates').length, 1); + // The full list loads again, and selects each book. await click(card, 'Show all stories in the library'); - assert.deepEqual(['Select all', 'A', 'B'].map((label) => field(card, label).checked), [true, true, true]); + assert.equal(api.sent('GET', 'library/candidates').length, 2); + assert.deepEqual(['Select all', ...shown()].map((label) => field(card, label).checked), [true, true, true, true]); }); -test('a failed library import search shows the error', async () => { +test('the library import buttons cannot be used again until the answer comes', 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'); + const form = nodes(card).find((el) => el.tagName === 'form'); + const submit = find(form, 'button', 'Search'); + const all = find(card, 'button', 'Show all stories in the library'); + let answer; + api.on('GET', 'library/candidates', () => new Promise((resolve) => { answer = resolve; })); + field(card, 'Search the library').value = 'a'; + emit(form, 'submit'); await settle(); - assert.equal(find(card, 'button', 'Import selected'), undefined); + assert.equal(submit.disabled, true); + emit(form, 'submit'); + await settle(); + assert.equal(api.sent('GET', 'library/candidates').length, 1); + answer([{ id: 8, uuid: 'a', title: 'A', authors: [] }]); + await settle(); + assert.equal(submit.disabled, false); + assert.ok(field(card, 'A')); + emit(all, 'click'); + await settle(); + assert.equal(all.disabled, true); + answer([]); + await settle(); + assert.equal(all.disabled, false); + // A failed load frees the button, and the next search loads again. + api.on('GET', 'library/candidates', { error: 'CWA is offline' }, 502); + await click(card, 'Show all stories in the library'); + emit(form, 'submit'); + await settle(); + assert.equal(submit.disabled, false); + assert.equal(all.disabled, false); + assert.equal(api.sent('GET', 'library/candidates').length, 4); }); test('rules edits, moves, removal and paragraph choices are saved in order', async () => { diff --git a/tests/test_api_flows.py b/tests/test_api_flows.py index 1ab6bb6..bae3fbd 100644 --- a/tests/test_api_flows.py +++ b/tests/test_api_flows.py @@ -92,24 +92,11 @@ 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 fe25927..b4016d8 100644 --- a/tests/test_deliver.py +++ b/tests/test_deliver.py @@ -151,18 +151,9 @@ 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 [b["id"] for b in orchestrator.library_candidates()] == [4] - assert orchestrator.library_candidates("test") == [] + assert orchestrator.library_candidates() == [] assert orchestrator.search_library("test")[0]["tracked_as"] == "Test Story" with pytest.raises(KeyError): orchestrator.link_story(story_id, "uuid-nope")