1 """

2 Merge manager for handling project merge conflicts

3

4 This 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

10 import copy

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

12 from enum import Enum

13 from dataclasses import dataclass

14 from datetime import datetime, timezone

15

16

17 class 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

33 class 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

43 class 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

54 class 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

474 def 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