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

1""" 

2Merge operations mixin for pyPhotoAlbum 

3""" 

4 

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 

14 

15 

16class MergeOperationsMixin: 

17 """Mixin providing project merge operations""" 

18 

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. 

28 

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 ) 

40 

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() 

47 

48 # Select file to merge 

49 file_path, _ = QFileDialog.getOpenFileName( 

50 self, "Select Project to Merge", "", "Photo Album Projects (*.ppz);;All Files (*)" 

51 ) 

52 

53 if not file_path: 

54 return 

55 

56 try: 

57 # Disable autosave during merge 

58 if hasattr(self, "_autosave_timer"): 

59 self._autosave_timer.stop() 

60 

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) 

65 

66 # Serialize both projects for comparison 

67 our_data = self.project.serialize() 

68 their_data = other_project.serialize() 

69 

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) 

73 

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) 

80 

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() 

87 

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) 

93 

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 ) 

102 

103 if reply != QMessageBox.StandardButton.Yes: 

104 return 

105 

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) 

111 

112 if dialog.exec() != QMessageBox.DialogCode.Accepted: 

113 QMessageBox.information(self, "Merge Cancelled", "Merge operation cancelled.") 

114 return 

115 

116 # Get merged data from dialog 

117 merged_data = dialog.get_merged_project_data() 

118 

119 # Apply merged data to current project 

120 self._apply_merged_data(merged_data) 

121 

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 ) 

129 

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 ) 

141 

142 if reply != QMessageBox.StandardButton.Yes: 

143 return 

144 

145 # Concatenate projects 

146 merged_data = concatenate_projects(our_data, their_data) 

147 

148 # Apply merged data 

149 self._apply_merged_data(merged_data) 

150 

151 QMessageBox.information( 

152 self, 

153 "Concatenation Complete", 

154 f"Projects concatenated successfully.\n" f"Total pages: {len(merged_data.get('pages', []))}", 

155 ) 

156 

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) 

162 

163 # Replace current project 

164 self._project = new_project 

165 

166 # Update asset resolution context 

167 set_asset_resolution_context(new_project.folder_path) 

168 

169 # Mark as dirty (has unsaved changes from merge) 

170 new_project.mark_dirty() 

171 

172 # Update UI 

173 if hasattr(self, "gl_widget"): 

174 self.gl_widget.set_project(new_project) 

175 self.gl_widget.update() 

176 

177 if hasattr(self, "status_bar"): 

178 self.status_bar.showMessage("Merge completed successfully", 3000)