Filter the import search from the list of library books
The search through CWA gave no results for books that the full list shows. The search now filters the list that "Show all stories in the library" loads, by title, author and site. The buttons stay disabled until the answer comes. A book that the user imports leaves the list.
This commit is contained in:
@@ -651,14 +651,10 @@ def search_library(query: str) -> list[dict[str, Any]]:
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def library_candidates(query: str | None = None) -> list[dict[str, Any]]:
|
def library_candidates() -> 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."""
|
"""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"]}
|
tracked_ids = {s["calibre_id"] for s in store.list_stories() if s["calibre_id"]}
|
||||||
if query is None:
|
return [b.to_dict() for b in cwa().site_books(fff.is_supported_site) if b.id not in tracked_ids]
|
||||||
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]:
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ function libraryImport(config) {
|
|||||||
let selected = new Set();
|
let selected = new Set();
|
||||||
let check = true;
|
let check = true;
|
||||||
let empty = '';
|
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 results = h('div');
|
||||||
|
|
||||||
const render = () => {
|
const render = () => {
|
||||||
@@ -89,6 +91,8 @@ function libraryImport(config) {
|
|||||||
}
|
}
|
||||||
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`);
|
||||||
|
const queued = new Set(books.map((b) => b.id));
|
||||||
|
list = list?.then((all) => all.filter((c) => !queued.has(c.id)));
|
||||||
fill(results);
|
fill(results);
|
||||||
refreshJobs();
|
refreshJobs();
|
||||||
},
|
},
|
||||||
@@ -108,13 +112,24 @@ function libraryImport(config) {
|
|||||||
};
|
};
|
||||||
const query = h('input', {
|
const query = h('input', {
|
||||||
type: 'search',
|
type: 'search',
|
||||||
placeholder: 'Search the library by title or author',
|
placeholder: 'Search the library by title, author or site',
|
||||||
'aria-label': 'Search the library',
|
'aria-label': 'Search the library',
|
||||||
});
|
});
|
||||||
const search = async () => {
|
const load = () => {
|
||||||
const text = query.value.trim();
|
list ??= api('library/candidates').catch((e) => {
|
||||||
if (text) show(await api(`library/candidates?q=${encodeURIComponent(text)}`), false);
|
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(
|
return h(
|
||||||
'div',
|
'div',
|
||||||
@@ -132,16 +147,23 @@ function libraryImport(config) {
|
|||||||
class: 'library-search',
|
class: 'library-search',
|
||||||
onsubmit: (e) => {
|
onsubmit: (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
search().catch(showError);
|
if (submit.disabled) return;
|
||||||
|
submit.disabled = true;
|
||||||
|
search()
|
||||||
|
.catch(showError)
|
||||||
|
.finally(() => (submit.disabled = false));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
query,
|
query,
|
||||||
h('button', { type: 'submit' }, 'Search'),
|
submit,
|
||||||
),
|
),
|
||||||
h(
|
h(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'controls' },
|
{ 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'),
|
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(request.args.get("q"))
|
return orchestrator.library_candidates()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/library/search")
|
@app.get("/api/library/search")
|
||||||
|
|||||||
+66
-25
@@ -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 });
|
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();
|
await mountStories();
|
||||||
const card = section(main, 'Import from the library');
|
const card = section(main, 'Import from the library');
|
||||||
const form = nodes(card).find((el) => el.tagName === 'form');
|
const form = nodes(card).find((el) => el.tagName === 'form');
|
||||||
|
const submit = find(form, 'button', 'Search');
|
||||||
assert.equal(form.className, 'library-search');
|
assert.equal(form.className, 'library-search');
|
||||||
assert.equal(find(form, 'button', 'Search').attributes.type, 'submit');
|
assert.equal(submit.attributes.type, 'submit');
|
||||||
emit(form, 'submit');
|
const search = async (text) => {
|
||||||
field(card, 'Search the library').value = ' ';
|
field(card, 'Search the library').value = text;
|
||||||
emit(form, 'submit');
|
emit(form, 'submit');
|
||||||
await settle();
|
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);
|
assert.equal(api.requests.some((r) => r.path.startsWith('library/candidates')), false);
|
||||||
field(card, 'Search the library').value = ' A & B ';
|
const books = [
|
||||||
api.on('GET', 'library/candidates?q=A%20%26%20B', []);
|
{ id: 8, uuid: 'a', title: 'Ash & Bone', authors: ['Wren'], publisher: 'www.royalroad.com' },
|
||||||
emit(form, 'submit');
|
{ id: 9, uuid: 'b', title: 'Bright Sky', authors: ['Ashley Poe', 'Wren'], publisher: 'archiveofourown.org' },
|
||||||
await settle();
|
{ 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.match(card.textContent, /No library books from a story site match/);
|
||||||
assert.equal(find(card, 'button', 'Import selected'), undefined);
|
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: [] }];
|
// Each word must be in the title, the authors or the site. Capitals do not matter.
|
||||||
api.on('GET', 'library/candidates?q=A%20%26%20B', books);
|
await search(' ASH ');
|
||||||
emit(form, 'submit');
|
assert.deepEqual(shown(), ['Ash & Bone', 'Bright Sky', 'Ash Road']);
|
||||||
await settle();
|
await search('ash royalroad');
|
||||||
assert.deepEqual(['Select all', 'A', 'B'].map((label) => field(card, label).checked), [false, false, false]);
|
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');
|
await click(card, 'Import selected');
|
||||||
assert.equal(writes().length, 0);
|
assert.equal(writes().length, 0);
|
||||||
change(field(card, 'B'), true);
|
change(field(card, 'Bright Sky'), true);
|
||||||
api.on('POST', 'library/import', [{ id: 'job' }]);
|
api.on('POST', 'library/import', [{ id: 'job' }]);
|
||||||
await click(card, 'Import selected');
|
await click(card, 'Import selected');
|
||||||
assert.deepEqual(api.sent('POST', 'library/import')[0].body, { books: [{ id: 9, uuid: 'b', title: 'B' }], check: true });
|
assert.deepEqual(api.sent('POST', 'library/import')[0].body, { books: [{ id: 9, uuid: 'b', title: 'Bright Sky' }], check: true });
|
||||||
// The full list still selects each book.
|
// A queued book leaves the list, without another load.
|
||||||
api.on('GET', 'library/candidates', books);
|
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');
|
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();
|
await mountStories();
|
||||||
const card = section(main, 'Import from the library');
|
const card = section(main, 'Import from the library');
|
||||||
field(card, 'Search the library').value = 'A';
|
const form = nodes(card).find((el) => el.tagName === 'form');
|
||||||
api.on('GET', 'library/candidates?q=A', { error: 'CWA is offline' }, 502);
|
const submit = find(form, 'button', 'Search');
|
||||||
emit(nodes(card).find((el) => el.tagName === 'form'), 'submit');
|
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();
|
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 () => {
|
test('rules edits, moves, removal and paragraph choices are saved in order', async () => {
|
||||||
|
|||||||
@@ -92,24 +92,11 @@ 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"
|
||||||
|
|||||||
+1
-10
@@ -151,18 +151,9 @@ 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 [b["id"] for b in orchestrator.library_candidates()] == [4]
|
assert orchestrator.library_candidates() == []
|
||||||
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