From e93a789524ec72934b75715f628697457fb735b9 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Nov 2025 22:29:05 +0100 Subject: [PATCH] Added autosave Fixed unusual bug where scrolling while draging content results in desyned movements --- pyPhotoAlbum/autosave_manager.py | 247 +++++++++++++++++++++ pyPhotoAlbum/commands.py | 33 ++- pyPhotoAlbum/main.py | 127 ++++++++++- pyPhotoAlbum/mixins/mouse_interaction.py | 21 ++ pyPhotoAlbum/mixins/operations/file_ops.py | 32 ++- pyPhotoAlbum/project.py | 31 ++- 6 files changed, 459 insertions(+), 32 deletions(-) create mode 100644 pyPhotoAlbum/autosave_manager.py diff --git a/pyPhotoAlbum/autosave_manager.py b/pyPhotoAlbum/autosave_manager.py new file mode 100644 index 0000000..0486bd7 --- /dev/null +++ b/pyPhotoAlbum/autosave_manager.py @@ -0,0 +1,247 @@ +""" +Autosave and checkpoint management for pyPhotoAlbum. + +This module provides automatic checkpoint creation and recovery functionality +to prevent data loss from crashes or unexpected exits. +""" + +import os +import json +import shutil +from pathlib import Path +from datetime import datetime +from typing import Optional, List, Tuple +from pyPhotoAlbum.project_serializer import save_to_zip, load_from_zip + + +class AutosaveManager: + """Manages autosave checkpoints for projects.""" + + CHECKPOINT_DIR = Path.home() / ".pyphotoalbum" / "checkpoints" + CHECKPOINT_PREFIX = "checkpoint_" + CHECKPOINT_EXTENSION = ".ppz" + + def __init__(self): + """Initialize the autosave manager.""" + self._ensure_checkpoint_directory() + + def _ensure_checkpoint_directory(self): + """Ensure the checkpoint directory exists.""" + self.CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True) + + def _get_checkpoint_path(self, project_name: str, timestamp: Optional[datetime] = None) -> Path: + """ + Get the path for a checkpoint file. + + Args: + project_name: Name of the project + timestamp: Optional timestamp, defaults to current time + + Returns: + Path to the checkpoint file + """ + if timestamp is None: + timestamp = datetime.now() + + # Sanitize project name for filename + safe_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in project_name) + timestamp_str = timestamp.strftime("%Y%m%d_%H%M%S") + filename = f"{self.CHECKPOINT_PREFIX}{safe_name}_{timestamp_str}{self.CHECKPOINT_EXTENSION}" + + return self.CHECKPOINT_DIR / filename + + def create_checkpoint(self, project) -> Tuple[bool, str]: + """ + Create a checkpoint for the given project. + + Args: + project: Project instance to checkpoint + + Returns: + Tuple of (success: bool, message: str) + """ + try: + checkpoint_path = self._get_checkpoint_path(project.name) + success, message = save_to_zip(project, str(checkpoint_path)) + + if success: + # Also save metadata about this checkpoint + self._save_checkpoint_metadata(project, checkpoint_path) + return True, f"Checkpoint created: {checkpoint_path.name}" + else: + return False, f"Checkpoint failed: {message}" + + except Exception as e: + return False, f"Checkpoint error: {str(e)}" + + def _save_checkpoint_metadata(self, project, checkpoint_path: Path): + """ + Save metadata about a checkpoint. + + Args: + project: Project instance + checkpoint_path: Path to the checkpoint file + """ + metadata = { + "project_name": project.name, + "timestamp": datetime.now().isoformat(), + "checkpoint_path": str(checkpoint_path), + "original_path": getattr(project, 'file_path', None), + } + + metadata_path = checkpoint_path.with_suffix('.json') + with open(metadata_path, 'w') as f: + json.dump(metadata, f, indent=2) + + def list_checkpoints(self, project_name: Optional[str] = None) -> List[Tuple[Path, dict]]: + """ + List available checkpoints. + + Args: + project_name: Optional filter by project name + + Returns: + List of tuples (checkpoint_path, metadata) + """ + checkpoints = [] + + for checkpoint_file in self.CHECKPOINT_DIR.glob(f"{self.CHECKPOINT_PREFIX}*{self.CHECKPOINT_EXTENSION}"): + metadata_file = checkpoint_file.with_suffix('.json') + + # Try to load metadata + metadata = {} + if metadata_file.exists(): + try: + with open(metadata_file, 'r') as f: + metadata = json.load(f) + except: + pass + + # Filter by project name if specified + if project_name is None or metadata.get('project_name') == project_name: + checkpoints.append((checkpoint_file, metadata)) + + # Sort by timestamp (newest first) + checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True) + return checkpoints + + def load_checkpoint(self, checkpoint_path: Path): + """ + Load a project from a checkpoint. + + Args: + checkpoint_path: Path to the checkpoint file + + Returns: + Tuple of (success: bool, project or error_message) + """ + try: + success, result = load_from_zip(str(checkpoint_path)) + return success, result + except Exception as e: + return False, f"Failed to load checkpoint: {str(e)}" + + def delete_checkpoint(self, checkpoint_path: Path) -> bool: + """ + Delete a checkpoint file and its metadata. + + Args: + checkpoint_path: Path to the checkpoint file + + Returns: + True if successful + """ + try: + # Delete checkpoint file + if checkpoint_path.exists(): + checkpoint_path.unlink() + + # Delete metadata file + metadata_path = checkpoint_path.with_suffix('.json') + if metadata_path.exists(): + metadata_path.unlink() + + return True + except Exception as e: + print(f"Error deleting checkpoint: {e}") + return False + + def delete_all_checkpoints(self, project_name: Optional[str] = None): + """ + Delete all checkpoints, optionally filtered by project name. + + Args: + project_name: Optional filter by project name + """ + checkpoints = self.list_checkpoints(project_name) + for checkpoint_path, _ in checkpoints: + self.delete_checkpoint(checkpoint_path) + + def cleanup_old_checkpoints(self, max_age_hours: int = 24 * 7, max_count: int = 50): + """ + Clean up old checkpoints to prevent unlimited growth. + + Args: + max_age_hours: Maximum age in hours (default: 7 days) + max_count: Maximum number of checkpoints to keep per project + """ + from datetime import timedelta + + now = datetime.now() + checkpoints_by_project = {} + + # Group checkpoints by project + for checkpoint_path, metadata in self.list_checkpoints(): + project_name = metadata.get('project_name', 'unknown') + if project_name not in checkpoints_by_project: + checkpoints_by_project[project_name] = [] + checkpoints_by_project[project_name].append((checkpoint_path, metadata)) + + # Clean up each project's checkpoints + for project_name, checkpoints in checkpoints_by_project.items(): + # Sort by timestamp (newest first) + checkpoints.sort(key=lambda x: x[1].get('timestamp', ''), reverse=True) + + for idx, (checkpoint_path, metadata) in enumerate(checkpoints): + # Delete if too old + timestamp_str = metadata.get('timestamp') + if timestamp_str: + try: + timestamp = datetime.fromisoformat(timestamp_str) + age = now - timestamp + if age > timedelta(hours=max_age_hours): + self.delete_checkpoint(checkpoint_path) + continue + except: + pass + + # Delete if beyond max count + if idx >= max_count: + self.delete_checkpoint(checkpoint_path) + + def has_checkpoints(self, project_name: Optional[str] = None) -> bool: + """ + Check if there are any checkpoints available. + + Args: + project_name: Optional filter by project name + + Returns: + True if checkpoints exist + """ + return len(self.list_checkpoints(project_name)) > 0 + + def get_latest_checkpoint(self, project_name: Optional[str] = None) -> Optional[Tuple[Path, dict]]: + """ + Get the most recent checkpoint. + + Args: + project_name: Optional filter by project name + + Returns: + Tuple of (checkpoint_path, metadata) or None + """ + checkpoints = self.list_checkpoints(project_name) + if checkpoints: + return checkpoints[0] # Already sorted newest first + return None diff --git a/pyPhotoAlbum/commands.py b/pyPhotoAlbum/commands.py index 1b29d68..7522152 100644 --- a/pyPhotoAlbum/commands.py +++ b/pyPhotoAlbum/commands.py @@ -705,24 +705,29 @@ class StateChangeCommand(Command): class CommandHistory: """Manages undo/redo command history""" - - def __init__(self, max_history: int = 100, asset_manager=None): + + def __init__(self, max_history: int = 100, asset_manager=None, project=None): self.undo_stack: List[Command] = [] self.redo_stack: List[Command] = [] self.max_history = max_history self.asset_manager = asset_manager - + self.project = project # Reference to project for dirty flag tracking + def execute(self, command: Command): """Execute a command and add it to history""" command.execute() - + # When clearing redo stack, release asset references for cmd in self.redo_stack: self._release_command_assets(cmd) self.redo_stack.clear() - + self.undo_stack.append(command) - + + # Mark project as dirty + if self.project: + self.project.mark_dirty() + # Limit history size - release assets from old commands if len(self.undo_stack) > self.max_history: old_cmd = self.undo_stack.pop(0) @@ -747,20 +752,30 @@ class CommandHistory: """Undo the last command""" if not self.can_undo(): return False - + command = self.undo_stack.pop() command.undo() self.redo_stack.append(command) + + # Mark project as dirty + if self.project: + self.project.mark_dirty() + return True - + def redo(self) -> bool: """Redo the last undone command""" if not self.can_redo(): return False - + command = self.redo_stack.pop() command.redo() self.undo_stack.append(command) + + # Mark project as dirty + if self.project: + self.project.mark_dirty() + return True def can_undo(self) -> bool: diff --git a/pyPhotoAlbum/main.py b/pyPhotoAlbum/main.py index 516dcaf..17e6bba 100644 --- a/pyPhotoAlbum/main.py +++ b/pyPhotoAlbum/main.py @@ -9,9 +9,9 @@ import sys from pathlib import Path from PyQt6.QtWidgets import ( QApplication, QMainWindow, QVBoxLayout, QWidget, - QStatusBar, QScrollBar, QHBoxLayout + QStatusBar, QScrollBar, QHBoxLayout, QMessageBox ) -from PyQt6.QtCore import Qt, QSize +from PyQt6.QtCore import Qt, QSize, QTimer from PyQt6.QtGui import QIcon from pyPhotoAlbum.project import Project @@ -19,6 +19,7 @@ from pyPhotoAlbum.template_manager import TemplateManager from pyPhotoAlbum.ribbon_widget import RibbonWidget from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary from pyPhotoAlbum.gl_widget import GLWidget +from pyPhotoAlbum.autosave_manager import AutosaveManager # Import mixins from pyPhotoAlbum.mixins.base import ApplicationStateMixin @@ -60,13 +61,24 @@ class MainWindow( def __init__(self): super().__init__() - + + # Initialize autosave manager + self._autosave_manager = AutosaveManager() + # Initialize shared state first self._init_state() - + # Initialize UI self._init_ui() - + + # Check for checkpoint recovery + self._check_checkpoint_recovery() + + # Setup autosave timer (every 5 minutes) + self._autosave_timer = QTimer(self) + self._autosave_timer.timeout.connect(self._perform_autosave) + self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds + # Add a sample page for demonstration #self._add_sample_page() @@ -264,7 +276,110 @@ class MainWindow( page = Page(layout=page_layout, page_number=1) page.manually_sized = False # Not manually sized, uses defaults self.project.add_page(page) - + + def _perform_autosave(self): + """Perform automatic checkpoint save""" + if self.project and self.project.is_dirty(): + success, message = self._autosave_manager.create_checkpoint(self.project) + if success: + print(f"Autosave: {message}") + else: + print(f"Autosave failed: {message}") + + def _check_checkpoint_recovery(self): + """Check for available checkpoints on startup and offer recovery""" + if not self._autosave_manager.has_checkpoints(): + return + + # Get the latest checkpoint + checkpoint_info = self._autosave_manager.get_latest_checkpoint() + if not checkpoint_info: + return + + checkpoint_path, metadata = checkpoint_info + project_name = metadata.get('project_name', 'Unknown') + timestamp_str = metadata.get('timestamp', 'Unknown time') + + # Parse timestamp for better display + try: + from datetime import datetime + timestamp = datetime.fromisoformat(timestamp_str) + time_display = timestamp.strftime("%Y-%m-%d %H:%M:%S") + except: + time_display = timestamp_str + + # Ask user if they want to recover + reply = QMessageBox.question( + self, + "Checkpoint Recovery", + f"A checkpoint was found:\n\n" + f"Project: {project_name}\n" + f"Time: {time_display}\n\n" + f"Would you like to recover this checkpoint?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.Yes + ) + + if reply == QMessageBox.StandardButton.Yes: + # Load the checkpoint + success, result = self._autosave_manager.load_checkpoint(checkpoint_path) + + if success: + # Replace current project with recovered one + if hasattr(self, '_project') and self._project: + self._project.cleanup() + + self._project = result + self.gl_widget.current_page_index = 0 + self.update_view() + + self.show_status(f"Recovered checkpoint: {project_name}") + print(f"Successfully recovered checkpoint: {project_name}") + else: + error_msg = f"Failed to recover checkpoint: {result}" + self.show_error("Recovery Failed", error_msg) + print(error_msg) + + def closeEvent(self, event): + """Handle window close event""" + # Check if project has unsaved changes + if self.project and self.project.is_dirty(): + reply = QMessageBox.question( + self, + "Unsaved Changes", + "You have unsaved changes. Would you like to save before exiting?", + QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, + QMessageBox.StandardButton.Save + ) + + if reply == QMessageBox.StandardButton.Save: + # Trigger save + self.save_project() + + # Check if save was successful (project should be clean now) + if self.project.is_dirty(): + # User cancelled save dialog or save failed + event.ignore() + return + elif reply == QMessageBox.StandardButton.Cancel: + # User cancelled exit + event.ignore() + return + # If Discard, continue with exit + + # Clean up checkpoints on successful exit + if self.project: + self._autosave_manager.delete_all_checkpoints(self.project.name) + self.project.cleanup() + + # Stop autosave timer + if hasattr(self, '_autosave_timer'): + self._autosave_timer.stop() + + # Cleanup old checkpoints + self._autosave_manager.cleanup_old_checkpoints() + + event.accept() def main(): diff --git a/pyPhotoAlbum/mixins/mouse_interaction.py b/pyPhotoAlbum/mixins/mouse_interaction.py index d886350..41a147a 100644 --- a/pyPhotoAlbum/mixins/mouse_interaction.py +++ b/pyPhotoAlbum/mixins/mouse_interaction.py @@ -280,11 +280,23 @@ class MouseInteractionMixin: new_zoom = self.zoom_level * zoom_factor if 0.1 <= new_zoom <= 5.0: + old_pan_x = self.pan_offset[0] + old_pan_y = self.pan_offset[1] + self.zoom_level = new_zoom self.pan_offset[0] = mouse_x - world_x * self.zoom_level self.pan_offset[1] = mouse_y - world_y * self.zoom_level + # If dragging, adjust drag_start_pos to account for pan_offset change + if self.is_dragging and self.drag_start_pos: + pan_delta_x = self.pan_offset[0] - old_pan_x + pan_delta_y = self.pan_offset[1] - old_pan_y + self.drag_start_pos = ( + self.drag_start_pos[0] + pan_delta_x, + self.drag_start_pos[1] + pan_delta_y + ) + # Clamp pan offset to content bounds if hasattr(self, 'clamp_pan_offset'): self.clamp_pan_offset() @@ -301,12 +313,21 @@ class MouseInteractionMixin: else: # Regular wheel: Vertical scroll scroll_amount = delta * 0.5 + old_pan_y = self.pan_offset[1] self.pan_offset[1] += scroll_amount # Clamp pan offset to content bounds if hasattr(self, 'clamp_pan_offset'): self.clamp_pan_offset() + # If dragging, adjust drag_start_pos to account for pan_offset change + if self.is_dragging and self.drag_start_pos: + pan_delta_y = self.pan_offset[1] - old_pan_y + self.drag_start_pos = ( + self.drag_start_pos[0], + self.drag_start_pos[1] + pan_delta_y + ) + self.update() # Update scrollbars if available diff --git a/pyPhotoAlbum/mixins/operations/file_ops.py b/pyPhotoAlbum/mixins/operations/file_ops.py index 161e37e..3052bd8 100644 --- a/pyPhotoAlbum/mixins/operations/file_ops.py +++ b/pyPhotoAlbum/mixins/operations/file_ops.py @@ -200,6 +200,7 @@ class FileOperationsMixin: # Create and configure async loader self._project_loader = AsyncProjectLoader(file_path) + self._opening_file_path = file_path # Store for later # Connect signals self._project_loader.progress_updated.connect(self._on_load_progress) @@ -223,6 +224,13 @@ class FileOperationsMixin: # Set new project self.project = project + + # Set file path and mark as clean + if hasattr(self, '_opening_file_path'): + self.project.file_path = self._opening_file_path + delattr(self, '_opening_file_path') + self.project.mark_clean() + self.gl_widget.current_page_index = 0 # Reset to first page # Hide loading widget @@ -255,20 +263,26 @@ class FileOperationsMixin: ) def save_project(self): """Save the current project""" - file_path, _ = QFileDialog.getSaveFileName( - self, - "Save Project", - "", - "pyPhotoAlbum Projects (*.ppz);;All Files (*)" - ) - + # If project has a file path, use it; otherwise prompt for location + file_path = self.project.file_path if hasattr(self.project, 'file_path') and self.project.file_path else None + + if not file_path: + file_path, _ = QFileDialog.getSaveFileName( + self, + "Save Project", + "", + "pyPhotoAlbum Projects (*.ppz);;All Files (*)" + ) + if file_path: print(f"Saving project to: {file_path}") - + # Save project to ZIP success, error = save_to_zip(self.project, file_path) - + if success: + self.project.file_path = file_path + self.project.mark_clean() self.show_status(f"Project saved: {file_path}") print(f"Successfully saved project to: {file_path}") else: diff --git a/pyPhotoAlbum/project.py b/pyPhotoAlbum/project.py index d9a8f17..e021a29 100644 --- a/pyPhotoAlbum/project.py +++ b/pyPhotoAlbum/project.py @@ -135,9 +135,24 @@ class Project: # Initialize asset manager self.asset_manager = AssetManager(self.folder_path) - # Initialize command history with asset manager - self.history = CommandHistory(max_history=100) - self.history.asset_manager = self.asset_manager + # Initialize command history with asset manager and project reference + self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self) + + # Track unsaved changes + self._dirty = False + self.file_path = None # Path to the saved .ppz file + + def mark_dirty(self): + """Mark the project as having unsaved changes.""" + self._dirty = True + + def mark_clean(self): + """Mark the project as saved (no unsaved changes).""" + self._dirty = False + + def is_dirty(self) -> bool: + """Check if the project has unsaved changes.""" + return self._dirty def add_page(self, page: Page): """Add a page to the project""" @@ -145,6 +160,7 @@ class Project: # Update cover dimensions if we have a cover if self.has_cover and self.pages: self.update_cover_dimensions() + self.mark_dirty() def remove_page(self, page: Page): """Remove a page from the project""" @@ -152,6 +168,7 @@ class Project: # Update cover dimensions if we have a cover if self.has_cover and self.pages: self.update_cover_dimensions() + self.mark_dirty() def calculate_spine_width(self) -> float: """ @@ -380,15 +397,13 @@ class Project: page.deserialize(page_data) self.pages.append(page) - # Deserialize command history with asset manager + # Deserialize command history with asset manager and project reference history_data = data.get("history") if history_data: - self.history = CommandHistory(max_history=100) - self.history.asset_manager = self.asset_manager + self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self) self.history.deserialize(history_data, self) else: - self.history = CommandHistory(max_history=100) - self.history.asset_manager = self.asset_manager + self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self) def cleanup(self): """