1 """

2 Merge operations mixin for pyPhotoAlbum

3 """

4

5 from PyQt6.QtWidgets import QFileDialog, QMessageBox

6 from pyPhotoAlbum.decorators import ribbon_action

7 from pyPhotoAlbum.merge_manager import MergeManager, concatenate_projects

8 from pyPhotoAlbum.merge_dialog import MergeDialog

9 from pyPhotoAlbum.project_serializer import load_from_zip, save_to_zip

10 from pyPhotoAlbum.models import set_asset_resolution_context

11 from pyPhotoAlbum.project import Project

12 import tempfile

13 import os

14

15

16 class 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)