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.
This commit is contained in:
tim
2026-10-04 02:04:16 +02:00
parent d28f69d93a
commit b73b2808ca
6 changed files with 117 additions and 15 deletions
+7 -3
View File
@@ -649,10 +649,14 @@ def search_library(query: str) -> list[dict[str, Any]]:
return results return results
def library_candidates() -> list[dict[str, Any]]: def library_candidates(query: str | None = None) -> list[dict[str, Any]]:
"""Library books from a story site that are not tracked yet.""" """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"]} 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]: def download_fields() -> dict[str, str]:
+40 -8
View File
@@ -21,11 +21,12 @@ function libraryImport(config) {
let candidates = []; let candidates = [];
let selected = new Set(); let selected = new Set();
let check = true; let check = true;
let empty = '';
const results = h('div'); const results = h('div');
const render = () => { const render = () => {
if (!candidates.length) { 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; return;
} }
const toggle = (id, on) => { const toggle = (id, on) => {
@@ -81,7 +82,10 @@ function libraryImport(config) {
const books = candidates const books = candidates
.filter((c) => selected.has(c.id)) .filter((c) => selected.has(c.id))
.map(({ id, uuid, title }) => ({ id, uuid, title })); .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 }); const jobs = await api('library/import', 'POST', { books, check });
toast(`Queued ${jobs.length} imports`); toast(`Queued ${jobs.length} imports`);
fill(results); 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( return h(
'div', 'div',
{}, {},
h( h(
'p', 'p',
{ class: 'hint' }, { 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.', '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( h(
'div', 'div',
{ class: 'controls' }, { class: 'controls' },
button('Find stories in the library', async () => { button('Show all stories in the library', async () => show(await api('library/candidates'), true)),
candidates = await api('library/candidates');
selected = new Set(candidates.map((c) => c.id));
render();
}),
h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'), h('label', {}, checkbox(true, (on) => (check = on)), ' Check the site for new chapters after import'),
), ),
results, results,
+1 -1
View File
@@ -492,7 +492,7 @@ def delete_revision(chapter_id: int, rev: int) -> Any:
@app.get("/api/library/candidates") @app.get("/api/library/candidates")
def library_candidates() -> Any: def library_candidates() -> Any:
return orchestrator.library_candidates() return orchestrator.library_candidates(request.args.get("q"))
@app.get("/api/library/search") @app.get("/api/library/search")
+46 -2
View File
@@ -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 () => { test('library import sends only selected books and handles an empty candidate list', async () => {
await mountStories(); await mountStories();
api.on('GET', 'library/candidates', []); 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/); assert.match(main.textContent, /tracked already/);
const books = [{ id: 8, uuid: 'a', title: 'A', authors: ['W'] }, { id: 9, uuid: 'b', title: 'B', authors: [] }]; const books = [{ id: 8, uuid: 'a', title: 'A', authors: ['W'] }, { id: 9, uuid: 'b', title: 'B', authors: [] }];
api.on('GET', 'library/candidates', books); 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); change(field(main, 'Select all'), false);
await click(main, 'Import selected'); await click(main, 'Import selected');
assert.equal(writes().length, 0); 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 }); 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 () => { 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' }] }); api.on('GET', 'stories/1/rules', { ...emptyRules, rules: [rule, { ...rule, pattern: 'second' }] });
const editor = rulesEditor('stories/1/rules'); const editor = rulesEditor('stories/1/rules');
+13
View File
@@ -92,11 +92,24 @@ def test_library_search_link_import_and_unlink(client, story, monkeypatch):
monkeypatch.setattr(o, "cwa", lambda: cwa) monkeypatch.setattr(o, "cwa", lambda: cwa)
monkeypatch.setattr(config, "CWA_URL", "https://cwa.example") monkeypatch.setattr(config, "CWA_URL", "https://cwa.example")
assert client.get("/api/library/candidates").json[0]["id"] == 7 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 assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] is None
cwa.search.assert_called_once_with("Title") cwa.search.assert_called_once_with("Title")
linked = client.post(f"/api/stories/{sid}/link", json={"uuid": "book-7"}, headers=H) linked = client.post(f"/api/stories/{sid}/link", json={"uuid": "book-7"}, headers=H)
assert linked.json["calibre_id"] == 7 assert linked.json["calibre_id"] == 7
assert client.get("/api/library/candidates").json == [] 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" assert client.get("/api/library/search?q=Title").json[0]["tracked_as"] == "Story"
cwa.get_book.side_effect = CwaError("offline") cwa.get_book.side_effect = CwaError("offline")
assert client.get(f"/api/stories/{sid}").json["library_error"] == "offline" assert client.get(f"/api/stories/{sid}").json["library_error"] == "offline"
+10 -1
View File
@@ -151,9 +151,18 @@ def test_library_candidates_and_search(fake):
"formats": ["epub"], "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() story_id = add_story()
orchestrator.link_story(story_id, "uuid-1") 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" assert orchestrator.search_library("test")[0]["tracked_as"] == "Test Story"
with pytest.raises(KeyError): with pytest.raises(KeyError):
orchestrator.link_story(story_id, "uuid-nope") orchestrator.link_story(story_id, "uuid-nope")