Added autosave
Fixed unusual bug where scrolling while draging content results in desyned movements
This commit is contained in:
parent
3bfe2fa654
commit
e93a789524
247
pyPhotoAlbum/autosave_manager.py
Normal file
247
pyPhotoAlbum/autosave_manager.py
Normal file
@ -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
|
||||||
@ -706,11 +706,12 @@ class StateChangeCommand(Command):
|
|||||||
class CommandHistory:
|
class CommandHistory:
|
||||||
"""Manages undo/redo command history"""
|
"""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.undo_stack: List[Command] = []
|
||||||
self.redo_stack: List[Command] = []
|
self.redo_stack: List[Command] = []
|
||||||
self.max_history = max_history
|
self.max_history = max_history
|
||||||
self.asset_manager = asset_manager
|
self.asset_manager = asset_manager
|
||||||
|
self.project = project # Reference to project for dirty flag tracking
|
||||||
|
|
||||||
def execute(self, command: Command):
|
def execute(self, command: Command):
|
||||||
"""Execute a command and add it to history"""
|
"""Execute a command and add it to history"""
|
||||||
@ -723,6 +724,10 @@ class CommandHistory:
|
|||||||
|
|
||||||
self.undo_stack.append(command)
|
self.undo_stack.append(command)
|
||||||
|
|
||||||
|
# Mark project as dirty
|
||||||
|
if self.project:
|
||||||
|
self.project.mark_dirty()
|
||||||
|
|
||||||
# Limit history size - release assets from old commands
|
# Limit history size - release assets from old commands
|
||||||
if len(self.undo_stack) > self.max_history:
|
if len(self.undo_stack) > self.max_history:
|
||||||
old_cmd = self.undo_stack.pop(0)
|
old_cmd = self.undo_stack.pop(0)
|
||||||
@ -751,6 +756,11 @@ class CommandHistory:
|
|||||||
command = self.undo_stack.pop()
|
command = self.undo_stack.pop()
|
||||||
command.undo()
|
command.undo()
|
||||||
self.redo_stack.append(command)
|
self.redo_stack.append(command)
|
||||||
|
|
||||||
|
# Mark project as dirty
|
||||||
|
if self.project:
|
||||||
|
self.project.mark_dirty()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def redo(self) -> bool:
|
def redo(self) -> bool:
|
||||||
@ -761,6 +771,11 @@ class CommandHistory:
|
|||||||
command = self.redo_stack.pop()
|
command = self.redo_stack.pop()
|
||||||
command.redo()
|
command.redo()
|
||||||
self.undo_stack.append(command)
|
self.undo_stack.append(command)
|
||||||
|
|
||||||
|
# Mark project as dirty
|
||||||
|
if self.project:
|
||||||
|
self.project.mark_dirty()
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def can_undo(self) -> bool:
|
def can_undo(self) -> bool:
|
||||||
|
|||||||
@ -9,9 +9,9 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QApplication, QMainWindow, QVBoxLayout, QWidget,
|
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 PyQt6.QtGui import QIcon
|
||||||
|
|
||||||
from pyPhotoAlbum.project import Project
|
from pyPhotoAlbum.project import Project
|
||||||
@ -19,6 +19,7 @@ from pyPhotoAlbum.template_manager import TemplateManager
|
|||||||
from pyPhotoAlbum.ribbon_widget import RibbonWidget
|
from pyPhotoAlbum.ribbon_widget import RibbonWidget
|
||||||
from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
|
from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
|
||||||
from pyPhotoAlbum.gl_widget import GLWidget
|
from pyPhotoAlbum.gl_widget import GLWidget
|
||||||
|
from pyPhotoAlbum.autosave_manager import AutosaveManager
|
||||||
|
|
||||||
# Import mixins
|
# Import mixins
|
||||||
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
|
||||||
@ -61,12 +62,23 @@ class MainWindow(
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
# Initialize autosave manager
|
||||||
|
self._autosave_manager = AutosaveManager()
|
||||||
|
|
||||||
# Initialize shared state first
|
# Initialize shared state first
|
||||||
self._init_state()
|
self._init_state()
|
||||||
|
|
||||||
# Initialize UI
|
# Initialize UI
|
||||||
self._init_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
|
# Add a sample page for demonstration
|
||||||
#self._add_sample_page()
|
#self._add_sample_page()
|
||||||
|
|
||||||
@ -265,6 +277,109 @@ class MainWindow(
|
|||||||
page.manually_sized = False # Not manually sized, uses defaults
|
page.manually_sized = False # Not manually sized, uses defaults
|
||||||
self.project.add_page(page)
|
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():
|
def main():
|
||||||
|
|||||||
@ -280,11 +280,23 @@ class MouseInteractionMixin:
|
|||||||
new_zoom = self.zoom_level * zoom_factor
|
new_zoom = self.zoom_level * zoom_factor
|
||||||
|
|
||||||
if 0.1 <= new_zoom <= 5.0:
|
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.zoom_level = new_zoom
|
||||||
|
|
||||||
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
|
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
|
||||||
self.pan_offset[1] = mouse_y - world_y * 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
|
# Clamp pan offset to content bounds
|
||||||
if hasattr(self, 'clamp_pan_offset'):
|
if hasattr(self, 'clamp_pan_offset'):
|
||||||
self.clamp_pan_offset()
|
self.clamp_pan_offset()
|
||||||
@ -301,12 +313,21 @@ class MouseInteractionMixin:
|
|||||||
else:
|
else:
|
||||||
# Regular wheel: Vertical scroll
|
# Regular wheel: Vertical scroll
|
||||||
scroll_amount = delta * 0.5
|
scroll_amount = delta * 0.5
|
||||||
|
old_pan_y = self.pan_offset[1]
|
||||||
self.pan_offset[1] += scroll_amount
|
self.pan_offset[1] += scroll_amount
|
||||||
|
|
||||||
# Clamp pan offset to content bounds
|
# Clamp pan offset to content bounds
|
||||||
if hasattr(self, 'clamp_pan_offset'):
|
if hasattr(self, 'clamp_pan_offset'):
|
||||||
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()
|
self.update()
|
||||||
|
|
||||||
# Update scrollbars if available
|
# Update scrollbars if available
|
||||||
|
|||||||
@ -200,6 +200,7 @@ class FileOperationsMixin:
|
|||||||
|
|
||||||
# Create and configure async loader
|
# Create and configure async loader
|
||||||
self._project_loader = AsyncProjectLoader(file_path)
|
self._project_loader = AsyncProjectLoader(file_path)
|
||||||
|
self._opening_file_path = file_path # Store for later
|
||||||
|
|
||||||
# Connect signals
|
# Connect signals
|
||||||
self._project_loader.progress_updated.connect(self._on_load_progress)
|
self._project_loader.progress_updated.connect(self._on_load_progress)
|
||||||
@ -223,6 +224,13 @@ class FileOperationsMixin:
|
|||||||
|
|
||||||
# Set new project
|
# Set new project
|
||||||
self.project = 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
|
self.gl_widget.current_page_index = 0 # Reset to first page
|
||||||
|
|
||||||
# Hide loading widget
|
# Hide loading widget
|
||||||
@ -255,6 +263,10 @@ class FileOperationsMixin:
|
|||||||
)
|
)
|
||||||
def save_project(self):
|
def save_project(self):
|
||||||
"""Save the current project"""
|
"""Save the current project"""
|
||||||
|
# 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(
|
file_path, _ = QFileDialog.getSaveFileName(
|
||||||
self,
|
self,
|
||||||
"Save Project",
|
"Save Project",
|
||||||
@ -269,6 +281,8 @@ class FileOperationsMixin:
|
|||||||
success, error = save_to_zip(self.project, file_path)
|
success, error = save_to_zip(self.project, file_path)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
|
self.project.file_path = file_path
|
||||||
|
self.project.mark_clean()
|
||||||
self.show_status(f"Project saved: {file_path}")
|
self.show_status(f"Project saved: {file_path}")
|
||||||
print(f"Successfully saved project to: {file_path}")
|
print(f"Successfully saved project to: {file_path}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@ -135,9 +135,24 @@ class Project:
|
|||||||
# Initialize asset manager
|
# Initialize asset manager
|
||||||
self.asset_manager = AssetManager(self.folder_path)
|
self.asset_manager = AssetManager(self.folder_path)
|
||||||
|
|
||||||
# Initialize command history with asset manager
|
# Initialize command history with asset manager and project reference
|
||||||
self.history = CommandHistory(max_history=100)
|
self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
|
||||||
self.history.asset_manager = self.asset_manager
|
|
||||||
|
# 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):
|
def add_page(self, page: Page):
|
||||||
"""Add a page to the project"""
|
"""Add a page to the project"""
|
||||||
@ -145,6 +160,7 @@ class Project:
|
|||||||
# Update cover dimensions if we have a cover
|
# Update cover dimensions if we have a cover
|
||||||
if self.has_cover and self.pages:
|
if self.has_cover and self.pages:
|
||||||
self.update_cover_dimensions()
|
self.update_cover_dimensions()
|
||||||
|
self.mark_dirty()
|
||||||
|
|
||||||
def remove_page(self, page: Page):
|
def remove_page(self, page: Page):
|
||||||
"""Remove a page from the project"""
|
"""Remove a page from the project"""
|
||||||
@ -152,6 +168,7 @@ class Project:
|
|||||||
# Update cover dimensions if we have a cover
|
# Update cover dimensions if we have a cover
|
||||||
if self.has_cover and self.pages:
|
if self.has_cover and self.pages:
|
||||||
self.update_cover_dimensions()
|
self.update_cover_dimensions()
|
||||||
|
self.mark_dirty()
|
||||||
|
|
||||||
def calculate_spine_width(self) -> float:
|
def calculate_spine_width(self) -> float:
|
||||||
"""
|
"""
|
||||||
@ -380,15 +397,13 @@ class Project:
|
|||||||
page.deserialize(page_data)
|
page.deserialize(page_data)
|
||||||
self.pages.append(page)
|
self.pages.append(page)
|
||||||
|
|
||||||
# Deserialize command history with asset manager
|
# Deserialize command history with asset manager and project reference
|
||||||
history_data = data.get("history")
|
history_data = data.get("history")
|
||||||
if history_data:
|
if history_data:
|
||||||
self.history = CommandHistory(max_history=100)
|
self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
|
||||||
self.history.asset_manager = self.asset_manager
|
|
||||||
self.history.deserialize(history_data, self)
|
self.history.deserialize(history_data, self)
|
||||||
else:
|
else:
|
||||||
self.history = CommandHistory(max_history=100)
|
self.history = CommandHistory(max_history=100, asset_manager=self.asset_manager, project=self)
|
||||||
self.history.asset_manager = self.asset_manager
|
|
||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user