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:
@@ -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]:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user