Give a library book one form in the API
A book from the search, from the list of library candidates and from a lookup now has the same fields, with the authors as a list.
This commit is contained in:
+2
-1
@@ -56,11 +56,12 @@ class Book:
|
|||||||
formats: dict[str, str] = field(default_factory=dict) # "epub" -> download href
|
formats: dict[str, str] = field(default_factory=dict) # "epub" -> download href
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
"""The book in the form of get_book, with its publisher."""
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"uuid": self.uuid,
|
"uuid": self.uuid,
|
||||||
"title": self.title,
|
"title": self.title,
|
||||||
"authors": " & ".join(self.authors),
|
"authors": self.authors,
|
||||||
"publisher": self.publisher,
|
"publisher": self.publisher,
|
||||||
"formats": sorted(self.formats),
|
"formats": sorted(self.formats),
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-9
@@ -620,11 +620,7 @@ def search_library(query: str) -> list[dict[str, Any]]:
|
|||||||
def library_candidates() -> list[dict[str, Any]]:
|
def library_candidates() -> 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."""
|
||||||
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 [
|
return [b.to_dict() for b in cwa().site_books(fff.is_supported_site) if b.id not in tracked_ids]
|
||||||
{"calibre_id": b.id, "uuid": b.uuid, "title": b.title, "authors": " & ".join(b.authors), "site": b.publisher}
|
|
||||||
for b in cwa().site_books(fff.is_supported_site)
|
|
||||||
if b.id not in tracked_ids
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_for_download(story_id: int) -> tuple[BinaryIO, str]:
|
def build_for_download(story_id: int) -> tuple[BinaryIO, str]:
|
||||||
@@ -779,19 +775,24 @@ def _link(story: dict[str, Any], book_id: int, book_uuid: str) -> None:
|
|||||||
store.update_story(story["id"], calibre_id=book_id, calibre_uuid=book_uuid, ingested_at=story["ingested_at"])
|
store.update_story(story["id"], calibre_id=book_id, calibre_uuid=book_uuid, ingested_at=story["ingested_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def _saved_book(story: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
|
"""The library book that the story names, without a check that CWA still has it."""
|
||||||
|
if story["calibre_id"] is None:
|
||||||
|
return None
|
||||||
|
return {"id": story["calibre_id"], "uuid": story["calibre_uuid"], "title": story["title"], "authors": []}
|
||||||
|
|
||||||
|
|
||||||
def _linked_book(story: dict[str, Any]) -> dict[str, Any] | None:
|
def _linked_book(story: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
"""The library book for a story. Links the story if CWA has a book with its URL."""
|
"""The library book for a story. Links the story if CWA has a book with its URL."""
|
||||||
if not _link_books():
|
if not _link_books():
|
||||||
if story["calibre_id"] is not None:
|
return _saved_book(story)
|
||||||
return {"id": story["calibre_id"], "uuid": story["calibre_uuid"], "title": story["title"]}
|
|
||||||
return None
|
|
||||||
if story["calibre_uuid"]:
|
if story["calibre_uuid"]:
|
||||||
try:
|
try:
|
||||||
book = cwa().get_book(story["calibre_uuid"])
|
book = cwa().get_book(story["calibre_uuid"])
|
||||||
except CwaError as e:
|
except CwaError as e:
|
||||||
# CWA checks the book id itself, so an update can still go out.
|
# CWA checks the book id itself, so an update can still go out.
|
||||||
logger.warning(f"Cannot check library book {story['calibre_id']}: {e}")
|
logger.warning(f"Cannot check library book {story['calibre_id']}: {e}")
|
||||||
return {"id": story["calibre_id"], "uuid": story["calibre_uuid"], "title": story["title"]}
|
return _saved_book(story)
|
||||||
if book:
|
if book:
|
||||||
if book["id"] != story["calibre_id"]:
|
if book["id"] != story["calibre_id"]:
|
||||||
_link(story, book["id"], book["uuid"])
|
_link(story, book["id"], book["uuid"])
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function libraryImport(config) {
|
|||||||
'th',
|
'th',
|
||||||
{},
|
{},
|
||||||
checkbox(selected.size === candidates.length, (on) => {
|
checkbox(selected.size === candidates.length, (on) => {
|
||||||
selected = new Set(on ? candidates.map((c) => c.calibre_id) : []);
|
selected = new Set(on ? candidates.map((c) => c.id) : []);
|
||||||
render();
|
render();
|
||||||
}, { 'aria-label': 'Select all' }),
|
}, { 'aria-label': 'Select all' }),
|
||||||
),
|
),
|
||||||
@@ -66,10 +66,10 @@ function libraryImport(config) {
|
|||||||
h(
|
h(
|
||||||
'tr',
|
'tr',
|
||||||
{},
|
{},
|
||||||
h('td', {}, checkbox(selected.has(c.calibre_id), (on) => toggle(c.calibre_id, on), { 'aria-label': c.title })),
|
h('td', {}, checkbox(selected.has(c.id), (on) => toggle(c.id, on), { 'aria-label': c.title })),
|
||||||
h('td', {}, c.title),
|
h('td', {}, c.title),
|
||||||
h('td', {}, c.authors),
|
h('td', {}, c.authors.join(' & ')),
|
||||||
h('td', {}, c.site),
|
h('td', {}, c.publisher),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -79,8 +79,8 @@ function libraryImport(config) {
|
|||||||
'Import selected',
|
'Import selected',
|
||||||
async () => {
|
async () => {
|
||||||
const books = candidates
|
const books = candidates
|
||||||
.filter((c) => selected.has(c.calibre_id))
|
.filter((c) => selected.has(c.id))
|
||||||
.map((c) => ({ id: c.calibre_id, uuid: c.uuid, title: c.title }));
|
.map(({ id, uuid, title }) => ({ id, uuid, title }));
|
||||||
if (!books.length) return;
|
if (!books.length) 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`);
|
||||||
@@ -106,7 +106,7 @@ function libraryImport(config) {
|
|||||||
{ class: 'controls' },
|
{ class: 'controls' },
|
||||||
button('Find stories in the library', async () => {
|
button('Find stories in the library', async () => {
|
||||||
candidates = await api('library/candidates');
|
candidates = await api('library/candidates');
|
||||||
selected = new Set(candidates.map((c) => c.calibre_id));
|
selected = new Set(candidates.map((c) => c.id));
|
||||||
render();
|
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'),
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const editText = (key, value) => {
|
|||||||
return formatField(key, value);
|
return formatField(key, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const authorsText = (authors) => (Array.isArray(authors) ? authors.join(' & ') : authors);
|
const authorsText = (authors) => authors.join(' & ');
|
||||||
|
|
||||||
// On a narrow screen, the rows stack instead of scrolling sideways. `extra` names the wrapper class
|
// On a narrow screen, the rows stack instead of scrolling sideways. `extra` names the wrapper class
|
||||||
// that gives a table's stacked rows their own layout, such as picking out the row's first cell.
|
// that gives a table's stacked rows their own layout, such as picking out the row's first cell.
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ test('library import sends only selected books and handles an empty candidate li
|
|||||||
api.on('GET', 'library/candidates', []);
|
api.on('GET', 'library/candidates', []);
|
||||||
await click(main, 'Find stories in the library');
|
await click(main, 'Find stories in the library');
|
||||||
assert.match(main.textContent, /tracked already/);
|
assert.match(main.textContent, /tracked already/);
|
||||||
const books = [{ calibre_id: 8, uuid: 'a', title: 'A' }, { calibre_id: 9, uuid: 'b', title: 'B' }];
|
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, 'Find stories in the library');
|
||||||
change(field(main, 'Select all'), false);
|
change(field(main, 'Select all'), false);
|
||||||
@@ -479,7 +479,7 @@ test('library search trims the query and links by UUID', async () => {
|
|||||||
await settle();
|
await settle();
|
||||||
assert.match(main.textContent, /No library books match/);
|
assert.match(main.textContent, /No library books match/);
|
||||||
api.on('GET', 'library/search?q=A%20%26%20B', [{ id: 7, uuid: 'book-7', title: 'A & B', authors: ['A', 'B'] },
|
api.on('GET', 'library/search?q=A%20%26%20B', [{ id: 7, uuid: 'book-7', title: 'A & B', authors: ['A', 'B'] },
|
||||||
{ id: 8, uuid: 'book-8', title: 'Tracked', authors: 'Writer', tracked_as: 'Other' }]);
|
{ id: 8, uuid: 'book-8', title: 'Tracked', authors: ['Writer'], tracked_as: 'Other' }]);
|
||||||
emit(form, 'submit');
|
emit(form, 'submit');
|
||||||
await settle();
|
await settle();
|
||||||
const links = nodes(main).filter((el) => el.tagName === 'button' && el.textContent === 'Link');
|
const links = nodes(main).filter((el) => el.tagName === 'button' && el.textContent === 'Link');
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ def test_library_search_link_import_and_unlink(client, story, monkeypatch):
|
|||||||
cwa.get_book.return_value = book.to_dict()
|
cwa.get_book.return_value = book.to_dict()
|
||||||
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]["calibre_id"] == 7
|
assert client.get("/api/library/candidates").json[0]["id"] == 7
|
||||||
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)
|
||||||
|
|||||||
@@ -142,7 +142,14 @@ def test_import_from_the_library(fake):
|
|||||||
def test_library_candidates_and_search(fake):
|
def test_library_candidates_and_search(fake):
|
||||||
candidates = orchestrator.library_candidates()
|
candidates = orchestrator.library_candidates()
|
||||||
assert candidates == [
|
assert candidates == [
|
||||||
{"calibre_id": 1, "uuid": "uuid-1", "title": "Test Story", "authors": "Writer", "site": "www.royalroad.com"}
|
{
|
||||||
|
"id": 1,
|
||||||
|
"uuid": "uuid-1",
|
||||||
|
"title": "Test Story",
|
||||||
|
"authors": ["Writer"],
|
||||||
|
"publisher": "www.royalroad.com",
|
||||||
|
"formats": ["epub"],
|
||||||
|
}
|
||||||
]
|
]
|
||||||
story_id = add_story()
|
story_id = add_story()
|
||||||
orchestrator.link_story(story_id, "uuid-1")
|
orchestrator.link_story(story_id, "uuid-1")
|
||||||
|
|||||||
Reference in New Issue
Block a user