Add Goodreads series and group them on the about page

This commit is contained in:
SirBlob committed 2026-11-01 20:57:37 -05:00
1 parent 737700a9a8
commit 2318cdeaac
11 files changed
+651 -12

No files matched your search

+16
View File
@@ -250,3 +250,19 @@ RecordEditor {
width: 100%;
margin: 0 0 1 0;
}
SeriesPicker {
background: $background;
}
#series-table {
height: 1fr;
margin: 1 2 0 2;
background: $surface;
border: solid $primary;
}
.series-summary {
width: 100%;
padding: 0 2;
}
+38
View File
@@ -6,13 +6,16 @@ from textual.containers import Horizontal, VerticalScroll
from textual.widgets import DataTable, Static
import books
import goodreads_series
from tui.content_pane import ContentPane
from tui.dialogs import ConfirmDialog, FormDialog, FormField
from tui.series_picker import SeriesPicker
def book_details(book: dict) -> str:
rows = [
("Shelf", book.get("shelf")),
("Series", " ".join(filter(None, [(book.get("series") or {}).get("name"), (book.get("series") or {}).get("position")]))),
("Your rating", f"{book['rating']}/10" if book.get("rating") is not None else None),
("Goodreads", f"{book['averageRating']} from {book.get('ratingCount') or 0:,} ratings" if book.get("averageRating") else None),
("Pages", book.get("pages")),
@@ -31,6 +34,7 @@ def book_details(book: dict) -> str:
class BooksPane(ContentPane):
BINDINGS = [
Binding("a", "add", "Add"),
Binding("S", "add_series", "Add series"),
Binding("e", "rate", "Rate"),
Binding("s", "shelf", "Shelf"),
Binding("d", "delete", "Delete"),
@@ -160,3 +164,37 @@ class BooksPane(ContentPane):
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
summary = f"Refreshed {len(all_books) - failures} of {len(all_books)} books"
self.app.call_from_thread(self.notify, summary)
def action_add_series(self):
fields = [FormField("link", "Goodreads series link", placeholder="https://www.goodreads.com/series/92037-wings-of-fire", required=True)]
self.app.push_screen(FormDialog("Add books from a series", fields, "Load series"), self.load_series)
def load_series(self, values: dict | None):
if values:
self.run_in_background("Reading the series from Goodreads...", lambda: goodreads_series.scrape_series(values["link"]), self.pick_series_books)
def pick_series_books(self, series: dict):
def picked(choices: dict[str, str] | None):
if not choices:
return
self.notify(f"Adding {len(choices)} books from {series['name']}...")
self.add_series_books(series, choices)
self.app.push_screen(SeriesPicker(series, books.library_shelves()), picked)
@work(thread=True, exclusive=True, group="series")
def add_series_books(self, series: dict, choices: dict[str, str]):
added = 0
for entry in series["books"]:
shelf = choices.get(entry["goodreadsId"])
if not shelf:
continue
try:
book = books.add_series_book(series, entry, shelf)
except (OSError, ValueError) as error:
self.app.call_from_thread(self.notify, f"{entry['title']}: {error}", title="Skipped", severity="warning")
continue
added += 1
self.app.call_from_thread(self.notify, f"Saved {book['title']} ({shelf})")
self.app.call_from_thread(lambda: self.reload(self.selected_key()))
self.app.call_from_thread(self.notify, f"Saved {added} of {len(choices)} books from {series['name']}")
+97
View File
@@ -0,0 +1,97 @@
from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import DataTable, Footer, Label
SKIP = "skip"
CHOICE_STYLES = {"read": "green", "reading": "blue", "want-to-read": "magenta", SKIP: "dim"}
CHOICE_ORDER = ["read", "reading", "want-to-read", SKIP]
class SeriesPicker(Screen[dict[str, str] | None]):
BINDINGS = [
Binding("r", "choose('read')", "Read"),
Binding("c", "choose('reading')", "Reading"),
Binding("w", "choose('want-to-read')", "Want to read"),
Binding("x", "choose('skip')", "Skip"),
Binding("space", "cycle", "Cycle"),
Binding("R", "choose_all('read')", "All read"),
Binding("W", "choose_all('want-to-read')", "All want"),
Binding("ctrl+s", "save", "Save"),
Binding("escape", "cancel", "Cancel"),
]
def __init__(self, series: dict, current_shelves: dict[str, str]):
super().__init__()
self.series = series
self.choices = {entry["goodreadsId"]: current_shelves.get(entry["goodreadsId"], SKIP) for entry in series["books"]}
self.already_listed = set(current_shelves)
def compose(self) -> ComposeResult:
yield Label(f"{self.series['name']} [dim]{len(self.series['books'])} books, pick a shelf for each one[/dim]", classes="editor-title")
yield DataTable(cursor_type="row", zebra_stripes=True, id="series-table")
yield Label("", id="series-summary", classes="series-summary")
yield Footer()
def on_mount(self):
table = self.query_one(DataTable)
table.add_column("Shelf", key="shelf")
table.add_column("#", key="position")
table.add_column("Title", key="title")
table.add_column("Author", key="author")
table.add_column("", key="listed")
for entry in self.series["books"]:
identifier = entry["goodreadsId"]
listed = "in library" if identifier in self.already_listed else ""
table.add_row(self.choice_text(identifier), entry["position"] or "-", entry["title"], ", ".join(entry["authors"]), listed, key=identifier)
table.focus()
self.update_summary()
def choice_text(self, identifier: str) -> Text:
choice = self.choices[identifier]
return Text(choice, style=CHOICE_STYLES[choice])
def update_summary(self):
counts = {choice: list(self.choices.values()).count(choice) for choice in CHOICE_ORDER}
parts = [f"[{CHOICE_STYLES[choice]}]{counts[choice]} {choice}[/]" for choice in CHOICE_ORDER]
self.query_one("#series-summary", Label).update(" ".join(parts) + " [dim]ctrl+s to save[/dim]")
def selected_id(self) -> str | None:
table = self.query_one(DataTable)
if table.row_count == 0:
return None
row_key, _ = table.coordinate_to_cell_key(table.cursor_coordinate)
return row_key.value
def set_choice(self, identifier: str, choice: str):
self.choices[identifier] = choice
self.query_one(DataTable).update_cell(identifier, "shelf", self.choice_text(identifier))
def action_choose(self, choice: str):
identifier = self.selected_id()
if not identifier:
return
self.set_choice(identifier, choice)
self.update_summary()
self.query_one(DataTable).action_cursor_down()
def action_cycle(self):
identifier = self.selected_id()
if not identifier:
return
next_choice = CHOICE_ORDER[(CHOICE_ORDER.index(self.choices[identifier]) + 1) % len(CHOICE_ORDER)]
self.set_choice(identifier, next_choice)
self.update_summary()
def action_choose_all(self, choice: str):
for identifier in self.choices:
self.set_choice(identifier, choice)
self.update_summary()
def action_save(self):
picked = {identifier: choice for identifier, choice in self.choices.items() if choice != SKIP}
self.dismiss(picked)
def action_cancel(self):
self.dismiss(None)