Coverage for pyPhotoAlbum/merge_manager.py: 72%

202 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2Merge manager for handling project merge conflicts 

3 

4This module provides functionality for: 

5- Detecting when two projects should be merged vs. concatenated 

6- Finding conflicts between two project versions 

7- Resolving conflicts based on user input or automatic strategies 

8""" 

9 

10import copy 

11from typing import Dict, Any, List, Optional, Tuple 

12from enum import Enum 

13from dataclasses import dataclass 

14from datetime import datetime, timezone 

15 

16 

17class ConflictType(Enum): 

18 """Types of merge conflicts""" 

19 

20 # Page-level conflicts 

21 PAGE_MODIFIED_BOTH = "page_modified_both" # Page modified in both versions 

22 PAGE_DELETED_ONE = "page_deleted_one" # Page deleted in one version, modified in other 

23 PAGE_ADDED_BOTH = "page_added_both" # Same page number added in both (rare) 

24 

25 # Element-level conflicts 

26 ELEMENT_MODIFIED_BOTH = "element_modified_both" # Element modified in both versions 

27 ELEMENT_DELETED_ONE = "element_deleted_one" # Element deleted in one, modified in other 

28 

29 # Project-level conflicts 

30 SETTINGS_MODIFIED_BOTH = "settings_modified_both" # Project settings changed in both 

31 

32 

33class MergeStrategy(Enum): 

34 """Automatic merge resolution strategies""" 

35 

36 LATEST_WINS = "latest_wins" # Most recent last_modified wins 

37 OURS = "ours" # Always use our version 

38 THEIRS = "theirs" # Always use their version 

39 MANUAL = "manual" # Require manual resolution 

40 

41 

42@dataclass 

43class ConflictInfo: 

44 """Information about a single merge conflict""" 

45 

46 conflict_type: ConflictType 

47 page_uuid: Optional[str] # UUID of the page (if page-level conflict) 

48 element_uuid: Optional[str] # UUID of the element (if element-level conflict) 

49 our_version: Any # Our version of the conflicted item 

50 their_version: Any # Their version of the conflicted item 

51 description: str # Human-readable description 

52 

53 

54class MergeManager: 

55 """Manages merge operations between two project versions""" 

56 

57 def __init__(self): 

58 self.conflicts: List[ConflictInfo] = [] 

59 

60 def should_merge_projects(self, project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> bool: 

61 """ 

62 Determine if two projects should be merged or concatenated. 

63 

64 Projects with the same project_id should be merged (conflict resolution). 

65 Projects with different project_ids should be concatenated (combine content). 

66 

67 Args: 

68 project_a_data: First project's serialized data 

69 project_b_data: Second project's serialized data 

70 

71 Returns: 

72 True if projects should be merged, False if concatenated 

73 """ 

74 project_a_id = project_a_data.get("project_id") 

75 project_b_id = project_b_data.get("project_id") 

76 

77 # If either project lacks a project_id (v2.0 or earlier), assume different projects 

78 if not project_a_id or not project_b_id: 

79 print("MergeManager: One or both projects lack project_id, assuming concatenation") 

80 return False 

81 

82 return bool(project_a_id == project_b_id) 

83 

84 def detect_conflicts( 

85 self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any] 

86 ) -> List[ConflictInfo]: 

87 """ 

88 Detect conflicts between two versions of the same project. 

89 

90 Args: 

91 our_project_data: Our version of the project (serialized) 

92 their_project_data: Their version of the project (serialized) 

93 

94 Returns: 

95 List of conflicts found 

96 """ 

97 self.conflicts = [] 

98 

99 # Detect project-level conflicts 

100 self._detect_project_settings_conflicts(our_project_data, their_project_data) 

101 

102 # Detect page-level conflicts 

103 self._detect_page_conflicts(our_project_data, their_project_data) 

104 

105 return self.conflicts 

106 

107 def _detect_project_settings_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]): 

108 """Detect conflicts in project-level settings.""" 

109 # Settings that can conflict 

110 settings_keys = [ 

111 "name", 

112 "page_size_mm", 

113 "working_dpi", 

114 "export_dpi", 

115 "has_cover", 

116 "paper_thickness_mm", 

117 "cover_bleed_mm", 

118 "binding_type", 

119 ] 

120 

121 our_modified = our_data.get("last_modified") 

122 their_modified = their_data.get("last_modified") 

123 

124 for key in settings_keys: 

125 our_value = our_data.get(key) 

126 their_value = their_data.get(key) 

127 

128 # If values differ, it's a conflict 

129 if our_value != their_value: 

130 self.conflicts.append( 

131 ConflictInfo( 

132 conflict_type=ConflictType.SETTINGS_MODIFIED_BOTH, 

133 page_uuid=None, 

134 element_uuid=None, 

135 our_version={key: our_value, "last_modified": our_modified}, 

136 their_version={key: their_value, "last_modified": their_modified}, 

137 description=f"Project setting '{key}' modified in both versions", 

138 ) 

139 ) 

140 

141 def _detect_page_conflicts(self, our_data: Dict[str, Any], their_data: Dict[str, Any]): 

142 """Detect conflicts at page level.""" 

143 our_pages = {page["uuid"]: page for page in our_data.get("pages", [])} 

144 their_pages = {page["uuid"]: page for page in their_data.get("pages", [])} 

145 

146 # Check all pages that exist in our version 

147 for page_uuid, our_page in our_pages.items(): 

148 their_page = their_pages.get(page_uuid) 

149 

150 if their_page is None: 

151 # Page exists in ours but not theirs - check if deleted 

152 if our_page.get("deleted"): 

153 continue # Both deleted, no conflict 

154 # We have it, they don't (might have deleted it) 

155 # This could be a conflict if we modified it after they deleted it 

156 continue 

157 

158 # Page exists in both - check for modifications 

159 self._detect_page_modification_conflicts(page_uuid, our_page, their_page) 

160 

161 # Check for pages that exist only in their version 

162 for page_uuid, their_page in their_pages.items(): 

163 if page_uuid not in our_pages: 

164 # They have a page we don't - this is fine, add it 

165 # Unless we deleted it 

166 pass 

167 

168 def _detect_page_modification_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]): 

169 """Detect conflicts in a specific page.""" 

170 our_modified = our_page.get("last_modified") 

171 their_modified = their_page.get("last_modified") 

172 

173 # Check if both deleted 

174 if our_page.get("deleted") and their_page.get("deleted"): 

175 return # No conflict 

176 

177 # Check if one deleted, one modified 

178 if our_page.get("deleted") != their_page.get("deleted"): 

179 self.conflicts.append( 

180 ConflictInfo( 

181 conflict_type=ConflictType.PAGE_DELETED_ONE, 

182 page_uuid=page_uuid, 

183 element_uuid=None, 

184 our_version=our_page, 

185 their_version=their_page, 

186 description=f"Page deleted in one version but modified in the other", 

187 ) 

188 ) 

189 return 

190 

191 # Check page-level properties 

192 page_props = ["page_number", "is_cover", "is_double_spread"] 

193 page_modified = False 

194 for prop in page_props: 

195 if our_page.get(prop) != their_page.get(prop): 

196 page_modified = True 

197 break 

198 

199 # Only flag as conflict if properties differ AND timestamps are identical 

200 # (See element conflict detection for detailed explanation of this strategy) 

201 if page_modified and our_modified == their_modified: 

202 self.conflicts.append( 

203 ConflictInfo( 

204 conflict_type=ConflictType.PAGE_MODIFIED_BOTH, 

205 page_uuid=page_uuid, 

206 element_uuid=None, 

207 our_version=our_page, 

208 their_version=their_page, 

209 description=f"Page properties modified with same timestamp (possible conflict)", 

210 ) 

211 ) 

212 

213 # Check element-level conflicts 

214 self._detect_element_conflicts(page_uuid, our_page, their_page) 

215 

216 def _detect_element_conflicts(self, page_uuid: str, our_page: Dict[str, Any], their_page: Dict[str, Any]): 

217 """Detect conflicts in elements within a page.""" 

218 our_layout = our_page.get("layout", {}) 

219 their_layout = their_page.get("layout", {}) 

220 

221 our_elements = {elem["uuid"]: elem for elem in our_layout.get("elements", [])} 

222 their_elements = {elem["uuid"]: elem for elem in their_layout.get("elements", [])} 

223 

224 # Check all elements in our version 

225 for elem_uuid, our_elem in our_elements.items(): 

226 their_elem = their_elements.get(elem_uuid) 

227 

228 if their_elem is None: 

229 # Element exists in ours but not theirs 

230 if our_elem.get("deleted"): 

231 continue # Both deleted, no conflict 

232 # We have it, they don't 

233 continue 

234 

235 # Element exists in both - check for modifications 

236 self._detect_element_modification_conflicts(page_uuid, elem_uuid, our_elem, their_elem) 

237 

238 def _detect_element_modification_conflicts( 

239 self, page_uuid: str, elem_uuid: str, our_elem: Dict[str, Any], their_elem: Dict[str, Any] 

240 ): 

241 """Detect conflicts in a specific element.""" 

242 our_modified = our_elem.get("last_modified") 

243 their_modified = their_elem.get("last_modified") 

244 

245 # Check if both deleted 

246 if our_elem.get("deleted") and their_elem.get("deleted"): 

247 return # No conflict 

248 

249 # Check if one deleted, one modified 

250 if our_elem.get("deleted") != their_elem.get("deleted"): 

251 self.conflicts.append( 

252 ConflictInfo( 

253 conflict_type=ConflictType.ELEMENT_DELETED_ONE, 

254 page_uuid=page_uuid, 

255 element_uuid=elem_uuid, 

256 our_version=our_elem, 

257 their_version=their_elem, 

258 description=f"Element deleted in one version but modified in the other", 

259 ) 

260 ) 

261 return 

262 

263 # Check element properties 

264 elem_props = ["position", "size", "rotation", "z_index"] 

265 

266 # Add type-specific properties 

267 elem_type = our_elem.get("type") 

268 if elem_type == "image": 

269 elem_props.extend(["image_path", "crop_info", "pil_rotation_90"]) 

270 elif elem_type == "textbox": 

271 elem_props.extend(["text_content", "font_settings", "alignment"]) 

272 

273 # Check if any properties differ 

274 props_modified = False 

275 for prop in elem_props: 

276 if our_elem.get(prop) != their_elem.get(prop): 

277 props_modified = True 

278 break 

279 

280 # Without a 3-way merge (base version), we cannot reliably detect if BOTH versions 

281 # modified an element vs only ONE version modifying it. 

282 # 

283 # Strategy: Only flag as conflict when we have strong evidence of concurrent modification: 

284 # - Properties differ AND timestamps are identical → suspicious, possible conflict 

285 # - Properties differ AND timestamps differ → one version modified it, auto-merge by timestamp 

286 # 

287 # If timestamps differ, _merge_non_conflicting_changes will handle it by using the newer version. 

288 if props_modified and our_modified == their_modified: 

289 # Properties differ but timestamps match - this is unusual and might indicate 

290 # that both versions modified it at exactly the same time, or there's data corruption. 

291 # Flag as conflict to be safe. 

292 self.conflicts.append( 

293 ConflictInfo( 

294 conflict_type=ConflictType.ELEMENT_MODIFIED_BOTH, 

295 page_uuid=page_uuid, 

296 element_uuid=elem_uuid, 

297 our_version=our_elem, 

298 their_version=their_elem, 

299 description=f"Element modified with same timestamp (possible conflict)", 

300 ) 

301 ) 

302 

303 # Note: If timestamps differ, we assume one version modified it and the other didn't. 

304 # The _merge_non_conflicting_changes method will automatically use the newer version. 

305 

306 def auto_resolve_conflicts(self, strategy: MergeStrategy = MergeStrategy.LATEST_WINS) -> Dict[int, str]: 

307 """ 

308 Automatically resolve conflicts based on a strategy. 

309 

310 Args: 

311 strategy: The resolution strategy to use 

312 

313 Returns: 

314 Dictionary mapping conflict index to resolution choice ("ours" or "theirs") 

315 """ 

316 resolutions = {} 

317 

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

319 if strategy == MergeStrategy.LATEST_WINS: 

320 # Compare timestamps 

321 our_modified = self._get_timestamp(conflict.our_version) 

322 their_modified = self._get_timestamp(conflict.their_version) 

323 

324 if our_modified and their_modified: 

325 resolutions[i] = "ours" if our_modified >= their_modified else "theirs" 

326 else: 

327 resolutions[i] = "ours" # Default to ours if timestamps missing 

328 

329 elif strategy == MergeStrategy.OURS: 

330 resolutions[i] = "ours" 

331 

332 elif strategy == MergeStrategy.THEIRS: 

333 resolutions[i] = "theirs" 

334 

335 # MANUAL strategy leaves resolutions empty 

336 

337 return resolutions 

338 

339 def _get_timestamp(self, version_data: Any) -> Optional[str]: 

340 """Extract timestamp from version data.""" 

341 if isinstance(version_data, dict): 

342 return version_data.get("last_modified") 

343 return None 

344 

345 def apply_resolutions( 

346 self, our_project_data: Dict[str, Any], their_project_data: Dict[str, Any], resolutions: Dict[int, str] 

347 ) -> Dict[str, Any]: 

348 """ 

349 Apply conflict resolutions to create merged project. 

350 

351 Args: 

352 our_project_data: Our version of the project 

353 their_project_data: Their version of the project 

354 resolutions: Dictionary mapping conflict index to choice ("ours" or "theirs") 

355 

356 Returns: 

357 Merged project data 

358 """ 

359 # Start with a copy of our project 

360 merged_data = copy.deepcopy(our_project_data) 

361 

362 # Apply resolutions 

363 for conflict_idx, choice in resolutions.items(): 

364 if conflict_idx >= len(self.conflicts): 

365 continue 

366 

367 conflict = self.conflicts[conflict_idx] 

368 

369 if choice == "theirs": 

370 # Apply their version 

371 self._apply_their_version(merged_data, conflict) 

372 # If choice is "ours", no need to do anything 

373 

374 # Add pages/elements from their version that we don't have 

375 self._merge_non_conflicting_changes(merged_data, their_project_data) 

376 

377 return merged_data 

378 

379 def _apply_their_version(self, merged_data: Dict[str, Any], conflict: ConflictInfo): 

380 """Apply their version for a specific conflict.""" 

381 if conflict.conflict_type == ConflictType.SETTINGS_MODIFIED_BOTH: 

382 # Update project setting 

383 for key, value in conflict.their_version.items(): 

384 if key != "last_modified": 

385 merged_data[key] = value 

386 

387 elif conflict.conflict_type in [ConflictType.PAGE_MODIFIED_BOTH, ConflictType.PAGE_DELETED_ONE]: 

388 # Replace entire page 

389 for i, page in enumerate(merged_data.get("pages", [])): 

390 if page.get("uuid") == conflict.page_uuid: 

391 merged_data["pages"][i] = conflict.their_version 

392 break 

393 

394 elif conflict.conflict_type in [ConflictType.ELEMENT_MODIFIED_BOTH, ConflictType.ELEMENT_DELETED_ONE]: 

395 # Replace element within page 

396 for page in merged_data.get("pages", []): 

397 if page.get("uuid") == conflict.page_uuid: 

398 layout = page.get("layout", {}) 

399 for i, elem in enumerate(layout.get("elements", [])): 

400 if elem.get("uuid") == conflict.element_uuid: 

401 layout["elements"][i] = conflict.their_version 

402 break 

403 break 

404 

405 def _merge_non_conflicting_changes(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]): 

406 """Add non-conflicting pages and elements from their version.""" 

407 self._add_missing_pages(merged_data, their_data) 

408 self._merge_page_elements(merged_data, their_data) 

409 

410 def _add_missing_pages(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]): 

411 """Add pages that exist only in their version.""" 

412 our_page_uuids = {page["uuid"] for page in merged_data.get("pages", [])} 

413 

414 for their_page in their_data.get("pages", []): 

415 if their_page["uuid"] not in our_page_uuids: 

416 merged_data["pages"].append(their_page) 

417 

418 def _merge_page_elements(self, merged_data: Dict[str, Any], their_data: Dict[str, Any]): 

419 """For pages that exist in both versions, merge their elements.""" 

420 their_pages = {page["uuid"]: page for page in their_data.get("pages", [])} 

421 

422 for our_page in merged_data.get("pages", []): 

423 their_page = their_pages.get(our_page["uuid"]) 

424 if not their_page: 

425 continue 

426 

427 our_elements = {elem["uuid"]: elem for elem in our_page.get("layout", {}).get("elements", [])} 

428 

429 for their_elem in their_page.get("layout", {}).get("elements", []): 

430 self._merge_element( 

431 our_page=our_page, page_uuid=our_page["uuid"], their_elem=their_elem, our_elements=our_elements 

432 ) 

433 

434 def _merge_element( 

435 self, our_page: Dict[str, Any], page_uuid: str, their_elem: Dict[str, Any], our_elements: Dict[str, Any] 

436 ): 

437 """Merge a single element from their version into our page.""" 

438 elem_uuid = their_elem["uuid"] 

439 

440 # Add new elements that we don't have 

441 if elem_uuid not in our_elements: 

442 our_page["layout"]["elements"].append(their_elem) 

443 return 

444 

445 # Element exists in both - check if already resolved as conflict 

446 if self._is_element_in_conflict(elem_uuid, page_uuid): 

447 return 

448 

449 # No conflict - use the more recently modified version 

450 self._merge_by_timestamp(our_page, elem_uuid, their_elem, our_elements[elem_uuid]) 

451 

452 def _is_element_in_conflict(self, elem_uuid: str, page_uuid: str) -> bool: 

453 """Check if element was part of a conflict that was already resolved.""" 

454 return any(c.element_uuid == elem_uuid and c.page_uuid == page_uuid for c in self.conflicts) 

455 

456 def _merge_by_timestamp( 

457 self, our_page: Dict[str, Any], elem_uuid: str, their_elem: Dict[str, Any], our_elem: Dict[str, Any] 

458 ): 

459 """Use the more recently modified version of an element.""" 

460 our_modified = our_elem.get("last_modified") 

461 their_modified = their_elem.get("last_modified") 

462 

463 # Their version is newer 

464 if not their_modified or (our_modified and their_modified <= our_modified): 

465 return 

466 

467 # Replace with their newer version 

468 for i, elem in enumerate(our_page["layout"]["elements"]): 

469 if elem["uuid"] == elem_uuid: 

470 our_page["layout"]["elements"][i] = their_elem 

471 break 

472 

473 

474def concatenate_projects(project_a_data: Dict[str, Any], project_b_data: Dict[str, Any]) -> Dict[str, Any]: 

475 """ 

476 Concatenate two projects with different project_ids. 

477 

478 This combines the pages from both projects into a single project. 

479 

480 Args: 

481 project_a_data: First project data 

482 project_b_data: Second project data 

483 

484 Returns: 

485 Combined project data 

486 """ 

487 # Start with project A as base 

488 merged_data = copy.deepcopy(project_a_data) 

489 

490 # Append all pages from project B 

491 merged_data["pages"].extend(copy.deepcopy(project_b_data.get("pages", []))) 

492 

493 # Update project name to indicate merge 

494 merged_data["name"] = f"{project_a_data.get('name', 'Untitled')} + {project_b_data.get('name', 'Untitled')}" 

495 

496 # Keep project A's ID and settings 

497 # Update last_modified to now 

498 merged_data["last_modified"] = datetime.now(timezone.utc).isoformat() 

499 

500 print( 

501 f"Concatenated projects: {len(project_a_data.get('pages', []))} + {len(project_b_data.get('pages', []))} = {len(merged_data['pages'])} pages" 

502 ) 

503 

504 return merged_data