library and toc navigation
Python CI / test (push) Successful in 4m30s

This commit is contained in:
2025-11-08 12:20:23 +01:00
parent 60426432a0
commit 284a6e3393
13 changed files with 2365 additions and 219 deletions
+47 -1
View File
@@ -8,6 +8,52 @@ with all essential features for building ereader applications.
from dreader.application import EbookReader, create_ebook_reader
from dreader import html_generator
from dreader import book_utils
from dreader.gesture import (
TouchEvent,
GestureType,
GestureResponse,
ActionType
)
from dreader.state import (
StateManager,
AppState,
BookState,
LibraryState,
Settings,
EreaderMode,
OverlayState
)
from dreader.library import LibraryManager
from dreader.overlay import OverlayManager
__version__ = "0.1.0"
__all__ = ["EbookReader", "create_ebook_reader", "html_generator", "book_utils"]
__all__ = [
# Core reader
"EbookReader",
"create_ebook_reader",
# Utilities
"html_generator",
"book_utils",
# Gesture system
"TouchEvent",
"GestureType",
"GestureResponse",
"ActionType",
# State management
"StateManager",
"AppState",
"BookState",
"LibraryState",
"Settings",
"EreaderMode",
"OverlayState",
# Library
"LibraryManager",
# Overlay
"OverlayManager",
]
+245 -3
View File
@@ -42,7 +42,7 @@ import os
from PIL import Image
from pyWebLayout.io.readers.epub_reader import read_epub
from pyWebLayout.io.gesture import TouchEvent, GestureType, GestureResponse, ActionType
from pyWebLayout.io.readers.html_extraction import parse_html_string
from pyWebLayout.abstract.block import Block, HeadingLevel
from pyWebLayout.layout.ereader_manager import EreaderLayoutManager
from pyWebLayout.layout.ereader_layout import RenderingPosition
@@ -51,6 +51,10 @@ from pyWebLayout.concrete.page import Page
from pyWebLayout.core.query import QueryResult, SelectionRange
from pyWebLayout.core.highlight import Highlight, HighlightManager, HighlightColor, create_highlight_from_query_result
from .gesture import TouchEvent, GestureType, GestureResponse, ActionType
from .state import OverlayState
from .overlay import OverlayManager
class EbookReader:
"""
@@ -121,6 +125,10 @@ class EbookReader:
self._selection_start: Optional[Tuple[int, int]] = None
self._selection_end: Optional[Tuple[int, int]] = None
self._selected_range: Optional[SelectionRange] = None
# Overlay management
self.overlay_manager = OverlayManager(page_size=page_size)
self.current_overlay_state = OverlayState.NONE
def load_epub(self, epub_path: str) -> bool:
"""
@@ -178,10 +186,61 @@ class EbookReader:
print(f"Error loading EPUB: {e}")
return False
def load_html(self, html_string: str, title: str = "HTML Document", author: str = "Unknown", document_id: str = "html_doc") -> bool:
"""
Load HTML content directly into the reader.
This is useful for rendering library screens, menus, or other HTML-based UI elements
using the same rendering engine as the ebook reader.
Args:
html_string: HTML content to render
title: Document title (for metadata)
author: Document author (for metadata)
document_id: Unique identifier for this HTML document
Returns:
True if loaded successfully, False otherwise
"""
try:
# Parse HTML into blocks
blocks = parse_html_string(html_string)
if not blocks:
raise ValueError("No content blocks parsed from HTML")
# Set metadata
self.book_title = title
self.book_author = author
self.document_id = document_id
self.blocks = blocks
# Initialize the ereader manager
self.manager = EreaderLayoutManager(
blocks=self.blocks,
page_size=self.page_size,
document_id=self.document_id,
buffer_size=self.buffer_size,
page_style=self.page_style,
bookmarks_dir=self.bookmarks_dir
)
# Initialize highlight manager for this document
self.highlight_manager = HighlightManager(
document_id=self.document_id,
highlights_dir=self.highlights_dir
)
return True
except Exception as e:
print(f"Error loading HTML: {e}")
return False
def is_loaded(self) -> bool:
"""Check if a book is currently loaded."""
return self.manager is not None
def get_current_page(self, include_highlights: bool = True) -> Optional[Image.Image]:
"""
Get the current page as a PIL Image.
@@ -646,7 +705,15 @@ class EbookReader:
if not self.is_loaded():
return GestureResponse(ActionType.ERROR, {"message": "No book loaded"})
# Dispatch based on gesture type
# Handle overlay-specific gestures first
if self.is_overlay_open():
if event.gesture == GestureType.TAP:
return self._handle_overlay_tap(event.x, event.y)
elif event.gesture == GestureType.SWIPE_DOWN:
# Swipe down closes overlay
return self._handle_overlay_close()
# Dispatch based on gesture type for normal reading mode
if event.gesture == GestureType.TAP:
return self._handle_tap(event.x, event.y)
elif event.gesture == GestureType.LONG_PRESS:
@@ -655,6 +722,9 @@ class EbookReader:
return self._handle_page_forward()
elif event.gesture == GestureType.SWIPE_RIGHT:
return self._handle_page_back()
elif event.gesture == GestureType.SWIPE_UP:
# Swipe up from bottom opens TOC overlay
return self._handle_swipe_up(event.y)
elif event.gesture == GestureType.PINCH_IN:
return self._handle_zoom_out()
elif event.gesture == GestureType.PINCH_OUT:
@@ -829,6 +899,77 @@ class EbookReader:
"bounds": self._selected_range.bounds_list
})
def _handle_swipe_up(self, y: int) -> GestureResponse:
"""Handle swipe up gesture - opens TOC overlay if from bottom of screen"""
# Check if swipe started from bottom 20% of screen
bottom_threshold = self.page_size[1] * 0.8
if y >= bottom_threshold:
# Open TOC overlay
overlay_image = self.open_toc_overlay()
if overlay_image:
return GestureResponse(ActionType.OVERLAY_OPENED, {
"overlay_type": "toc",
"chapters": self.get_chapters()
})
return GestureResponse(ActionType.NONE, {})
def _handle_overlay_tap(self, x: int, y: int) -> GestureResponse:
"""Handle tap when overlay is open - select chapter or close overlay"""
# For TOC overlay, use pyWebLayout link query to detect chapter clicks
if self.current_overlay_state == OverlayState.TOC:
# Query the overlay to see what was tapped
query_result = self.overlay_manager.query_overlay_pixel(x, y)
# If query failed (tap outside overlay), close it
if not query_result:
self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# Check if tapped on a link (chapter)
if query_result.get("is_interactive") and query_result.get("link_target"):
link_target = query_result["link_target"]
# Parse "chapter:N" format
if link_target.startswith("chapter:"):
try:
chapter_idx = int(link_target.split(":")[1])
# Get chapter title for response
chapters = self.get_chapters()
chapter_title = None
for title, idx in chapters:
if idx == chapter_idx:
chapter_title = title
break
# Jump to selected chapter
self.jump_to_chapter(chapter_idx)
# Close overlay
self.close_overlay()
return GestureResponse(ActionType.CHAPTER_SELECTED, {
"chapter_index": chapter_idx,
"chapter_title": chapter_title or f"Chapter {chapter_idx}"
})
except (ValueError, IndexError):
pass
# Not a chapter link, close overlay
self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# For other overlays, just close on any tap for now
self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
def _handle_overlay_close(self) -> GestureResponse:
"""Handle overlay close gesture (swipe down)"""
self.close_overlay()
return GestureResponse(ActionType.OVERLAY_CLOSED, {})
# ===================================================================
# Highlighting API
# ===================================================================
@@ -1037,6 +1178,107 @@ class EbookReader:
return result
# ===================================================================
# Overlay Management API
# ===================================================================
def open_toc_overlay(self) -> Optional[Image.Image]:
"""
Open the table of contents overlay.
Returns:
Composited image with TOC overlay on top of current page, or None if no book loaded
"""
if not self.is_loaded():
return None
# Get current page as base
base_page = self.get_current_page(include_highlights=False)
if not base_page:
return None
# Get chapters
chapters = self.get_chapters()
# Open overlay and get composited image
result = self.overlay_manager.open_toc_overlay(chapters, base_page)
self.current_overlay_state = OverlayState.TOC
return result
def open_settings_overlay(self) -> Optional[Image.Image]:
"""
Open the settings overlay.
Returns:
Composited image with settings overlay on top of current page, or None if no book loaded
"""
if not self.is_loaded():
return None
# Get current page as base
base_page = self.get_current_page(include_highlights=False)
if not base_page:
return None
# Open overlay and get composited image
result = self.overlay_manager.open_settings_overlay(base_page)
self.current_overlay_state = OverlayState.SETTINGS
return result
def open_bookmarks_overlay(self) -> Optional[Image.Image]:
"""
Open the bookmarks overlay.
Returns:
Composited image with bookmarks overlay on top of current page, or None if no book loaded
"""
if not self.is_loaded():
return None
# Get current page as base
base_page = self.get_current_page(include_highlights=False)
if not base_page:
return None
# Get bookmarks
bookmark_names = self.list_saved_positions()
bookmarks = [
{"name": name, "position": f"Saved position"}
for name in bookmark_names
]
# Open overlay and get composited image
result = self.overlay_manager.open_bookmarks_overlay(bookmarks, base_page)
self.current_overlay_state = OverlayState.BOOKMARKS
return result
def close_overlay(self) -> Optional[Image.Image]:
"""
Close the current overlay and return to reading view.
Returns:
Base page image without overlay, or None if no overlay was open
"""
if self.current_overlay_state == OverlayState.NONE:
return None
result = self.overlay_manager.close_overlay()
self.current_overlay_state = OverlayState.NONE
# Return fresh current page
return self.get_current_page()
def is_overlay_open(self) -> bool:
"""Check if an overlay is currently open."""
return self.current_overlay_state != OverlayState.NONE
def get_overlay_state(self) -> OverlayState:
"""Get current overlay state."""
return self.current_overlay_state
def close(self):
"""
Close the reader and save current position.
+77 -3
View File
@@ -7,6 +7,9 @@ from typing import List, Dict, Optional
from dreader import create_ebook_reader
import base64
from io import BytesIO
from PIL import Image
import ebooklib
from ebooklib import epub
def scan_book_directory(directory: Path) -> List[Dict[str, str]]:
@@ -53,9 +56,9 @@ def extract_book_metadata(epub_path: Path, include_cover: bool = True) -> Option
'author': reader.book_author or 'Unknown Author',
}
# Extract cover image if requested
# Extract cover image if requested - use direct EPUB extraction
if include_cover:
cover_data = extract_cover_as_base64(reader)
cover_data = extract_cover_from_epub(epub_path)
metadata['cover_data'] = cover_data
return metadata
@@ -75,6 +78,9 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450)
"""
Extract cover image from reader and return as base64 encoded string.
This function is kept for backward compatibility but now uses extract_cover_from_epub
internally if the reader has an epub_path attribute.
Args:
reader: EbookReader instance with loaded book
max_width: Maximum width for cover image
@@ -84,7 +90,11 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450)
Base64 encoded PNG image string or None
"""
try:
# Get first page as cover
# If the reader has an epub path, try to extract actual cover
if hasattr(reader, '_epub_path') and reader._epub_path:
return extract_cover_from_epub(reader._epub_path, max_width, max_height)
# Fallback to first page as cover
cover_image = reader.get_current_page()
# Resize if needed
@@ -104,6 +114,70 @@ def extract_cover_as_base64(reader, max_width: int = 300, max_height: int = 450)
return None
def extract_cover_from_epub(epub_path: Path, max_width: int = 300, max_height: int = 450) -> Optional[str]:
"""
Extract the actual cover image from an EPUB file.
Args:
epub_path: Path to EPUB file
max_width: Maximum width for cover image
max_height: Maximum height for cover image
Returns:
Base64 encoded PNG image string or None
"""
try:
# Read the EPUB
book = epub.read_epub(str(epub_path))
# Look for cover image
cover_image = None
# First, try to find item marked as cover
for item in book.get_items():
if item.get_type() == ebooklib.ITEM_COVER:
cover_image = Image.open(BytesIO(item.get_content()))
break
# If not found, look for files with 'cover' in the name
if not cover_image:
for item in book.get_items():
if item.get_type() == ebooklib.ITEM_IMAGE:
name = item.get_name().lower()
if 'cover' in name:
cover_image = Image.open(BytesIO(item.get_content()))
break
# If still not found, get the first image
if not cover_image:
for item in book.get_items():
if item.get_type() == ebooklib.ITEM_IMAGE:
try:
cover_image = Image.open(BytesIO(item.get_content()))
break
except:
continue
if not cover_image:
return None
# Resize if needed (maintain aspect ratio)
if cover_image.width > max_width or cover_image.height > max_height:
cover_image.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
# Convert to base64
buffer = BytesIO()
cover_image.save(buffer, format='PNG')
img_bytes = buffer.getvalue()
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
return img_base64
except Exception as e:
print(f"Error extracting cover from EPUB {epub_path}: {e}")
return None
def get_chapter_list(reader) -> List[Dict]:
"""
Get formatted chapter list from reader.
+67 -210
View File
@@ -8,14 +8,13 @@ for rendering.
from pathlib import Path
from typing import List, Dict, Optional
from dreader import create_ebook_reader
import base64
from io import BytesIO
def generate_library_html(books: List[Dict[str, str]]) -> str:
def generate_library_html(books: List[Dict[str, str]], save_covers_to_disk: bool = False) -> str:
"""
Generate HTML for the library view showing all books in a grid.
Generate HTML for the library view showing all books in a simple table.
Args:
books: List of book dictionaries with keys:
@@ -23,140 +22,46 @@ def generate_library_html(books: List[Dict[str, str]]) -> str:
- author: Book author
- filename: EPUB filename
- cover_data: Optional base64 encoded cover image
- cover_path: Optional path to saved cover image (if save_covers_to_disk=True)
save_covers_to_disk: If True, expect cover_path instead of cover_data
Returns:
Complete HTML string for library view
"""
books_html = []
for book in books:
cover_img = ''
if book.get('cover_data'):
cover_img = f'<img src="data:image/png;base64,{book["cover_data"]}" alt="Cover">'
else:
# Placeholder if no cover
cover_img = f'<div class="no-cover">{book["title"][:1]}</div>'
books_html.append(f'''
<td class="book-item" data-filename="{book['filename']}">
<table style="width: 100%;">
<tr>
<td class="cover-cell">
{cover_img}
</td>
</tr>
<tr>
<td class="title-cell">{book['title']}</td>
</tr>
<tr>
<td class="author-cell">{book['author']}</td>
</tr>
</table>
</td>
''')
# Arrange books in rows of 3
# Build table rows
rows = []
for i in range(0, len(books_html), 3):
row_books = books_html[i:i+3]
# Pad with empty cells if needed
while len(row_books) < 3:
row_books.append('<td class="book-item empty"></td>')
rows.append(f'<tr>{"".join(row_books)}</tr>')
html = f'''
for book in books:
# Add cover image cell if available
if save_covers_to_disk and book.get('cover_path'):
cover_cell = f'<td><img src="{book["cover_path"]}" width="150"/></td>'
elif book.get('cover_data'):
cover_cell = f'<td><img src="data:image/png;base64,{book["cover_data"]}" width="150"/></td>'
else:
cover_cell = '<td>[No cover]</td>'
# Add book info cell
info_cell = f'<td><b>{book["title"]}</b><br/>{book["author"]}</td>'
rows.append(f'<tr>{cover_cell}{info_cell}</tr>')
table_html = '\n'.join(rows)
return f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Library</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: Arial, sans-serif;
background-color: #f5f5f5;
padding: 20px;
}}
.header {{
text-align: center;
padding: 20px;
background-color: #333;
color: white;
margin-bottom: 30px;
}}
.library-grid {{
width: 100%;
border-collapse: separate;
border-spacing: 20px;
}}
.book-item {{
background-color: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
cursor: pointer;
vertical-align: top;
width: 33%;
}}
.book-item:hover {{
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
}}
.book-item.empty {{
background: none;
box-shadow: none;
cursor: default;
}}
.cover-cell {{
text-align: center;
padding-bottom: 10px;
}}
.cover-cell img {{
max-width: 200px;
max-height: 300px;
border: 1px solid #ddd;
}}
.no-cover {{
width: 200px;
height: 300px;
background-color: #ddd;
display: flex;
align-items: center;
justify-content: center;
font-size: 72px;
color: #999;
margin: 0 auto;
}}
.title-cell {{
font-weight: bold;
font-size: 16px;
text-align: center;
padding: 5px;
}}
.author-cell {{
color: #666;
font-size: 14px;
text-align: center;
padding: 5px;
}}
</style>
</head>
<body>
<div class="header">
<h1>My Library</h1>
<p>{len(books)} books</p>
</div>
<table class="library-grid">
{"".join(rows)}
</table>
<h1>My Library</h1>
<p>{len(books)} books</p>
<table>
{table_html}
</table>
</body>
</html>
'''
return html
</html>'''
def generate_reader_html(book_title: str, book_author: str, page_image_data: str) -> str:
@@ -418,7 +323,7 @@ def generate_settings_overlay() -> str:
return html
def generate_toc_overlay(chapters: List[Dict]) -> str:
def generate_toc_overlay(chapters: List[Dict], page_size: tuple = (800, 1200)) -> str:
"""
Generate HTML for the table of contents overlay.
@@ -426,105 +331,57 @@ def generate_toc_overlay(chapters: List[Dict]) -> str:
chapters: List of chapter dictionaries with keys:
- index: Chapter index
- title: Chapter title
page_size: Page dimensions (width, height) for sizing the overlay
Returns:
HTML string for TOC overlay
HTML string for TOC overlay (60% popup with transparent background)
"""
chapter_rows = []
for chapter in chapters:
chapter_rows.append(f'''
<tr class="chapter-row" data-chapter-index="{chapter['index']}">
<td class="chapter-cell">{chapter['title']}</td>
</tr>
''')
# Build chapter list items with clickable links for pyWebLayout query
chapter_items = []
for i, chapter in enumerate(chapters):
title = chapter["title"]
# Wrap each row in a paragraph with an inline link
# For very short titles (I, II), pad the link text to ensure it's clickable
link_text = f'{i+1}. {title}'
if len(title) <= 2:
# Add extra padding spaces inside the link to make it easier to click
link_text = f'{i+1}. {title} ' # Extra spaces for padding
chapter_items.append(
f'<p style="padding: 12px; margin: 5px 0; background-color: #f0f0f0; '
f'border-left: 3px solid #000;">'
f'<a href="chapter:{chapter["index"]}" style="text-decoration: none; color: #000;">'
f'{link_text}</a></p>'
)
# Render simple white panel - compositing will be done by OverlayManager
html = f'''
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Table of Contents</title>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
body {{
font-family: Arial, sans-serif;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}}
.overlay-panel {{
background-color: white;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
padding: 20px;
min-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
}}
.overlay-header {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #ddd;
}}
.overlay-title {{
font-size: 24px;
font-weight: bold;
}}
.close-button {{
background-color: #dc3545;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}}
.close-button:hover {{
background-color: #c82333;
}}
.chapters-container {{
overflow-y: auto;
flex: 1;
}}
.chapters-table {{
width: 100%;
border-collapse: collapse;
}}
.chapter-row {{
cursor: pointer;
}}
.chapter-row:hover {{
background-color: #f0f0f0;
}}
.chapter-cell {{
padding: 12px;
border-bottom: 1px solid #eee;
}}
</style>
</head>
<body>
<div class="overlay-panel">
<div class="overlay-header">
<span class="overlay-title">Table of Contents</span>
<button class="close-button" id="btn-close">Close</button>
</div>
<body style="background-color: white; margin: 0; padding: 25px; font-family: Arial, sans-serif;">
<div class="chapters-container">
<table class="chapters-table">
{"".join(chapter_rows)}
</table>
</div>
<h1 style="color: #000; margin: 0 0 8px 0; font-size: 24px; text-align: center; font-weight: bold;">
Table of Contents
</h1>
<p style="text-align: center; color: #666; margin: 0 0 15px 0; padding-bottom: 12px;
border-bottom: 2px solid #ccc; font-size: 13px;">
{len(chapters)} chapters
</p>
<div style="max-height: 600px; overflow-y: auto;">
{"".join(chapter_items)}
</div>
<p style="text-align: center; margin: 15px 0 0 0; padding-top: 12px;
border-top: 2px solid #ccc; color: #888; font-size: 11px;">
Tap a chapter to navigate Tap outside to close
</p>
</body>
</html>
'''
+410
View File
@@ -0,0 +1,410 @@
"""
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()
+332
View File
@@ -0,0 +1,332 @@
"""
Overlay management for dreader application.
Handles rendering and compositing of overlay screens (TOC, Settings, Bookmarks)
on top of the base reading page.
"""
from __future__ import annotations
from typing import Optional, List, Dict, Any, Tuple
from pathlib import Path
from PIL import Image
from .state import OverlayState
from .html_generator import (
generate_toc_overlay,
generate_settings_overlay,
generate_bookmarks_overlay
)
class OverlayManager:
"""
Manages overlay rendering and interaction.
Handles:
- Generating overlay HTML
- Rendering HTML to images using pyWebLayout
- Compositing overlays on top of base pages
- Tracking current overlay state
"""
def __init__(self, page_size: Tuple[int, int] = (800, 1200)):
"""
Initialize overlay manager.
Args:
page_size: Size of the page/overlay (width, height)
"""
self.page_size = page_size
self.current_overlay = OverlayState.NONE
self._cached_base_page: Optional[Image.Image] = None
self._cached_overlay_image: Optional[Image.Image] = None
self._overlay_reader = None # Will be EbookReader instance for rendering overlays
self._overlay_panel_offset: Tuple[int, int] = (0, 0) # Panel position on screen
def render_html_to_image(self, html: str, size: Optional[Tuple[int, int]] = None) -> Image.Image:
"""
Render HTML content to a PIL Image using pyWebLayout.
This creates a temporary EbookReader instance to render the HTML,
then extracts the rendered page as an image.
Args:
html: HTML string to render
size: Optional (width, height) for rendering size. Defaults to self.page_size
Returns:
PIL Image of the rendered HTML
"""
# Import here to avoid circular dependency
from .application import EbookReader
render_size = size if size else self.page_size
# Create a temporary reader for rendering this HTML
temp_reader = EbookReader(
page_size=render_size,
margin=15,
background_color=(255, 255, 255)
)
# Load the HTML content
success = temp_reader.load_html(
html_string=html,
title="Overlay",
author="",
document_id="temp_overlay"
)
if not success:
raise ValueError("Failed to load HTML for overlay rendering")
# Get the rendered page
image = temp_reader.get_current_page()
# Clean up
temp_reader.close()
return image
def composite_overlay(self, base_image: Image.Image, overlay_panel: Image.Image) -> Image.Image:
"""
Composite overlay panel on top of base image with darkened background.
Creates a popup effect by:
1. Darkening the base image (multiply by 0.5)
2. Placing the overlay panel (60% size) centered on top
Args:
base_image: Base page image (reading page)
overlay_panel: Rendered overlay panel (TOC, settings, etc.)
Returns:
Composited PIL Image with popup overlay effect
"""
from PIL import ImageDraw, ImageEnhance
import numpy as np
# Convert base image to RGB
result = base_image.convert('RGB').copy()
# Lighten the background slightly (70% brightness for e-ink visibility)
enhancer = ImageEnhance.Brightness(result)
result = enhancer.enhance(0.7)
# Convert overlay panel to RGB
if overlay_panel.mode != 'RGB':
overlay_panel = overlay_panel.convert('RGB')
# Calculate centered position for the panel
panel_x = int((self.page_size[0] - overlay_panel.width) / 2)
panel_y = int((self.page_size[1] - overlay_panel.height) / 2)
# Add a thick black border around the panel for e-ink clarity
draw = ImageDraw.Draw(result)
border_width = 3
draw.rectangle(
[panel_x - border_width, panel_y - border_width,
panel_x + overlay_panel.width + border_width, panel_y + overlay_panel.height + border_width],
outline=(0, 0, 0),
width=border_width
)
# Paste the panel onto the dimmed background
result.paste(overlay_panel, (panel_x, panel_y))
return result
def open_toc_overlay(self, chapters: List[Tuple[str, int]], base_page: Image.Image) -> Image.Image:
"""
Open the table of contents overlay.
Args:
chapters: List of (chapter_title, chapter_index) tuples
base_page: Current reading page to show underneath
Returns:
Composited image with TOC overlay on top
"""
# Import here to avoid circular dependency
from .application import EbookReader
# Calculate panel size (60% of screen)
panel_width = int(self.page_size[0] * 0.6)
panel_height = int(self.page_size[1] * 0.7)
# Convert chapters to format expected by HTML generator
chapter_data = [
{"index": idx, "title": title}
for title, idx in chapters
]
# Generate TOC HTML with clickable links
html = generate_toc_overlay(chapter_data, page_size=(panel_width, panel_height))
# Create reader for overlay and keep it alive for querying
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = EbookReader(
page_size=(panel_width, panel_height),
margin=15,
background_color=(255, 255, 255)
)
# Load the HTML content
success = self._overlay_reader.load_html(
html_string=html,
title="Table of Contents",
author="",
document_id="toc_overlay"
)
if not success:
raise ValueError("Failed to load TOC overlay HTML")
# Get the rendered page
overlay_panel = self._overlay_reader.get_current_page()
# Calculate and store panel position for coordinate translation
panel_x = int((self.page_size[0] - panel_width) / 2)
panel_y = int((self.page_size[1] - panel_height) / 2)
self._overlay_panel_offset = (panel_x, panel_y)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_panel
self.current_overlay = OverlayState.TOC
# Composite and return
return self.composite_overlay(base_page, overlay_panel)
def open_settings_overlay(self, base_page: Image.Image) -> Image.Image:
"""
Open the settings overlay.
Args:
base_page: Current reading page to show underneath
Returns:
Composited image with settings overlay on top
"""
# Generate settings HTML
html = generate_settings_overlay()
# Render HTML to image
overlay_image = self.render_html_to_image(html)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_image
self.current_overlay = OverlayState.SETTINGS
# Composite and return
return self.composite_overlay(base_page, overlay_image)
def open_bookmarks_overlay(self, bookmarks: List[Dict[str, Any]], base_page: Image.Image) -> Image.Image:
"""
Open the bookmarks overlay.
Args:
bookmarks: List of bookmark dictionaries with 'name' and 'position' keys
base_page: Current reading page to show underneath
Returns:
Composited image with bookmarks overlay on top
"""
# Generate bookmarks HTML
html = generate_bookmarks_overlay(bookmarks)
# Render HTML to image
overlay_image = self.render_html_to_image(html)
# Cache for later use
self._cached_base_page = base_page.copy()
self._cached_overlay_image = overlay_image
self.current_overlay = OverlayState.BOOKMARKS
# Composite and return
return self.composite_overlay(base_page, overlay_image)
def close_overlay(self) -> Optional[Image.Image]:
"""
Close the current overlay and return to base page.
Returns:
Base page image (without overlay), or None if no overlay was open
"""
if self.current_overlay == OverlayState.NONE:
return None
self.current_overlay = OverlayState.NONE
base_page = self._cached_base_page
# Clear caches
self._cached_base_page = None
self._cached_overlay_image = None
self._overlay_panel_offset = (0, 0)
# Close overlay reader
if self._overlay_reader:
self._overlay_reader.close()
self._overlay_reader = None
return base_page
def is_overlay_open(self) -> bool:
"""Check if an overlay is currently open."""
return self.current_overlay != OverlayState.NONE
def get_current_overlay_type(self) -> OverlayState:
"""Get the type of currently open overlay."""
return self.current_overlay
def query_overlay_pixel(self, x: int, y: int) -> Optional[Dict[str, Any]]:
"""
Query a pixel in the current overlay to detect interactions.
Uses pyWebLayout's query_point() to detect which element was tapped,
including link targets and data attributes.
Args:
x, y: Pixel coordinates to query (in screen space)
Returns:
Dictionary with query result data (text, link_target, is_interactive),
or None if no overlay open or query failed
"""
if not self.is_overlay_open() or not self._overlay_reader:
return None
# Translate screen coordinates to overlay panel coordinates
panel_x, panel_y = self._overlay_panel_offset
overlay_x = x - panel_x
overlay_y = y - panel_y
# Check if coordinates are within the overlay panel
if overlay_x < 0 or overlay_y < 0:
return None
# Get the current page from the overlay reader
if not self._overlay_reader.manager:
return None
current_page = self._overlay_reader.manager.get_current_page()
if not current_page:
return None
# Query the point
result = current_page.query_point((overlay_x, overlay_y))
if not result:
return None
# Extract relevant data from QueryResult
return {
"text": result.text,
"link_target": result.link_target,
"is_interactive": result.is_interactive,
"bounds": result.bounds,
"object_type": result.object_type
}
+392
View File
@@ -0,0 +1,392 @@
"""
State management for dreader application.
Handles application state persistence with asyncio-based auto-save functionality.
State is saved to a JSON file and includes current mode, book position, settings, etc.
"""
from __future__ import annotations
import asyncio
import json
import os
from dataclasses import dataclass, asdict, field
from datetime import datetime
from enum import Enum
from pathlib import Path
from typing import Optional, Dict, Any, List
import tempfile
import shutil
class EreaderMode(Enum):
"""Application mode states"""
LIBRARY = "library"
READING = "reading"
class OverlayState(Enum):
"""Overlay states within READING mode"""
NONE = "none"
TOC = "toc"
SETTINGS = "settings"
BOOKMARKS = "bookmarks"
@dataclass
class BookState:
"""State for currently open book - just the path and metadata"""
path: str
title: str = ""
author: str = ""
last_read_timestamp: str = ""
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'BookState':
"""Create from dictionary"""
return cls(
path=data['path'],
title=data.get('title', ''),
author=data.get('author', ''),
last_read_timestamp=data.get('last_read_timestamp', '')
)
@dataclass
class LibraryState:
"""State for library view"""
books_path: str = ""
last_selected_index: int = 0
scan_cache: List[Dict[str, Any]] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'LibraryState':
"""Create from dictionary"""
return cls(
books_path=data.get('books_path', ''),
last_selected_index=data.get('last_selected_index', 0),
scan_cache=data.get('scan_cache', [])
)
@dataclass
class Settings:
"""User settings"""
font_scale: float = 1.0
line_spacing: int = 5
inter_block_spacing: int = 15
brightness: int = 8
theme: str = "day"
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return asdict(self)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Settings':
"""Create from dictionary"""
return cls(
font_scale=data.get('font_scale', 1.0),
line_spacing=data.get('line_spacing', 5),
inter_block_spacing=data.get('inter_block_spacing', 15),
brightness=data.get('brightness', 8),
theme=data.get('theme', 'day')
)
@dataclass
class AppState:
"""Complete application state"""
version: str = "1.0"
mode: EreaderMode = EreaderMode.LIBRARY
overlay: OverlayState = OverlayState.NONE
current_book: Optional[BookState] = None
library: LibraryState = field(default_factory=LibraryState)
settings: Settings = field(default_factory=Settings)
bookmarks: Dict[str, List[str]] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization"""
return {
'version': self.version,
'mode': self.mode.value,
'overlay': self.overlay.value,
'current_book': self.current_book.to_dict() if self.current_book else None,
'library': self.library.to_dict(),
'settings': self.settings.to_dict(),
'bookmarks': self.bookmarks
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'AppState':
"""Create from dictionary"""
current_book = None
if data.get('current_book'):
current_book = BookState.from_dict(data['current_book'])
return cls(
version=data.get('version', '1.0'),
mode=EreaderMode(data.get('mode', 'library')),
overlay=OverlayState(data.get('overlay', 'none')),
current_book=current_book,
library=LibraryState.from_dict(data.get('library', {})),
settings=Settings.from_dict(data.get('settings', {})),
bookmarks=data.get('bookmarks', {})
)
class StateManager:
"""
Manages application state with persistence and auto-save.
Features:
- Load/save state to JSON file
- Asyncio-based auto-save timer (every 60 seconds)
- Atomic writes (write to temp file, then rename)
- Backup of previous state on corruption
- Thread-safe state updates
"""
def __init__(self, state_file: Optional[str] = None, auto_save_interval: int = 60):
"""
Initialize state manager.
Args:
state_file: Path to state file. If None, uses default location.
auto_save_interval: Auto-save interval in seconds (default: 60)
"""
if state_file:
self.state_file = Path(state_file)
else:
self.state_file = self._get_default_state_file()
self.auto_save_interval = auto_save_interval
self.state = AppState()
self._dirty = False
self._save_task: Optional[asyncio.Task] = None
self._lock = asyncio.Lock()
# Ensure state directory exists
self.state_file.parent.mkdir(parents=True, exist_ok=True)
@staticmethod
def _get_default_state_file() -> Path:
"""Get default state file location 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' / 'state.json'
def load_state(self) -> AppState:
"""
Load state from file.
Returns:
Loaded AppState, or default AppState if file doesn't exist or is corrupt
"""
if not self.state_file.exists():
print(f"No state file found at {self.state_file}, using defaults")
return AppState()
try:
with open(self.state_file, 'r') as f:
data = json.load(f)
self.state = AppState.from_dict(data)
self._dirty = False
print(f"State loaded from {self.state_file}")
# Clear overlay state on boot (always start without overlays)
if self.state.overlay != OverlayState.NONE:
print("Clearing overlay state on boot")
self.state.overlay = OverlayState.NONE
self._dirty = True
return self.state
except Exception as e:
print(f"Error loading state from {self.state_file}: {e}")
# Backup corrupt file
backup_path = self.state_file.with_suffix('.json.backup')
try:
shutil.copy2(self.state_file, backup_path)
print(f"Backed up corrupt state to {backup_path}")
except Exception as backup_error:
print(f"Failed to backup corrupt state: {backup_error}")
# Return default state
self.state = AppState()
self._dirty = True
return self.state
def save_state(self, force: bool = False) -> bool:
"""
Save state to file (synchronous).
Args:
force: Save even if state is not dirty
Returns:
True if saved successfully, False otherwise
"""
if not force and not self._dirty:
return True
try:
# Atomic write: write to temp file, then rename
temp_fd, temp_path = tempfile.mkstemp(
dir=self.state_file.parent,
prefix='.state_',
suffix='.json.tmp'
)
try:
with os.fdopen(temp_fd, 'w') as f:
json.dump(self.state.to_dict(), f, indent=2)
# Atomic rename
os.replace(temp_path, self.state_file)
self._dirty = False
print(f"State saved to {self.state_file}")
return True
except Exception as e:
# Clean up temp file on error
try:
os.unlink(temp_path)
except:
pass
raise e
except Exception as e:
print(f"Error saving state: {e}")
return False
async def save_state_async(self, force: bool = False) -> bool:
"""
Save state to file (async version).
Args:
force: Save even if state is not dirty
Returns:
True if saved successfully, False otherwise
"""
async with self._lock:
# Run sync save in executor to avoid blocking
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, self.save_state, force)
async def _auto_save_loop(self):
"""Background task for automatic state saving"""
while True:
try:
await asyncio.sleep(self.auto_save_interval)
if self._dirty:
print(f"Auto-saving state (interval: {self.auto_save_interval}s)")
await self.save_state_async()
except asyncio.CancelledError:
print("Auto-save loop cancelled")
break
except Exception as e:
print(f"Error in auto-save loop: {e}")
def start_auto_save(self):
"""Start the auto-save background task"""
if self._save_task is None or self._save_task.done():
self._save_task = asyncio.create_task(self._auto_save_loop())
print(f"Auto-save started (interval: {self.auto_save_interval}s)")
async def stop_auto_save(self, save_final: bool = True):
"""
Stop the auto-save background task.
Args:
save_final: Whether to perform a final save before stopping
"""
if self._save_task and not self._save_task.done():
self._save_task.cancel()
try:
await self._save_task
except asyncio.CancelledError:
pass
if save_final:
await self.save_state_async(force=True)
print("Final state save completed")
# Convenience methods for state access
def get_mode(self) -> EreaderMode:
"""Get current application mode"""
return self.state.mode
def set_mode(self, mode: EreaderMode):
"""Set application mode"""
if self.state.mode != mode:
self.state.mode = mode
self._dirty = True
def get_overlay(self) -> OverlayState:
"""Get current overlay state"""
return self.state.overlay
def set_overlay(self, overlay: OverlayState):
"""Set overlay state"""
if self.state.overlay != overlay:
self.state.overlay = overlay
self._dirty = True
def get_current_book(self) -> Optional[BookState]:
"""Get current book state"""
return self.state.current_book
def set_current_book(self, book: Optional[BookState]):
"""Set current book state"""
self.state.current_book = book
if book:
book.last_read_timestamp = datetime.now().isoformat()
self._dirty = True
def update_book_timestamp(self):
"""Update current book's last read timestamp"""
if self.state.current_book:
self.state.current_book.last_read_timestamp = datetime.now().isoformat()
self._dirty = True
def get_settings(self) -> Settings:
"""Get user settings"""
return self.state.settings
def update_setting(self, key: str, value: Any):
"""Update a single setting"""
if hasattr(self.state.settings, key):
setattr(self.state.settings, key, value)
self._dirty = True
def get_library_state(self) -> LibraryState:
"""Get library state"""
return self.state.library
def update_library_cache(self, cache: List[Dict[str, Any]]):
"""Update library scan cache"""
self.state.library.scan_cache = cache
self._dirty = True
def is_dirty(self) -> bool:
"""Check if state has unsaved changes"""
return self._dirty
def mark_dirty(self):
"""Mark state as having unsaved changes"""
self._dirty = True