411 lines
14 KiB
Python
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()
|