from rich.markup import escape from textual import on, work from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, VerticalScroll from textual.widgets import DataTable, Static import books from tui.content_pane import ContentPane from tui.dialogs import ConfirmDialog, FormDialog, FormField def book_details(book: dict) -> str: rows = [ ("Shelf", book.get("shelf")), ("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")), ("Published", book.get("published")), ("Genres", ", ".join(book.get("genres") or [])), ("Added", book.get("added")), ] lines = [f"[b]{escape(book.get('title', ''))}[/b]", escape(", ".join(book.get("authors") or [])), ""] lines += [f"[dim]{label:<12}[/dim]{escape(str(value))}" for label, value in rows if value] if book.get("description"): lines += ["", escape(book["description"])] lines += ["", f"[dim]{escape(book.get('url', ''))}[/dim]"] return "\n".join(lines) class BooksPane(ContentPane): BINDINGS = [ Binding("a", "add", "Add"), Binding("e", "rate", "Rate"), Binding("s", "shelf", "Shelf"), Binding("d", "delete", "Delete"), Binding("u", "refresh", "Refresh"), Binding("U", "refresh_all", "Refresh all"), Binding("r", "reload", "Reload"), ] content_file = "books.json" def compose(self) -> ComposeResult: with Horizontal(classes="split-layout"): yield DataTable(cursor_type="row", zebra_stripes=True) with VerticalScroll(classes="detail-scroll"): yield Static("Add a book with [b]a[/b] and a Goodreads link.", id="book-detail") def on_mount(self): table = self.table() table.add_column("Shelf", key="shelf") table.add_column("Rating", key="rating") table.add_column("Title", key="title") table.add_column("Authors", key="authors") self.reload() def book_by_id(self, identifier: str | None) -> dict | None: return next((book for book in books.load_books() if book.get("goodreadsId") == identifier), None) def reload(self, focus_key: str | None = None): table = self.table() table.clear() shelf_order = {shelf: index for index, shelf in enumerate(books.SHELVES)} sorted_books = sorted(books.load_books(), key=lambda book: (shelf_order.get(book.get("shelf"), 99), -(book.get("rating") or -1))) for book in sorted_books: rating = f"{book['rating']}/10" if book.get("rating") is not None else "-" table.add_row(book.get("shelf", ""), rating, book.get("title", ""), ", ".join(book.get("authors") or []), key=book["goodreadsId"]) self.restore_cursor(focus_key) self.show_details(self.selected_key()) def show_details(self, identifier: str | None): book = self.book_by_id(identifier) detail = self.query_one("#book-detail", Static) detail.update(book_details(book) if book else "Add a book with [b]a[/b] and a Goodreads link.") @on(DataTable.RowHighlighted) def highlight_changed(self, event: DataTable.RowHighlighted): self.show_details(event.row_key.value if event.row_key else None) def action_add(self): fields = [ FormField("link", "Goodreads link", placeholder="https://www.goodreads.com/book/show/170448.Animal_Farm", required=True), FormField("shelf", "Shelf", kind="select", options=books.SHELVES, value="read"), FormField("rating", "Your rating (0 to 10, optional)", kind="rating"), ] self.app.push_screen(FormDialog("Add a book", fields, "Add"), self.add_book) def add_book(self, values: dict | None): if not values: return def added(book: dict): self.reload(book["goodreadsId"]) self.notify(f"Added {book['title']} to {book['shelf']}") self.run_in_background("Reading the book from Goodreads...", lambda: books.add_book(values["link"], values["shelf"], values["rating"]), added) def action_rate(self): book = self.book_by_id(self.selected_key()) if not book: return current = "" if book.get("rating") is None else str(book["rating"]) fields = [FormField("rating", f"Rating for {book['title']} (empty to clear)", kind="rating", value=current)] def rated(values: dict | None): if values is not None: self.run_now(lambda: books.rate_book(book["goodreadsId"], values["rating"]), f"Updated {book['title']}", book["goodreadsId"]) self.app.push_screen(FormDialog("Change rating", fields), rated) def action_shelf(self): book = self.book_by_id(self.selected_key()) if not book: return fields = [FormField("shelf", f"Shelf for {book['title']}", kind="select", options=books.SHELVES, value=book.get("shelf", "read"))] def moved(values: dict | None): if values: self.run_now(lambda: books.move_book(book["goodreadsId"], values["shelf"]), f"Moved {book['title']} to {values['shelf']}", book["goodreadsId"]) self.app.push_screen(FormDialog("Move to shelf", fields, "Move"), moved) def action_delete(self): book = self.book_by_id(self.selected_key()) if not book: return def confirmed(should_delete: bool | None): if should_delete: self.run_now(lambda: books.remove_book(book["goodreadsId"]), f"Removed {book['title']}") self.app.push_screen(ConfirmDialog(f"Remove {book['title']}?"), confirmed) def action_refresh(self): book = self.book_by_id(self.selected_key()) if not book: return def refreshed(updated: dict): self.reload(updated["goodreadsId"]) self.notify(f"Refreshed {updated['title']}") self.run_in_background(f"Refreshing {book['title']}...", lambda: books.refresh_book(book["goodreadsId"]), refreshed) def action_refresh_all(self): self.notify("Refreshing every book from Goodreads...") self.refresh_every_book() @work(thread=True, exclusive=True, group="refresh") def refresh_every_book(self): failures = 0 all_books = books.load_books() for book in all_books: try: books.refresh_book(book["goodreadsId"]) except (OSError, ValueError) as error: failures += 1 self.app.call_from_thread(self.notify, f"{book['title']}: {error}", title="Skipped", severity="warning") 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)