Duncan Tourolle 284a6e3393
All checks were successful
Python CI / test (push) Successful in 4m30s
library and toc navigation
2025-11-08 12:20:23 +01:00

411 lines
14 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)
):
"""
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
"""
self.library_path = Path(library_path)
self.page_size = page_size
# 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
@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) -> Table:
"""
Create interactive library table with book covers and info.
Args:
books: List of books to display. If None, uses self.books
Returns:
Table object ready for rendering
"""
if books is None:
books = self.books
if not books:
print("No books to display in library")
books = []
print(f"Creating library table with {len(books)} books...")
# Create table
table = Table(caption="My Library", style=Font(font_size=18, weight="bold"))
# Add books as rows
for i, book in enumerate(books):
row = table.create_row("body")
# Cover cell with interactive image
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
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)
# Book info cell
info_cell = row.create_cell()
# Title paragraph
title_para = info_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 = info_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 = info_cell.create_paragraph()
filename_para.add_word(Word(
Path(book['path']).name,
Font(font_size=10, colour=(150, 150, 150))
))
self.library_table = table
return table
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.
Checks if the tap is within any row's bounds and returns the corresponding
book path. This makes the entire row interactive, not just the cover image.
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:
# 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")
# Return the corresponding book
if body_row_index < len(self.books):
book_path = self.books[body_row_index]['path']
print(f"Book selected (row {body_row_index}): {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 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()