2025-11-12 18:52:08 +00:00

408 lines
13 KiB
Python

"""
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" # Deprecated: use NAVIGATION instead
SETTINGS = "settings"
BOOKMARKS = "bookmarks" # Deprecated: use NAVIGATION instead
NAVIGATION = "navigation" # Unified overlay for TOC and 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 for rendering and display"""
font_scale: float = 1.0
line_spacing: int = 5
inter_block_spacing: int = 15
word_spacing: int = 0 # Default word spacing
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),
word_spacing=data.get('word_spacing', 0),
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 update_settings(self, settings_dict: Dict[str, Any]):
"""
Update multiple settings at once.
Args:
settings_dict: Dictionary with setting keys and values
"""
for key, value in settings_dict.items():
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