554 lines
20 KiB
Python
554 lines
20 KiB
Python
"""
|
|
Library manager for browsing and selecting books.
|
|
|
|
Handles:
|
|
- Scanning directories for EPUB files
|
|
- Extracting and caching book metadata and covers
|
|
- Rendering interactive library view using pyWebLayout
|
|
- Processing tap/click events to select books
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import os
|
|
from pathlib import Path
|
|
from typing import List, Dict, Optional, Tuple
|
|
from PIL import Image, ImageDraw
|
|
import tempfile
|
|
import base64
|
|
from io import BytesIO
|
|
|
|
from pyWebLayout.concrete.page import Page
|
|
from pyWebLayout.style.page_style import PageStyle
|
|
from pyWebLayout.concrete.table import TableRenderer, TableStyle
|
|
from pyWebLayout.abstract.block import Table
|
|
from pyWebLayout.abstract.interactive_image import InteractiveImage
|
|
from pyWebLayout.abstract.inline import Word
|
|
from pyWebLayout.style.fonts import Font
|
|
from pyWebLayout.core.query import QueryResult
|
|
|
|
from .book_utils import scan_book_directory, extract_book_metadata
|
|
from .state import LibraryState
|
|
|
|
|
|
class LibraryManager:
|
|
"""
|
|
Manages the book library view and interactions.
|
|
|
|
Features:
|
|
- Scan EPUB directories
|
|
- Cache book metadata and covers
|
|
- Render interactive library table
|
|
- Handle tap events to select books
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
library_path: str,
|
|
cache_dir: Optional[str] = None,
|
|
page_size: Tuple[int, int] = (800, 1200),
|
|
books_per_page: int = 6
|
|
):
|
|
"""
|
|
Initialize library manager.
|
|
|
|
Args:
|
|
library_path: Path to directory containing EPUB files
|
|
cache_dir: Optional cache directory for covers. If None, uses default.
|
|
page_size: Page size for library view rendering
|
|
books_per_page: Number of books to display per page (must be even for 2-column layout, default: 6)
|
|
"""
|
|
self.library_path = Path(library_path)
|
|
self.page_size = page_size
|
|
self.books_per_page = books_per_page if books_per_page % 2 == 0 else books_per_page + 1
|
|
|
|
# Set cache directory
|
|
if cache_dir:
|
|
self.cache_dir = Path(cache_dir)
|
|
else:
|
|
self.cache_dir = self._get_default_cache_dir()
|
|
|
|
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
self.covers_dir = self.cache_dir / 'covers'
|
|
self.covers_dir.mkdir(exist_ok=True)
|
|
|
|
# Current library state
|
|
self.books: List[Dict] = []
|
|
self.library_table: Optional[Table] = None
|
|
self.rendered_page: Optional[Page] = None
|
|
self.temp_cover_files: List[str] = [] # Track temp files for cleanup
|
|
self.row_bounds: List[Tuple[int, int, int, int]] = [] # Bounding boxes for rows (x, y, w, h)
|
|
self.table_renderer: Optional[TableRenderer] = None # Store renderer for bounds info
|
|
self.current_page: int = 0 # Current page index for pagination
|
|
|
|
@staticmethod
|
|
def _get_default_cache_dir() -> Path:
|
|
"""Get default cache directory based on platform"""
|
|
if os.name == 'nt': # Windows
|
|
config_dir = Path(os.environ.get('APPDATA', '~/.config'))
|
|
else: # Linux/Mac
|
|
config_dir = Path.home() / '.config'
|
|
|
|
return config_dir / 'dreader'
|
|
|
|
def scan_library(self, force_refresh: bool = False) -> List[Dict]:
|
|
"""
|
|
Scan library directory for EPUB files and extract metadata.
|
|
|
|
Args:
|
|
force_refresh: If True, re-scan even if cache exists
|
|
|
|
Returns:
|
|
List of book dictionaries with metadata
|
|
"""
|
|
print(f"Scanning library: {self.library_path}")
|
|
|
|
if not self.library_path.exists():
|
|
print(f"Library path does not exist: {self.library_path}")
|
|
return []
|
|
|
|
# Scan directory
|
|
self.books = scan_book_directory(self.library_path)
|
|
|
|
# Cache covers to disk if not already cached
|
|
for book in self.books:
|
|
self._cache_book_cover(book)
|
|
|
|
print(f"Found {len(self.books)} books in library")
|
|
return self.books
|
|
|
|
def _cache_book_cover(self, book: Dict) -> Optional[str]:
|
|
"""
|
|
Cache book cover image to disk.
|
|
|
|
Args:
|
|
book: Book dictionary with cover_data (base64) or path
|
|
|
|
Returns:
|
|
Path to cached cover file, or None if no cover
|
|
"""
|
|
if not book.get('cover_data'):
|
|
return None
|
|
|
|
# Generate cache filename from book path
|
|
book_path = Path(book['path'])
|
|
cover_filename = f"{book_path.stem}_cover.png"
|
|
cover_path = self.covers_dir / cover_filename
|
|
|
|
# Skip if already cached
|
|
if cover_path.exists():
|
|
book['cover_path'] = str(cover_path)
|
|
return str(cover_path)
|
|
|
|
try:
|
|
# Decode base64 and save to cache
|
|
img_data = base64.b64decode(book['cover_data'])
|
|
img = Image.open(BytesIO(img_data))
|
|
img.save(cover_path, 'PNG')
|
|
|
|
book['cover_path'] = str(cover_path)
|
|
print(f"Cached cover: {cover_filename}")
|
|
return str(cover_path)
|
|
|
|
except Exception as e:
|
|
print(f"Error caching cover for {book['title']}: {e}")
|
|
return None
|
|
|
|
def create_library_table(self, books: Optional[List[Dict]] = None, page: Optional[int] = None) -> Table:
|
|
"""
|
|
Create interactive library table with book covers and info in 2-column grid.
|
|
|
|
Args:
|
|
books: List of books to display. If None, uses self.books
|
|
page: Page number to display (0-indexed). If None, uses self.current_page
|
|
|
|
Returns:
|
|
Table object ready for rendering
|
|
"""
|
|
if books is None:
|
|
books = self.books
|
|
|
|
if page is None:
|
|
page = self.current_page
|
|
|
|
if not books:
|
|
print("No books to display in library")
|
|
books = []
|
|
|
|
# Calculate pagination
|
|
total_pages = (len(books) + self.books_per_page - 1) // self.books_per_page
|
|
start_idx = page * self.books_per_page
|
|
end_idx = min(start_idx + self.books_per_page, len(books))
|
|
page_books = books[start_idx:end_idx]
|
|
|
|
print(f"Creating library table with {len(page_books)} books (page {page + 1}/{total_pages})...")
|
|
|
|
# Create table with caption showing page info
|
|
caption_text = f"My Library (Page {page + 1}/{total_pages})" if total_pages > 1 else "My Library"
|
|
table = Table(caption=caption_text, style=Font(font_size=18, weight="bold"))
|
|
|
|
# Add books in 2-column grid (each pair of books gets 2 rows: covers then details)
|
|
for i in range(0, len(page_books), 2):
|
|
# Row 1: Covers for this pair
|
|
cover_row = table.create_row("body")
|
|
|
|
# Add first book's cover (left column)
|
|
self._add_book_cover(cover_row, page_books[i])
|
|
|
|
# Add second book's cover (right column) if it exists
|
|
if i + 1 < len(page_books):
|
|
self._add_book_cover(cover_row, page_books[i + 1])
|
|
else:
|
|
# Add empty cell if odd number of books
|
|
cover_row.create_cell()
|
|
|
|
# Row 2: Details for this pair
|
|
details_row = table.create_row("body")
|
|
|
|
# Add first book's details (left column)
|
|
self._add_book_details(details_row, page_books[i])
|
|
|
|
# Add second book's details (right column) if it exists
|
|
if i + 1 < len(page_books):
|
|
self._add_book_details(details_row, page_books[i + 1])
|
|
else:
|
|
# Add empty cell if odd number of books
|
|
details_row.create_cell()
|
|
|
|
self.library_table = table
|
|
return table
|
|
|
|
def _add_book_cover(self, row, book: Dict):
|
|
"""
|
|
Add a book cover to a table row.
|
|
|
|
Args:
|
|
row: Table row to add cover to
|
|
book: Book dictionary with metadata
|
|
"""
|
|
cover_cell = row.create_cell()
|
|
|
|
cover_path = book.get('cover_path')
|
|
book_path = book['path']
|
|
|
|
# Create callback that returns book path
|
|
callback = lambda point, path=book_path: path
|
|
|
|
# Add cover image
|
|
if cover_path and Path(cover_path).exists():
|
|
# Use cached cover with callback
|
|
img = InteractiveImage.create_and_add_to(
|
|
cover_cell,
|
|
source=cover_path,
|
|
alt_text=book['title'],
|
|
callback=callback
|
|
)
|
|
elif book.get('cover_data'):
|
|
# Decode base64 and save to temp file for InteractiveImage
|
|
try:
|
|
img_data = base64.b64decode(book['cover_data'])
|
|
img = Image.open(BytesIO(img_data))
|
|
|
|
# Save to temp file
|
|
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
|
img.save(tmp.name, 'PNG')
|
|
temp_path = tmp.name
|
|
self.temp_cover_files.append(temp_path)
|
|
|
|
img = InteractiveImage.create_and_add_to(
|
|
cover_cell,
|
|
source=temp_path,
|
|
alt_text=book['title'],
|
|
callback=callback
|
|
)
|
|
except Exception as e:
|
|
print(f"Error creating cover image for {book['title']}: {e}")
|
|
self._add_no_cover_text(cover_cell)
|
|
else:
|
|
# No cover available
|
|
self._add_no_cover_text(cover_cell)
|
|
|
|
def _add_book_details(self, row, book: Dict):
|
|
"""
|
|
Add book details (title, author, filename) to a table row.
|
|
|
|
Args:
|
|
row: Table row to add details to
|
|
book: Book dictionary with metadata
|
|
"""
|
|
details_cell = row.create_cell()
|
|
|
|
# Title paragraph
|
|
title_para = details_cell.create_paragraph()
|
|
for word in book['title'].split():
|
|
title_para.add_word(Word(word, Font(font_size=14, weight="bold")))
|
|
|
|
# Author paragraph
|
|
author_para = details_cell.create_paragraph()
|
|
for word in book.get('author', 'Unknown').split():
|
|
author_para.add_word(Word(word, Font(font_size=12)))
|
|
|
|
# Filename paragraph (small, gray)
|
|
filename_para = details_cell.create_paragraph()
|
|
filename_para.add_word(Word(
|
|
Path(book['path']).name,
|
|
Font(font_size=10, colour=(150, 150, 150))
|
|
))
|
|
|
|
def _add_no_cover_text(self, cell):
|
|
"""Add placeholder text when no cover is available"""
|
|
para = cell.create_paragraph()
|
|
para.add_word(Word("[No", Font(font_size=10, colour=(128, 128, 128))))
|
|
para.add_word(Word("cover]", Font(font_size=10, colour=(128, 128, 128))))
|
|
|
|
def render_library(self, table: Optional[Table] = None) -> Image.Image:
|
|
"""
|
|
Render the library table to an image.
|
|
|
|
Args:
|
|
table: Table to render. If None, uses self.library_table
|
|
|
|
Returns:
|
|
PIL Image of the rendered library
|
|
"""
|
|
if table is None:
|
|
if self.library_table is None:
|
|
print("No table to render, creating one first...")
|
|
self.create_library_table()
|
|
table = self.library_table
|
|
|
|
print("Rendering library table...")
|
|
|
|
# Create page
|
|
page_style = PageStyle(
|
|
border_width=0,
|
|
padding=(30, 30, 30, 30),
|
|
background_color=(255, 255, 255)
|
|
)
|
|
|
|
page = Page(size=self.page_size, style=page_style)
|
|
canvas = page.render()
|
|
draw = ImageDraw.Draw(canvas)
|
|
|
|
# Table style
|
|
table_style = TableStyle(
|
|
border_width=1,
|
|
border_color=(200, 200, 200),
|
|
cell_padding=(10, 15, 10, 15),
|
|
header_bg_color=(240, 240, 240),
|
|
cell_bg_color=(255, 255, 255),
|
|
alternate_row_color=(250, 250, 250)
|
|
)
|
|
|
|
# Position table
|
|
table_origin = (page_style.padding[3], page_style.padding[0])
|
|
table_width = page.size[0] - page_style.padding[1] - page_style.padding[3]
|
|
|
|
# Render table with canvas support for images
|
|
self.table_renderer = TableRenderer(
|
|
table,
|
|
table_origin,
|
|
table_width,
|
|
draw,
|
|
table_style,
|
|
canvas # Pass canvas to enable image rendering
|
|
)
|
|
self.table_renderer.render()
|
|
|
|
# Store rendered page for query support
|
|
self.rendered_page = page
|
|
|
|
return canvas
|
|
|
|
def handle_library_tap(self, x: int, y: int) -> Optional[str]:
|
|
"""
|
|
Handle tap event on library view with 2-column grid.
|
|
|
|
The layout has alternating rows: cover rows and detail rows.
|
|
Each pair of rows (cover + detail) represents one pair of books (2 books).
|
|
Tapping on either the cover row or detail row selects the corresponding book.
|
|
|
|
Args:
|
|
x: X coordinate of tap
|
|
y: Y coordinate of tap
|
|
|
|
Returns:
|
|
Path to selected book, or None if no book tapped
|
|
"""
|
|
if not self.library_table or not self.table_renderer:
|
|
print("No library table available")
|
|
return None
|
|
|
|
try:
|
|
# Get paginated books for current page
|
|
start_idx = self.current_page * self.books_per_page
|
|
end_idx = min(start_idx + self.books_per_page, len(self.books))
|
|
page_books = self.books[start_idx:end_idx]
|
|
|
|
# Build a mapping of row sections in order
|
|
all_rows = list(self.library_table.all_rows())
|
|
|
|
# Find which row was tapped by checking row renderers
|
|
for row_idx, row_renderer in enumerate(self.table_renderer._row_renderers):
|
|
# Get the row renderer's bounds
|
|
row_x, row_y = row_renderer._origin
|
|
row_w, row_h = row_renderer._size
|
|
|
|
# Check if tap is within this row's bounds
|
|
if (row_x <= x <= row_x + row_w and
|
|
row_y <= y <= row_y + row_h):
|
|
|
|
# Get the section and row for this renderer index
|
|
if row_idx < len(all_rows):
|
|
section, row = all_rows[row_idx]
|
|
|
|
# Only handle body rows
|
|
if section == "body":
|
|
# Find which body row this is (0-indexed)
|
|
body_row_index = sum(1 for s, _ in all_rows[:row_idx] if s == "body")
|
|
|
|
# Each pair of books uses 2 rows (cover row + detail row)
|
|
# Determine which book pair this row belongs to
|
|
book_pair_index = body_row_index // 2 # Which pair of books (0, 1, 2, ...)
|
|
is_cover_row = body_row_index % 2 == 0 # Even rows are covers, odd are details
|
|
|
|
# Check cell renderers in this row
|
|
if hasattr(row_renderer, '_cell_renderers') and len(row_renderer._cell_renderers) >= 1:
|
|
# Check left cell (first book in pair)
|
|
left_cell = row_renderer._cell_renderers[0]
|
|
left_x, left_y = left_cell._origin
|
|
left_w, left_h = left_cell._size
|
|
|
|
if (left_x <= x <= left_x + left_w and
|
|
left_y <= y <= left_y + left_h):
|
|
# Left column (first book in pair)
|
|
book_index = book_pair_index * 2
|
|
if book_index < len(page_books):
|
|
book_path = page_books[book_index]['path']
|
|
row_type = "cover" if is_cover_row else "detail"
|
|
print(f"Book selected (pair {book_pair_index}, left {row_type}): {book_path}")
|
|
return book_path
|
|
|
|
# Check right cell (second book in pair) if it exists
|
|
if len(row_renderer._cell_renderers) >= 2:
|
|
right_cell = row_renderer._cell_renderers[1]
|
|
right_x, right_y = right_cell._origin
|
|
right_w, right_h = right_cell._size
|
|
|
|
if (right_x <= x <= right_x + right_w and
|
|
right_y <= y <= right_y + right_h):
|
|
# Right column (second book in pair)
|
|
book_index = book_pair_index * 2 + 1
|
|
if book_index < len(page_books):
|
|
book_path = page_books[book_index]['path']
|
|
row_type = "cover" if is_cover_row else "detail"
|
|
print(f"Book selected (pair {book_pair_index}, right {row_type}): {book_path}")
|
|
return book_path
|
|
|
|
print(f"No book tapped at ({x}, {y})")
|
|
return None
|
|
|
|
except Exception as e:
|
|
print(f"Error handling library tap: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return None
|
|
|
|
def get_book_at_index(self, index: int) -> Optional[Dict]:
|
|
"""
|
|
Get book by index in library.
|
|
|
|
Args:
|
|
index: Book index
|
|
|
|
Returns:
|
|
Book dictionary or None
|
|
"""
|
|
if 0 <= index < len(self.books):
|
|
return self.books[index]
|
|
return None
|
|
|
|
def next_page(self) -> bool:
|
|
"""
|
|
Navigate to next page of library.
|
|
|
|
Returns:
|
|
True if page changed, False if already on last page
|
|
"""
|
|
total_pages = (len(self.books) + self.books_per_page - 1) // self.books_per_page
|
|
if self.current_page < total_pages - 1:
|
|
self.current_page += 1
|
|
return True
|
|
return False
|
|
|
|
def previous_page(self) -> bool:
|
|
"""
|
|
Navigate to previous page of library.
|
|
|
|
Returns:
|
|
True if page changed, False if already on first page
|
|
"""
|
|
if self.current_page > 0:
|
|
self.current_page -= 1
|
|
return True
|
|
return False
|
|
|
|
def set_page(self, page: int) -> bool:
|
|
"""
|
|
Set current page.
|
|
|
|
Args:
|
|
page: Page number (0-indexed)
|
|
|
|
Returns:
|
|
True if page changed, False if invalid page
|
|
"""
|
|
total_pages = (len(self.books) + self.books_per_page - 1) // self.books_per_page
|
|
if 0 <= page < total_pages:
|
|
self.current_page = page
|
|
return True
|
|
return False
|
|
|
|
def get_total_pages(self) -> int:
|
|
"""
|
|
Get total number of pages.
|
|
|
|
Returns:
|
|
Total number of pages
|
|
"""
|
|
return (len(self.books) + self.books_per_page - 1) // self.books_per_page
|
|
|
|
def get_library_state(self) -> LibraryState:
|
|
"""
|
|
Get current library state for persistence.
|
|
|
|
Returns:
|
|
LibraryState object
|
|
"""
|
|
return LibraryState(
|
|
books_path=str(self.library_path),
|
|
last_selected_index=0, # TODO: Track last selection
|
|
scan_cache=[
|
|
{
|
|
'path': book['path'],
|
|
'title': book['title'],
|
|
'author': book.get('author', 'Unknown'),
|
|
'cover_cached': bool(book.get('cover_path'))
|
|
}
|
|
for book in self.books
|
|
]
|
|
)
|
|
|
|
def cleanup(self):
|
|
"""Clean up temporary files"""
|
|
for temp_file in self.temp_cover_files:
|
|
try:
|
|
if os.path.exists(temp_file):
|
|
os.unlink(temp_file)
|
|
except Exception as e:
|
|
print(f"Error cleaning up temp file {temp_file}: {e}")
|
|
self.temp_cover_files.clear()
|
|
|
|
def __del__(self):
|
|
"""Destructor to ensure cleanup"""
|
|
self.cleanup()
|