Coverage for pyPhotoAlbum/mixins/operations/merge_ops.py: 99%
71 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2Merge operations mixin for pyPhotoAlbum
3"""
5from PyQt6.QtWidgets import QFileDialog, QMessageBox
6from pyPhotoAlbum.decorators import ribbon_action
7from pyPhotoAlbum.merge_manager import MergeManager, concatenate_projects
8from pyPhotoAlbum.merge_dialog import MergeDialog
9from pyPhotoAlbum.project_serializer import load_from_zip, save_to_zip
10from pyPhotoAlbum.models import set_asset_resolution_context
11from pyPhotoAlbum.project import Project
12import tempfile
13import os
16class MergeOperationsMixin:
17 """Mixin providing project merge operations"""
19 @ribbon_action(
20 label="Merge Projects",
21 tooltip="Merge another project file with the current project",
22 tab="Home",
23 group="File",
24 )
25 def merge_projects(self):
26 """
27 Merge another project with the current project.
29 If the projects have the same project_id, conflicts will be resolved.
30 If they have different project_ids, they will be concatenated.
31 """
32 # Check if current project has changes
33 if self.project.is_dirty():
34 reply = QMessageBox.question(
35 self,
36 "Unsaved Changes",
37 "You have unsaved changes in the current project. Save before merging?",
38 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No | QMessageBox.StandardButton.Cancel,
39 )
41 if reply == QMessageBox.StandardButton.Cancel:
42 return
43 elif reply == QMessageBox.StandardButton.Yes:
44 # Save current project first
45 if hasattr(self, "save_project"):
46 self.save_project()
48 # Select file to merge
49 file_path, _ = QFileDialog.getOpenFileName(
50 self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)"
51 )
53 if not file_path:
54 return
56 try:
57 # Disable autosave during merge
58 if hasattr(self, "_autosave_timer"):
59 self._autosave_timer.stop()
61 # Load the other project
62 with tempfile.TemporaryDirectory() as temp_dir:
63 # Load project data
64 other_project = load_from_zip(file_path, temp_dir)
66 # Serialize both projects for comparison
67 our_data = self.project.serialize()
68 their_data = other_project.serialize()
70 # Check if projects should be merged or concatenated
71 merge_manager = MergeManager()
72 should_merge = merge_manager.should_merge_projects(our_data, their_data)
74 if should_merge:
75 # Same project - merge with conflict resolution
76 self._perform_merge_with_conflicts(our_data, their_data)
77 else:
78 # Different projects - concatenate
79 self._perform_concatenation(our_data, their_data)
81 except Exception as e:
82 QMessageBox.critical(self, "Merge Error", f"Failed to merge projects:\n{str(e)}")
83 finally:
84 # Re-enable autosave
85 if hasattr(self, "_autosave_timer"):
86 self._autosave_timer.start()
88 def _perform_merge_with_conflicts(self, our_data, their_data):
89 """Perform merge with conflict resolution UI"""
90 # Detect conflicts
91 merge_manager = MergeManager()
92 conflicts = merge_manager.detect_conflicts(our_data, their_data)
94 if not conflicts:
95 # No conflicts - auto-merge
96 reply = QMessageBox.question(
97 self,
98 "No Conflicts",
99 "No conflicts detected. Merge projects automatically?",
100 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
101 )
103 if reply != QMessageBox.StandardButton.Yes:
104 return
106 # Auto-merge non-conflicting changes
107 merged_data = merge_manager.apply_resolutions(our_data, their_data, {})
108 else:
109 # Show merge dialog for conflict resolution
110 dialog = MergeDialog(our_data, their_data, self)
112 if dialog.exec() != QMessageBox.DialogCode.Accepted:
113 QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.")
114 return
116 # Get merged data from dialog
117 merged_data = dialog.get_merged_project_data()
119 # Apply merged data to current project
120 self._apply_merged_data(merged_data)
122 QMessageBox.information(
123 self,
124 "Merge Complete",
125 f"Projects merged successfully.\n"
126 f"Total pages: {len(merged_data.get('pages', []))}\n"
127 f"Resolved conflicts: {len(conflicts)}",
128 )
130 def _perform_concatenation(self, our_data, their_data):
131 """Concatenate two different projects"""
132 reply = QMessageBox.question(
133 self,
134 "Different Projects",
135 f"These are different projects:\n"
136 f" • {our_data.get('name', 'Untitled')}\n"
137 f" • {their_data.get('name', 'Untitled')}\n\n"
138 f"Concatenate them (combine all pages)?",
139 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
140 )
142 if reply != QMessageBox.StandardButton.Yes:
143 return
145 # Concatenate projects
146 merged_data = concatenate_projects(our_data, their_data)
148 # Apply merged data
149 self._apply_merged_data(merged_data)
151 QMessageBox.information(
152 self,
153 "Concatenation Complete",
154 f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}",
155 )
157 def _apply_merged_data(self, merged_data):
158 """Apply merged project data to current project"""
159 # Create new project from merged data
160 new_project = Project()
161 new_project.deserialize(merged_data)
163 # Replace current project
164 self._project = new_project
166 # Update asset resolution context
167 set_asset_resolution_context(new_project.folder_path)
169 # Mark as dirty (has unsaved changes from merge)
170 new_project.mark_dirty()
172 # Update UI
173 if hasattr(self, "gl_widget"):
174 self.gl_widget.set_project(new_project)
175 self.gl_widget.update()
177 if hasattr(self, "status_bar"):
178 self.status_bar.showMessage("Merge completed successfully", 3000)