1 """

2 Merge dialog for resolving project conflicts visually

3 """

4

5 from PyQt6.QtWidgets import (

6 QDialog,

7 QVBoxLayout,

8 QHBoxLayout,

9 QPushButton,

10 QLabel,

11 QListWidget,

12 QListWidgetItem,

13 QSplitter,

14 QWidget,

15 QScrollArea,

16 QRadioButton,

17 QButtonGroup,

18 QTextEdit,

19 QComboBox,

20 QGroupBox,

21 )

22 from PyQt6.QtCore import Qt, QSize, pyqtSignal

23 from PyQt6.QtGui import QPixmap, QPainter, QColor, QFont, QPen

24

25 from typing import Dict, Any, List, Optional

26 from pyPhotoAlbum.merge_manager import MergeManager, ConflictInfo, MergeStrategy

27 from pyPhotoAlbum.page_renderer import PageRenderer

28

29

30 class PagePreviewWidget(QWidget):

31 """Widget to render a page preview"""

32

33 def __init__(self, page_data: Dict[str, Any], parent=None):

34 super().__init__(parent)

35 self.page_data = page_data

36 self.setMinimumSize(200, 280)

37 self.setSizePolicy(self.sizePolicy().Policy.Expanding, self.sizePolicy().Policy.Expanding)

38

39 def paintEvent(self, event):

40 """Render the page preview"""

41 painter = QPainter(self)

42 painter.setRenderHint(QPainter.RenderHint.Antialiasing)

43

44 # Draw white background

45 painter.fillRect(self.rect(), QColor(255, 255, 255))

46

47 # Draw border

48 painter.setPen(QPen(QColor(200, 200, 200), 2))

49 painter.drawRect(self.rect().adjusted(1, 1, -1, -1))

50

51 # Draw placeholder text

52 painter.setPen(QColor(100, 100, 100))

53 font = QFont("Arial", 10)

54 painter.setFont(font)

55

56 # Page info

57 page_num = self.page_data.get("page_number", "?")

58 element_count = len(self.page_data.get("layout", {}).get("elements", []))

59 last_modified = self.page_data.get("last_modified", "Unknown")

60

61 # Draw simplified representation

62 y_offset = 20

63 painter.drawText(10, y_offset, f"Page {page_num}")

64 y_offset += 20

65 painter.drawText(10, y_offset, f"Elements: {element_count}")

66 y_offset += 20

67

68 # Draw element representations

69 elements = self.page_data.get("layout", {}).get("elements", [])

70 for i, elem in enumerate(elements[:5]): # Show first 5 elements

71 elem_type = elem.get("type", "unknown")

72 deleted = elem.get("deleted", False)

73

74 color = QColor(200, 200, 200) if deleted else QColor(100, 150, 200)

75 painter.setBrush(color)

76 painter.setPen(QPen(color.darker(120), 1))

77

78 # Draw small rectangle representing element

79 x = 10 + (i % 3) * 60

80 y = y_offset + (i // 3) * 60

81 painter.drawRect(x, y, 50, 50)

82

83 # Draw type label

84 painter.setPen(QColor(0, 0, 0))

85 painter.drawText(x + 5, y + 25, elem_type[:3].upper())

86

87 # Draw timestamp at bottom

88 painter.setPen(QColor(100, 100, 100))

89 painter.setFont(QFont("Arial", 8))

90 modified_text = last_modified[:19] if last_modified else "No timestamp"

91 painter.drawText(10, self.height() - 10, modified_text)

92

93

94 class ConflictItemWidget(QWidget):

95 """Widget for displaying and resolving a single conflict"""

96

97 resolution_changed = pyqtSignal(int, str) # conflict_index, choice ("ours" or "theirs")

98

99 def __init__(self, conflict_index: int, conflict: ConflictInfo, parent=None):

100 super().__init__(parent)

101 self.conflict_index = conflict_index

102 self.conflict = conflict

103

104 self._init_ui()

105

106 def _init_ui(self):

107 """Initialize the UI"""

108 layout = QVBoxLayout()

109

110 # Conflict description

111 desc_label = QLabel(f"<b>Conflict {self.conflict_index + 1}:</b> {self.conflict.description}")

112 desc_label.setWordWrap(True)

113 layout.addWidget(desc_label)

114

115 # Splitter for side-by-side comparison

116 splitter = QSplitter(Qt.Orientation.Horizontal)

117

118 # Our version

119 our_widget = QGroupBox("Your Version")

120 our_layout = QVBoxLayout()

121

122 if self.conflict.conflict_type.name.startswith("PAGE"):

123 # Show page preview

124 our_preview = PagePreviewWidget(self.conflict.our_version)

125 our_layout.addWidget(our_preview)

126 elif self.conflict.conflict_type.name.startswith("ELEMENT"):

127 # Show element details

128 our_details = self._create_element_details(self.conflict.our_version)

129 our_layout.addWidget(our_details)

130 else:

131 # Show settings

132 our_details = self._create_settings_details(self.conflict.our_version)

133 our_layout.addWidget(our_details)

134

135 our_widget.setLayout(our_layout)

136 splitter.addWidget(our_widget)

137

138 # Their version

139 their_widget = QGroupBox("Other Version")

140 their_layout = QVBoxLayout()

141

142 if self.conflict.conflict_type.name.startswith("PAGE"):

143 # Show page preview

144 their_preview = PagePreviewWidget(self.conflict.their_version)

145 their_layout.addWidget(their_preview)

146 elif self.conflict.conflict_type.name.startswith("ELEMENT"):

147 # Show element details

148 their_details = self._create_element_details(self.conflict.their_version)

149 their_layout.addWidget(their_details)

150 else:

151 # Show settings

152 their_details = self._create_settings_details(self.conflict.their_version)

153 their_layout.addWidget(their_details)

154

155 their_widget.setLayout(their_layout)

156 splitter.addWidget(their_widget)

157

158 layout.addWidget(splitter)

159

160 # Resolution buttons

161 resolution_layout = QHBoxLayout()

162

163 self.button_group = QButtonGroup(self)

164

165 use_ours_btn = QRadioButton("Use Your Version")

166 use_ours_btn.setChecked(True)

167 use_ours_btn.toggled.connect(lambda checked: self._on_resolution_changed("ours") if checked else None)

168 self.button_group.addButton(use_ours_btn)

169 resolution_layout.addWidget(use_ours_btn)

170

171 use_theirs_btn = QRadioButton("Use Other Version")

172 use_theirs_btn.toggled.connect(lambda checked: self._on_resolution_changed("theirs") if checked else None)

173 self.button_group.addButton(use_theirs_btn)

174 resolution_layout.addWidget(use_theirs_btn)

175

176 resolution_layout.addStretch()

177 layout.addLayout(resolution_layout)

178

179 self.setLayout(layout)

180

181 def _create_element_details(self, element_data: Dict[str, Any]) -> QTextEdit:

182 """Create a text widget showing element details"""

183 details = QTextEdit()

184 details.setReadOnly(True)

185 details.setMaximumHeight(150)

186

187 elem_type = element_data.get("type", "unknown")

188 position = element_data.get("position", (0, 0))

189 size = element_data.get("size", (0, 0))

190 deleted = element_data.get("deleted", False)

191 last_modified = element_data.get("last_modified", "Unknown")

192

193 text = f"Type: {elem_type}\n"

194 text += f"Position: ({position[0]:.1f}, {position[1]:.1f})\n"

195 text += f"Size: ({size[0]:.1f} × {size[1]:.1f})\n"

196 text += f"Deleted: {deleted}\n"

197 text += f"Modified: {last_modified[:19] if last_modified else 'Unknown'}\n"

198

199 if elem_type == "image":

200 text += f"Image: {element_data.get('image_path', 'N/A')}\n"

201 elif elem_type == "textbox":

202 text += f"Text: {element_data.get('text_content', '')[:50]}...\n"

203

204 details.setPlainText(text)

205 return details

206

207 def _create_settings_details(self, settings_data: Dict[str, Any]) -> QTextEdit:

208 """Create a text widget showing settings details"""

209 details = QTextEdit()

210 details.setReadOnly(True)

211 details.setMaximumHeight(150)

212

213 text = ""

214 for key, value in settings_data.items():

215 if key != "last_modified":

216 text += f"{key}: {value}\n"

217

218 last_modified = settings_data.get("last_modified", "Unknown")

219 text += f"\nModified: {last_modified[:19] if last_modified else 'Unknown'}"

220

221 details.setPlainText(text)

222 return details

223

224 def _on_resolution_changed(self, choice: str):

225 """Emit signal when resolution choice changes"""

226 self.resolution_changed.emit(self.conflict_index, choice)

227

228 def get_resolution(self) -> str:

229 """Get the current resolution choice"""

230 for button in self.button_group.buttons():

231 if button.isChecked():

232 if "Your" in button.text():

233 return "ours"

234 else:

235 return "theirs"

236 return "ours" # Default

237

238

239 class MergeDialog(QDialog):

240 """

241 Dialog for visually resolving merge conflicts between two project versions

242 """

243

244 def __init__(self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], parent=None):

245 super().__init__(parent)

246

247 self.our_project_data = our_project_data

248 self.their_project_data = their_project_data

249 self.merge_manager = MergeManager()

250

251 # Detect conflicts

252 self.conflicts = self.merge_manager.detect_conflicts(our_project_data, their_project_data)

253

254 # Resolution choices (conflict_index -> "ours" or "theirs")

255 self.resolutions: Dict[int, str] = {}

256

257 # Initialize default resolutions (all "ours")

258 for i in range(len(self.conflicts)):

259 self.resolutions[i] = "ours"

260

261 self.setWindowTitle("Merge Projects")

262 self.resize(900, 700)

263

264 self._init_ui()

265

266 def _init_ui(self):

267 """Initialize the user interface"""

268 layout = QVBoxLayout()

269

270 # Header

271 header_label = QLabel(

272 f"<h2>Merge Conflicts Detected</h2>"

273 f"<p>Your project: <b>{self.our_project_data.get('name', 'Untitled')}</b> "

274 f"(modified {self.our_project_data.get('last_modified', 'unknown')[:19]})</p>"

275 f"<p>Other project: <b>{self.their_project_data.get('name', 'Untitled')}</b> "

276 f"(modified {self.their_project_data.get('last_modified', 'unknown')[:19]})</p>"

277 f"<p>Found <b>{len(self.conflicts)}</b> conflict(s) requiring resolution.</p>"

278 )

279 header_label.setWordWrap(True)

280 layout.addWidget(header_label)

281

282 # Auto-resolve strategy

283 strategy_layout = QHBoxLayout()

284 strategy_layout.addWidget(QLabel("Auto-resolve all:"))

285

286 self.strategy_combo = QComboBox()

287 self.strategy_combo.addItem("Latest Wins", MergeStrategy.LATEST_WINS)

288 self.strategy_combo.addItem("Always Use Yours", MergeStrategy.OURS)

289 self.strategy_combo.addItem("Always Use Theirs", MergeStrategy.THEIRS)

290 strategy_layout.addWidget(self.strategy_combo)

291

292 auto_resolve_btn = QPushButton("Auto-Resolve All")

293 auto_resolve_btn.clicked.connect(self._auto_resolve)

294 strategy_layout.addWidget(auto_resolve_btn)

295

296 strategy_layout.addStretch()

297 layout.addLayout(strategy_layout)

298

299 # Scroll area for conflicts

300 scroll = QScrollArea()

301 scroll.setWidgetResizable(True)

302 scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)

303 scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)

304

305 conflicts_widget = QWidget()

306 conflicts_layout = QVBoxLayout()

307

308 # Create conflict widgets

309 self.conflict_widgets: List[ConflictItemWidget] = []

310 for i, conflict in enumerate(self.conflicts):

311 conflict_widget = ConflictItemWidget(i, conflict)

312 conflict_widget.resolution_changed.connect(self._on_resolution_changed)

313 self.conflict_widgets.append(conflict_widget)

314 conflicts_layout.addWidget(conflict_widget)

315

316 conflicts_layout.addStretch()

317 conflicts_widget.setLayout(conflicts_layout)

318 scroll.setWidget(conflicts_widget)

319

320 layout.addWidget(scroll)

321

322 # Buttons

323 button_layout = QHBoxLayout()

324 button_layout.addStretch()

325

326 cancel_button = QPushButton("Cancel")

327 cancel_button.clicked.connect(self.reject)

328 button_layout.addWidget(cancel_button)

329

330 merge_button = QPushButton("Apply Merge")

331 merge_button.clicked.connect(self.accept)

332 merge_button.setDefault(True)

333 button_layout.addWidget(merge_button)

334

335 layout.addLayout(button_layout)

336

337 self.setLayout(layout)

338

339 def _on_resolution_changed(self, conflict_index: int, choice: str):

340 """Handle resolution choice change"""

341 self.resolutions[conflict_index] = choice

342

343 def _auto_resolve(self):

344 """Auto-resolve all conflicts based on selected strategy"""

345 strategy = self.strategy_combo.currentData()

346 auto_resolutions = self.merge_manager.auto_resolve_conflicts(strategy)

347

348 # Update resolution choices

349 self.resolutions.update(auto_resolutions)

350

351 # Update UI to reflect auto-resolutions

352 for i, resolution in auto_resolutions.items():

353 if i < len(self.conflict_widgets):

354 # Find the correct radio button and check it

355 for button in self.conflict_widgets[i].button_group.buttons():

356 if resolution == "ours" and "Your" in button.text():

357 button.setChecked(True)

358 elif resolution == "theirs" and "Other" in button.text():

359 button.setChecked(True)

360

361 def get_merged_project_data(self) -> Dict[str, Any]:

362 """

363 Get the merged project data based on user's conflict resolutions.

364

365 Returns:

366 Merged project data dictionary

367 """

368 return self.merge_manager.apply_resolutions(self.our_project_data, self.their_project_data, self.resolutions)