Compare commits

...
2 Commits
Author SHA1 Message Date
SirBlob bad7b7d3b3 Add Animal Farm and 1984 2026-11-01 20:57:37 -05:00
SirBlob 2318cdeaac Add Goodreads series and group them on the about page 2026-11-01 20:57:37 -05:00
12 changed files with 652 additions and 12 deletions

No files matched your search

+2 -1
View File
@@ -1,3 +1,4 @@
[
{ "goodreadsId": "170448", "url": "https://www.goodreads.com/book/show/170448", "title": "Animal Farm", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1325861570i/170448.jpg", "description": "Animal Farm is the most famous by far of all twentieth-century political allegories. Its account of a group of barnyard animals who revolt against their vicious human master, only to submit to a tyranny erected by their own kind, can fairly be said to have become a universal drama. Orwell is one of the very few modern satirists comparable to Jonathan Swift in power, artistry, and moral authority; in animal farm his spare prose and the logic of his dark comedy brilliantly highlight his stark message.\n \nTaking as his starting point the betrayed promise of the Russian Revolution, Orwell lays out...", "pages": 141, "published": "1996-04-01", "genres": ["Classics", "Fiction", "Dystopia", "Fantasy", "School"], "averageRating": 4.03, "ratingCount": 4853138, "shelf": "read", "rating": 10, "added": "2026-11-01" }
{ "goodreadsId": "170448", "url": "https://www.goodreads.com/book/show/170448", "title": "Animal Farm", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1325861570i/170448.jpg", "description": "Animal Farm is the most famous by far of all twentieth-century political allegories. Its account of a group of barnyard animals who revolt against their vicious human master, only to submit to a tyranny erected by their own kind, can fairly be said to have become a universal drama. Orwell is one of the very few modern satirists comparable to Jonathan Swift in power, artistry, and moral authority; in animal farm his spare prose and the logic of his dark comedy brilliantly highlight his stark message.\n \nTaking as his starting point the betrayed promise of the Russian Revolution, Orwell lays out...", "pages": 141, "published": "1996-04-01", "genres": ["Classics", "Fiction", "Dystopia", "Fantasy", "School"], "averageRating": 4.03, "ratingCount": 4853138, "shelf": "read", "rating": 10, "added": "2026-11-01" },
{ "goodreadsId": "61439040", "url": "https://www.goodreads.com/book/show/61439040", "title": "1984", "authors": ["George Orwell"], "cover": "https://m.media-amazon.com/images/S/compressed.photo.goodreads.com/books/1657781256i/61439040.jpg", "description": "A masterpiece of rebellion and imprisonment where war is peace freedom is slavery and Big Brother is watching. Thought Police, Big Brother, Orwellian - these words have entered our vocabulary because of George Orwell's classic dystopian novel 1984. The story of one man's Nightmare Odyssey as he pursues a forbidden love affair through a world ruled by warring states and a power structure that controls not only information but also individual thought and memory 1984 is a prophetic haunting tale More relevant than ever before 1984 exposes the worst crimes imaginable the destruction of truth...", "pages": 368, "published": "2022-07-01", "genres": ["Classics", "Fiction", "Science Fiction", "Dystopia", "Literature"], "averageRating": 4.21, "ratingCount": 5815805, "shelf": "read", "rating": 10, "added": "2026-11-01" }
]
+60
View File
@@ -0,0 +1,60 @@
import type { Book, BookShelf } from '#lib/content';
export interface BookSeriesGroup {
name: string;
url: string;
books: Book[];
counts: Record<BookShelf, number>;
}
export interface GroupedBooks {
series: BookSeriesGroup[];
standalone: Book[];
}
const positionGroups = { single: 0, range: 1, none: 2 };
function seriesOrder(first: Book, second: Book): number {
const [firstGroup, firstNumber] = positionKey(first);
const [secondGroup, secondNumber] = positionKey(second);
return firstGroup - secondGroup || firstNumber - secondNumber;
}
function positionKey(book: Book): [number, number] {
const position = book.series?.position ?? '';
const match = position.match(/\d+(?:\.\d+)?/);
if (!match) return [positionGroups.none, 0];
const isRange = /\d\s*[-–]\s*\d/.test(position);
return [isRange ? positionGroups.range : positionGroups.single, Number(match[0])];
}
function shelfCounts(books: Book[]): Record<BookShelf, number> {
return {
read: books.filter((book) => book.shelf === 'read').length,
reading: books.filter((book) => book.shelf === 'reading').length,
'want-to-read': books.filter((book) => book.shelf === 'want-to-read').length
};
}
export function groupBySeries(books: Book[]): GroupedBooks {
const groups = new Map<string, Book[]>();
const standalone: Book[] = [];
for (const book of books) {
if (!book.series) {
standalone.push(book);
continue;
}
const key = book.series.url || book.series.name;
groups.set(key, [...(groups.get(key) ?? []), book]);
}
const series = [...groups.values()]
.map((seriesBooks) => {
const ordered = [...seriesBooks].sort(seriesOrder);
return { name: ordered[0].series!.name, url: ordered[0].series!.url, books: ordered, counts: shelfCounts(ordered) };
})
.sort((first, second) => second.counts.read - first.counts.read || first.name.localeCompare(second.name));
return { series, standalone };
}
+42 -2
View File
@@ -2,7 +2,9 @@
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
let { book }: { book: Book } = $props();
let { book, showShelf = false }: { book: Book; showShelf?: boolean } = $props();
const shelfLabels = { read: 'read', reading: 'reading', 'want-to-read': 'want to read' };
let coverFailed = $state(false);
@@ -26,14 +28,22 @@
<span class="rating community" title="Goodreads average"><Icon icon="mdi:star-outline" width="13" />{book.averageRating}</span>
{/if}
{#if showShelf}
<span class="shelf {book.shelf}">{shelfLabels[book.shelf]}</span>
{/if}
{#if book.description}
<p class="description">{book.description}</p>
{/if}
</div>
<span class="title">{book.title}</span>
{#if book.series?.position}
<span class="meta position">{book.series.position}</span>
{:else}
<span class="meta">{book.authors.join(', ')}</span>
{#if book.genres.length}
{/if}
{#if book.genres.length && !showShelf}
<span class="meta">{book.genres.slice(0, 3).join(' · ')}</span>
{/if}
</a>
@@ -104,6 +114,36 @@
color: var(--muted);
}
.shelf {
position: absolute;
bottom: 0.35rem;
left: 0.35rem;
padding: 0 0.35rem;
color: var(--background);
font-size: 0.7rem;
font-weight: 700;
}
.shelf.read {
background: var(--green);
}
.shelf.reading {
background: var(--blue);
}
.shelf.want-to-read {
background: var(--magenta);
}
.book-card:hover .shelf {
opacity: 0;
}
.position {
color: var(--foreground);
}
.description {
position: absolute;
right: 0;
@@ -0,0 +1,152 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { BookSeriesGroup } from '#lib/books/series';
import BookCard from './BookCard.svelte';
let { series }: { series: BookSeriesGroup } = $props();
const collapsedCount = 8;
let showAll = $state(false);
const shownBooks = $derived(showAll ? series.books : series.books.slice(0, collapsedCount));
const total = $derived(series.books.length);
const shelves = $derived([
{ id: 'read', label: 'read', count: series.counts.read },
{ id: 'reading', label: 'reading', count: series.counts.reading },
{ id: 'want-to-read', label: 'want to read', count: series.counts['want-to-read'] }
]);
</script>
<section class="series">
<header class="series-header">
<a class="series-name" href={series.url} target="_blank" rel="noopener noreferrer">
<Icon icon="mdi:bookshelf" width="16" />
{series.name}
</a>
<span class="series-counts">
{#each shelves.filter((shelf) => shelf.count > 0) as shelf (shelf.id)}
<span class="count {shelf.id}">{shelf.count} {shelf.label}</span>
{/each}
</span>
</header>
<div class="progress" role="img" aria-label="{series.counts.read} of {total} read">
{#each shelves as shelf (shelf.id)}
{#if shelf.count}
<span class="segment {shelf.id}" style:flex-grow={shelf.count}></span>
{/if}
{/each}
</div>
<div class="book-grid">
{#each shownBooks as book (book.goodreadsId)}
<BookCard {book} showShelf />
{/each}
</div>
{#if total > collapsedCount}
<button class="toggle" onclick={() => (showAll = !showAll)}>
<Icon icon={showAll ? 'mdi:chevron-up' : 'mdi:chevron-down'} width="16" />
{showAll ? 'Show less' : `Show all ${total}`}
</button>
{/if}
</section>
<style>
.series {
padding: 0.75rem;
border: 1px solid color-mix(in srgb, var(--foreground) 15%, transparent);
}
.series-header {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.4rem 1rem;
}
.series-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--accent);
font-weight: 700;
}
.series-name:hover {
text-decoration: underline;
}
.series-counts {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.78rem;
}
.count.read {
color: var(--green);
}
.count.reading {
color: var(--blue);
}
.count.want-to-read {
color: var(--magenta);
}
.progress {
display: flex;
gap: 2px;
height: 4px;
margin: 0.5rem 0 0.85rem;
}
.segment {
flex-basis: 0;
}
.segment.read {
background: var(--green);
}
.segment.reading {
background: var(--blue);
}
.segment.want-to-read {
background: var(--magenta);
}
.book-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.9rem 0.75rem;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
margin-top: 0.75rem;
padding: 0.3rem 0.7rem;
background: none;
border: 1px solid color-mix(in srgb, var(--foreground) 30%, transparent);
color: var(--foreground);
font-size: 0.85rem;
}
.toggle:hover {
border-color: var(--accent);
color: var(--accent);
}
@container (max-width: 520px) {
.book-grid {
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
}
}
</style>
@@ -1,8 +1,10 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Book } from '#lib/content';
import { groupBySeries } from '#lib/books/series';
import Panel from '#lib/components/ui/Panel.svelte';
import BookShelfGrid from './BookShelfGrid.svelte';
import BookSeriesGroup from './BookSeriesGroup.svelte';
let { books }: { books: Book[] } = $props();
@@ -20,6 +22,9 @@
{ label: 'Want to read', count: wantToRead.length, color: shelfColors['want-to-read'] }
]);
const shownCounts = $derived(shelfCounts.filter((shelf) => shelf.count > 0));
const grouped = $derived(groupBySeries(books));
const standaloneRead = $derived(read.filter((book) => !book.series));
const standaloneWantToRead = $derived(wantToRead.filter((book) => !book.series));
const pagesRead = $derived(read.reduce((total, book) => total + (book.pages ?? 0), 0));
const authorCount = $derived(new Set(read.flatMap((book) => book.authors)).size);
const topGenres = $derived.by(() => {
@@ -78,15 +83,39 @@
{/if}
</div>
{#if read.length}
<BookShelfGrid title="Read" icon="mdi:check-circle-outline" books={read} />
{#if grouped.series.length}
<h3 class="subheading"><Icon icon="mdi:bookshelf" width="16" /> Series <span class="muted">{grouped.series.length}</span></h3>
<div class="series-list">
{#each grouped.series as series (series.url || series.name)}
<BookSeriesGroup {series} />
{/each}
</div>
{/if}
{#if wantToRead.length}
<BookShelfGrid title="Want to read" icon="mdi:bookmark-outline" books={wantToRead} />
{#if standaloneRead.length}
<BookShelfGrid title="Read" icon="mdi:check-circle-outline" books={standaloneRead} />
{/if}
{#if standaloneWantToRead.length}
<BookShelfGrid title="Want to read" icon="mdi:bookmark-outline" books={standaloneWantToRead} />
{/if}
<style>
.subheading {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 1.25rem 0 0.6rem;
color: var(--foreground);
font-size: 0.9rem;
}
.series-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.overview {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+7
View File
@@ -156,6 +156,13 @@ export interface Book {
shelf: BookShelf;
rating?: number;
added?: string;
series?: BookSeries;
}
export interface BookSeries {
name: string;
url: string;
position?: string;
}
export interface LabeledValue {
+24
View File
@@ -73,6 +73,30 @@ def update_book(text: str, change) -> dict:
return books[index]
def series_tag(series: dict, entry: dict) -> dict:
tag = {"name": series["name"], "url": series["url"]}
if entry.get("position"):
tag["position"] = entry["position"]
return tag
def add_series_book(series: dict, entry: dict, shelf: str) -> dict:
check_shelf(shelf)
tag = series_tag(series, entry)
books = load_books()
index = find_index(books, entry["goodreadsId"])
if index is not None:
books[index] = {**books[index], "shelf": shelf, "series": tag}
save_books(books)
return books[index]
book = add_book(entry["url"], shelf)
return update_book(book["goodreadsId"], lambda stored: {**stored, "series": tag})
def library_shelves() -> dict[str, str]:
return {book["goodreadsId"]: book.get("shelf", "read") for book in load_books()}
def add_book(url: str, shelf: str = "read", rating: int | None = None) -> dict:
check_shelf(shelf)
if rating is not None:
+12 -5
View File
@@ -214,8 +214,10 @@ def print_summary(book: dict):
def main() -> int:
parser = argparse.ArgumentParser(description="Scrape book info from a Goodreads book page")
parser.add_argument("urls", nargs="+", help="Goodreads book urls, for example https://www.goodreads.com/book/show/170448.Animal_Farm")
import goodreads_series
parser = argparse.ArgumentParser(description="Scrape book or series info from Goodreads")
parser.add_argument("urls", nargs="+", help="Goodreads book or series urls, for example https://www.goodreads.com/book/show/170448.Animal_Farm")
parser.add_argument("--json", action="store_true", help="print the raw result as JSON")
parser.add_argument("--debug", action="store_true", help="show what the downloaded page contains instead of parsing it")
parser.add_argument("--save-html", type=Path, help="with --debug, save the downloaded page to this file")
@@ -223,19 +225,24 @@ def main() -> int:
exit_code = 0
for url in arguments.urls:
is_series = goodreads_series.series_id(url) is not None
try:
if arguments.debug:
print_diagnostics(url, arguments.save_html)
print(f"React data {len(goodreads_series.REACT_PROPS_PATTERN.findall(download_page(url).text))} blocks")
continue
book = scrape_book(url)
result = goodreads_series.scrape_series(url) if is_series else scrape_book(url)
except (OSError, ValueError) as error:
print(f"Error for {url}: {error}", file=sys.stderr)
exit_code = 1
continue
if arguments.json:
print(json.dumps(book, indent="\t", ensure_ascii=False))
print(json.dumps(result, indent="\t", ensure_ascii=False))
elif is_series:
goodreads_series.print_series(result)
print()
else:
print_summary(book)
print_summary(result)
print()
return exit_code
+169
View File
@@ -0,0 +1,169 @@
import html
import json
import re
import goodreads
SERIES_URL_PATTERN = re.compile(r"goodreads\.com/series/(\d+)")
REACT_PROPS_PATTERN = re.compile(r"data-react-props=\"([^\"]*)\"", re.S)
BOOK_LINK_PATTERN = re.compile(r"<a[^>]+href=\"(?:https://www\.goodreads\.com)?/book/show/(\d+)[^\"]*\"[^>]*>\s*(?:<span[^>]*>)?\s*([^<]+?)\s*<", re.S)
SERIES_TITLE_SUFFIX = re.compile(r"\s+(?:Series\s+)?by\s+.*$", re.I)
def series_id(url: str) -> str | None:
match = SERIES_URL_PATTERN.search(url)
return match.group(1) if match else None
def first_text(data: dict, keys: list[str]) -> str:
for key in keys:
value = data.get(key)
if isinstance(value, str) and value.strip():
return html.unescape(value.strip())
return ""
def author_names(book: dict) -> list[str]:
author = book.get("author")
if isinstance(author, dict) and author.get("name"):
return [html.unescape(author["name"])]
authors = book.get("authors")
if isinstance(authors, list):
return [html.unescape(item["name"]) for item in authors if isinstance(item, dict) and item.get("name")]
name = first_text(book, ["authorName"])
return [name] if name else []
def entry_from_book(book: dict, position: str) -> dict | None:
identifier = str(book.get("bookId") or book.get("legacyId") or book.get("id") or "")
link = first_text(book, ["bookUrl", "url", "webUrl"])
if not identifier.isdigit():
identifier = goodreads.book_id(link) or ""
if not identifier:
return None
return {
"goodreadsId": identifier,
"title": first_text(book, ["bookTitleBare", "title", "titleComplete"]),
"position": position,
"authors": author_names(book),
"cover": first_text(book, ["imageUrl", "image"]) or None,
"url": f"https://www.goodreads.com/book/show/{identifier}",
}
def clean_header(value) -> str:
return html.unescape(value).strip() if isinstance(value, str) else ""
def own_header(item: dict) -> str:
return next((clean_header(value) for key, value in item.items() if "header" in key.lower() and isinstance(value, str)), "")
def is_book_list(value) -> bool:
return isinstance(value, list) and bool(value) and all(isinstance(item, dict) and isinstance(item.get("book"), dict) for item in value)
def collect_entries(node, found: list[dict]):
if isinstance(node, list):
for item in node:
collect_entries(item, found)
return
if not isinstance(node, dict):
return
if isinstance(node.get("book"), dict):
entry = entry_from_book(node["book"], own_header(node))
if entry:
found.append(entry)
return
header_lists = [value for key, value in node.items() if "header" in key.lower() and isinstance(value, list)]
for value in node.values():
if not is_book_list(value):
collect_entries(value, found)
continue
headers = next((headers for headers in header_lists if len(headers) == len(value)), [])
for index, item in enumerate(value):
position = clean_header(headers[index]) if index < len(headers) else ""
entry = entry_from_book(item["book"], position or own_header(item))
if entry:
found.append(entry)
def entries_from_react_props(page: str) -> list[dict]:
found: list[dict] = []
for raw in REACT_PROPS_PATTERN.findall(page):
try:
collect_entries(json.loads(html.unescape(raw)), found)
except ValueError:
continue
return found
def entries_from_next_data(page: str) -> list[dict]:
state = goodreads.apollo_state(page)
books = [value for value in state.values() if isinstance(value, dict) and value.get("__typename") == "Book"]
entries = []
for book in books:
entry = entry_from_book(book, "")
if entry:
entry["authors"] = goodreads.book_authors(state, book) or entry["authors"]
entries.append(entry)
return entries
def entries_from_links(page: str) -> list[dict]:
entries = []
for identifier, title in BOOK_LINK_PATTERN.findall(page):
title = html.unescape(title).strip()
if title:
entries.append({"goodreadsId": identifier, "title": title, "position": "", "authors": [], "cover": None, "url": f"https://www.goodreads.com/book/show/{identifier}"})
return entries
def unique_entries(entries: list[dict]) -> list[dict]:
seen = set()
unique = []
for entry in entries:
if entry["goodreadsId"] in seen:
continue
seen.add(entry["goodreadsId"])
unique.append(entry)
return unique
def series_title(page: str) -> str:
meta = goodreads.meta_tags(page)
title = meta.get("og:title") or goodreads.page_title(page)
return SERIES_TITLE_SUFFIX.sub("", title).strip()
def scrape_series(url: str) -> dict:
identifier = series_id(url)
if not identifier:
raise ValueError(f"not a Goodreads series url: {url}")
page = goodreads.download_page(url)
reason = goodreads.blocked_reason(page.text)
if reason:
raise ValueError(reason)
entries = entries_from_react_props(page.text) or entries_from_next_data(page.text) or entries_from_links(page.text)
entries = unique_entries(entries)
if not entries:
raise ValueError("Could not find any books on the series page")
return {
"seriesId": identifier,
"name": series_title(page.text),
"url": f"https://www.goodreads.com/series/{identifier}",
"books": entries,
}
def print_series(series: dict):
print(f"{series['name']} ({len(series['books'])} books)")
print("-" * 60)
for entry in series["books"]:
position = entry["position"] or "-"
authors = ", ".join(entry["authors"]) or "unknown author"
print(f"{position:<12} {entry['title']} by {authors} [{entry['goodreadsId']}]")
+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)