import datetime import book_cache import goodreads import jsonfile BOOKS_FILE = "books.json" SHELVES = ["read", "reading", "want-to-read"] DESCRIPTION_MAX_LENGTH = 600 GENRE_LIMIT = 5 def check_rating(rating: int) -> int: if not 0 <= rating <= 10: raise ValueError("Rating must be between 0 and 10") return rating def check_shelf(shelf: str) -> str: if shelf not in SHELVES: raise ValueError(f"Shelf must be one of {', '.join(SHELVES)}") return shelf def load_books() -> list[dict]: if not jsonfile.content_path(BOOKS_FILE).exists(): return [] return jsonfile.load(BOOKS_FILE) def save_books(books: list[dict]) -> None: jsonfile.save(BOOKS_FILE, books) def find_index(books: list[dict], text: str) -> int | None: identifier = goodreads.book_id(text) or (text if text.isdigit() else None) for index, book in enumerate(books): if identifier and book.get("goodreadsId") == identifier: return index if book.get("title", "").lower() == text.lower(): return index return None def short_description(text: str | None) -> str | None: if not text or len(text) <= DESCRIPTION_MAX_LENGTH: return text return text[:DESCRIPTION_MAX_LENGTH].rsplit(" ", 1)[0] + "..." def local_cover(scraped: dict) -> str | None: try: return book_cache.cache_cover(scraped["goodreadsId"], scraped["cover"]) or scraped["cover"] except OSError: return scraped["cover"] def stored_fields(scraped: dict) -> dict: return { "goodreadsId": scraped["goodreadsId"], "url": scraped["url"], "title": scraped["title"], "authors": scraped["authors"], "cover": local_cover(scraped), "coverSource": scraped["cover"], "description": short_description(scraped["description"]), "pages": scraped["pages"], "published": scraped["published"], "genres": scraped["genres"][:GENRE_LIMIT], "averageRating": scraped["averageRating"], "ratingCount": scraped["ratingCount"], } def update_book(text: str, change) -> dict: books = load_books() index = find_index(books, text) if index is None: raise ValueError(f"{text} is not listed") books[index] = change(books[index]) save_books(books) 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: check_rating(rating) identifier = goodreads.book_id(url) if not identifier: raise ValueError(f"{url} is not a Goodreads book link") if find_index(load_books(), identifier) is not None: raise ValueError(f"{identifier} is already listed") scraped = book_cache.scrape_book(url) if not scraped["title"]: raise ValueError("Could not read the book from Goodreads") book = stored_fields(scraped) book["shelf"] = shelf if rating is not None: book["rating"] = rating book["added"] = datetime.date.today().isoformat() books = load_books() books.append(book) save_books(books) return book def remove_book(text: str) -> dict: books = load_books() index = find_index(books, text) if index is None: raise ValueError(f"{text} is not listed") removed = books.pop(index) save_books(books) book_cache.remove_cover(removed["goodreadsId"]) return removed def rate_book(text: str, rating: int | None) -> dict: if rating is not None: check_rating(rating) def change(book: dict) -> dict: updated = {key: value for key, value in book.items() if key != "rating"} if rating is not None: updated["rating"] = rating return updated return update_book(text, change) def move_book(text: str, shelf: str) -> dict: check_shelf(shelf) return update_book(text, lambda book: {**book, "shelf": shelf}) def refresh_book(text: str) -> dict: books = load_books() index = find_index(books, text) if index is None: raise ValueError(f"{text} is not listed") scraped = book_cache.scrape_book(books[index]["url"], fresh=True) return update_book(text, lambda book: {**book, **stored_fields(scraped)}) def books_missing_local_covers() -> list[dict]: return [book for book in load_books() if not book_cache.is_local_cover(book.get("cover"))] def cache_book_cover(identifier: str) -> dict: def change(book: dict) -> dict: source = book.get("coverSource") or book.get("cover") cover = book_cache.cache_cover(identifier, source) return {**book, "cover": cover or book.get("cover"), "coverSource": source} return update_book(identifier, change)