1 """

2 File operations mixin for pyPhotoAlbum

3 """

4

5 import os

6 from typing import TYPE_CHECKING, Optional, cast

7

8 from PyQt6.QtCore import QObject, pyqtSignal

9 from PyQt6.QtWidgets import (

10 QFileDialog,

11 QDialog,

12 QVBoxLayout,

13 QHBoxLayout,

14 QLabel,

15 QDoubleSpinBox,

16 QSpinBox,

17 QPushButton,

18 QGroupBox,

19 QRadioButton,

20 QButtonGroup,

21 QLineEdit,

22 QTextEdit,

23 QWidget,

24 QMessageBox,

25 )

26 from pyPhotoAlbum.decorators import ribbon_action, numerical_input

27 from pyPhotoAlbum.project import Project, Page

28 from pyPhotoAlbum.async_project_loader import AsyncProjectLoader

29 from pyPhotoAlbum.loading_widget import LoadingWidget

30 from pyPhotoAlbum.project_serializer import save_to_zip, save_to_zip_async

31 from pyPhotoAlbum.models import set_asset_resolution_context

32 from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION

33 from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog

34

35

36 class _SaveBridge(QObject):

37 """Thread-safe signal bridge for async save callbacks.

38

39 Signals can be safely emitted from any thread; connected slots run on

40 the main (GUI) thread via Qt's queued connection.

41 """

42

43 progress = pyqtSignal(int, str)

44 finished = pyqtSignal(bool, str)

45

46

47 class FileOperationsMixin:

48 """Mixin providing file-related operations"""

49

50 # Type hints for expected attributes from mixing class

51 def show_status(self, message: str, timeout: int = 0) -> None:

52 """Expected from ApplicationStateMixin"""

53 ...

54

55 def show_error(self, title: str, message: str) -> None:

56 """Expected from ApplicationStateMixin"""

57 ...

58

59 def resolve_asset_path(self, path: str) -> Optional[str]:

60 """Expected from asset path mixin"""

61 ...

62

63 @ribbon_action(label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N")

64 def new_project(self):

65 """Create a new project with initial setup dialog"""

66 # Create new project setup dialog

67 dialog = QDialog(self)

68 dialog.setWindowTitle("New Project Setup")

69 dialog.setMinimumWidth(450)

70

71 layout = QVBoxLayout()

72

73 # Project name group

74 name_group = QGroupBox("Project Name")

75 name_layout = QVBoxLayout()

76 name_input = QLineEdit()

77 name_input.setText("New Project")

78 name_input.selectAll()

79 name_layout.addWidget(name_input)

80 name_group.setLayout(name_layout)

81 layout.addWidget(name_group)

82

83 # Default page size group

84 size_group = QGroupBox("Default Page Size")

85 size_layout = QVBoxLayout()

86

87 info_label = QLabel("This will be the default size for all new pages in this project.")

88 info_label.setWordWrap(True)

89 info_label.setStyleSheet("font-size: 9pt; color: gray;")

90 size_layout.addWidget(info_label)

91

92 # Width

93 width_layout = QHBoxLayout()

94 width_layout.addWidget(QLabel("Width:"))

95 width_spinbox = QDoubleSpinBox()

96 width_spinbox.setRange(10, 1000)

97 width_spinbox.setValue(140) # Default 14cm

98 width_spinbox.setSuffix(" mm")

99 width_layout.addWidget(width_spinbox)

100 size_layout.addLayout(width_layout)

101

102 # Height

103 height_layout = QHBoxLayout()

104 height_layout.addWidget(QLabel("Height:"))

105 height_spinbox = QDoubleSpinBox()

106 height_spinbox.setRange(10, 1000)

107 height_spinbox.setValue(140) # Default 14cm

108 height_spinbox.setSuffix(" mm")

109 height_layout.addWidget(height_spinbox)

110 size_layout.addLayout(height_layout)

111

112 # Add common size presets

113 presets_layout = QHBoxLayout()

114 presets_layout.addWidget(QLabel("Presets:"))

115

116 def set_preset(w, h):

117 width_spinbox.setValue(w)

118 height_spinbox.setValue(h)

119

120 preset_a4 = QPushButton("A4 (210×297)")

121 preset_a4.clicked.connect(lambda: set_preset(210, 297))

122 presets_layout.addWidget(preset_a4)

123

124 preset_a5 = QPushButton("A5 (148×210)")

125 preset_a5.clicked.connect(lambda: set_preset(148, 210))

126 presets_layout.addWidget(preset_a5)

127

128 preset_square = QPushButton("Square (200×200)")

129 preset_square.clicked.connect(lambda: set_preset(200, 200))

130 presets_layout.addWidget(preset_square)

131

132 presets_layout.addStretch()

133 size_layout.addLayout(presets_layout)

134

135 size_group.setLayout(size_layout)

136 layout.addWidget(size_group)

137

138 # DPI settings group

139 dpi_group = QGroupBox("DPI Settings")

140 dpi_layout = QVBoxLayout()

141

142 # Working DPI

143 working_dpi_layout = QHBoxLayout()

144 working_dpi_layout.addWidget(QLabel("Working DPI:"))

145 working_dpi_spinbox = QSpinBox()

146 working_dpi_spinbox.setRange(72, 1200)

147 working_dpi_spinbox.setValue(300)

148 working_dpi_layout.addWidget(working_dpi_spinbox)

149 dpi_layout.addLayout(working_dpi_layout)

150

151 # Export DPI

152 export_dpi_layout = QHBoxLayout()

153 export_dpi_layout.addWidget(QLabel("Export DPI:"))

154 export_dpi_spinbox = QSpinBox()

155 export_dpi_spinbox.setRange(72, 1200)

156 export_dpi_spinbox.setValue(300)

157 export_dpi_layout.addWidget(export_dpi_spinbox)

158 dpi_layout.addLayout(export_dpi_layout)

159

160 dpi_group.setLayout(dpi_layout)

161 layout.addWidget(dpi_group)

162

163 # Buttons

164 button_layout = QHBoxLayout()

165 cancel_btn = QPushButton("Cancel")

166 cancel_btn.clicked.connect(dialog.reject)

167 create_btn = QPushButton("Create Project")

168 create_btn.clicked.connect(dialog.accept)

169 create_btn.setDefault(True)

170

171 button_layout.addStretch()

172 button_layout.addWidget(cancel_btn)

173 button_layout.addWidget(create_btn)

174 layout.addLayout(button_layout)

175

176 dialog.setLayout(layout)

177

178 # Show dialog

179 if dialog.exec() == QDialog.DialogCode.Accepted:

180 # Get values

181 project_name = name_input.text().strip() or "New Project"

182 width_mm = width_spinbox.value()

183 height_mm = height_spinbox.value()

184 working_dpi = working_dpi_spinbox.value()

185 export_dpi = export_dpi_spinbox.value()

186

187 # Cleanup old project if it exists

188 if hasattr(self, "project") and self.project:

189 self.project.cleanup()

190

191 # Create project with custom settings

192 self.project = Project(project_name)

193 self.project.page_size_mm = (width_mm, height_mm)

194 self.project.working_dpi = working_dpi

195 self.project.export_dpi = export_dpi

196

197 # Set asset resolution context

198 set_asset_resolution_context(self.project.folder_path)

199

200 # Update view

201 self.update_view()

202

203 self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)")

204 print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm")

205 else:

206 # User cancelled - keep current project

207 print("New project creation cancelled")

208

209 @ribbon_action(label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O")

210 def open_project(self):

211 """Open an existing project with async loading and progress bar"""

212 file_path, _ = QFileDialog.getOpenFileName(

213 self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)"

214 )

215

216 if file_path:

217 print(f"Opening project: {file_path}")

218

219 # Create loading widget if not exists

220 if not hasattr(self, "_loading_widget"):

221 self._loading_widget = LoadingWidget(self)

222

223 # Show loading widget

224 self._loading_widget.show_loading("Opening project...")

225

226 # Create and configure async loader

227 self._project_loader = AsyncProjectLoader(file_path)

228 self._opening_file_path = file_path # Store for later

229

230 # Connect signals

231 self._project_loader.progress_updated.connect(self._on_load_progress)

232 self._project_loader.load_complete.connect(self._on_load_complete)

233 self._project_loader.load_failed.connect(self._on_load_failed)

234

235 # Start async loading

236 self._project_loader.start()

237

238 def _on_load_progress(self, current: int, total: int, message: str):

239 """Handle loading progress updates"""

240 if hasattr(self, "_loading_widget"):

241 self._loading_widget.set_progress(current, total)

242 self._loading_widget.set_status(message)

243

244 def _on_load_complete(self, project):

245 """Handle successful project load"""

246 # Cleanup old project if it exists

247 if hasattr(self, "project") and self.project:

248 self.project.cleanup()

249

250 # Set new project

251 self.project = project

252

253 # Set file path and mark as clean

254 if hasattr(self, "_opening_file_path"):

255 self.project.file_path = self._opening_file_path

256 delattr(self, "_opening_file_path")

257 self.project.mark_clean()

258

259 self.gl_widget.current_page_index = 0 # Reset to first page

260

261 # Hide loading widget

262 if hasattr(self, "_loading_widget"):

263 self._loading_widget.hide_loading()

264

265 # Update view (this will trigger progressive image loading)

266 self.update_view()

267

268 # Check for missing assets and inform user

269 missing_assets = self._check_missing_assets()

270 if missing_assets:

271 self._show_missing_assets_warning(missing_assets)

272 self.show_status(f"Project opened: {project.name} ({len(missing_assets)} missing images)")

273 else:

274 self.show_status(f"Project opened: {project.name}")

275 print(f"Successfully loaded project: {project.name}")

276

277 def _on_load_failed(self, error_msg: str):

278 """Handle project load failure"""

279 # Hide loading widget

280 if hasattr(self, "_loading_widget"):

281 self._loading_widget.hide_loading()

282

283 error_msg = f"Failed to open project: {error_msg}"

284 self.show_status(error_msg)

285 self.show_error("Load Failed", error_msg)

286 print(error_msg)

287

288 @ribbon_action(label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S")

289 def save_project(self) -> bool:

290 """Save the current project asynchronously with progress feedback.

291

292 Returns True if an async save was started, False if the user cancelled.

293 """

294 # Prevent concurrent saves — only one background save at a time

295 if getattr(self, "_save_in_progress", False):

296 self.show_status("Save already in progress...")

297 return False

298

299 # If project has a file path, use it; otherwise prompt for location

300 file_path = self.project.file_path if hasattr(self.project, "file_path") and self.project.file_path else None

301

302 if not file_path:

303 file_path, _ = QFileDialog.getSaveFileName(

304 self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type]

305 )

306

307 if not file_path:

308 return False

309

310 self._save_in_progress = True

311 print(f"Saving project to: {file_path}")

312

313 # Create loading widget if not exists

314 if not hasattr(self, "_loading_widget"):

315 self._loading_widget = LoadingWidget(self)

316

317 # Show loading widget

318 self._loading_widget.show_loading("Saving project...")

319

320 # Bridge object: signals are thread-safe so background thread can

321 # emit them and slots always run on the main (GUI) thread.

322 bridge = _SaveBridge(parent=self) # type: ignore[arg-type]

323

324 def _on_progress(progress: int, message: str):

325 if hasattr(self, "_loading_widget"):

326 try:

327 self._loading_widget.set_progress(progress, 100)

328 self._loading_widget.set_status(message)

329 except RuntimeError:

330 pass

331

332 def _on_finished(success: bool, error: str):

333 self._save_in_progress = False

334 try:

335 if hasattr(self, "_loading_widget"):

336 self._loading_widget.hide_loading()

337 except RuntimeError:

338 pass

339

340 if success:

341 self.project.file_path = file_path

342 self.project.mark_clean()

343 self.show_status(f"Project saved: {file_path}")

344 print(f"Successfully saved project to: {file_path}")

345 if getattr(self, "_pending_close", False):

346 self._pending_close = False

347 self.close() # type: ignore[attr-defined]

348 else:

349 self._pending_close = False

350 error_msg = f"Failed to save project: {error}"

351 self.show_status(error_msg)

352 self.show_error("Save Failed", error_msg)

353 print(error_msg)

354

355 bridge.progress.connect(_on_progress)

356 bridge.finished.connect(_on_finished)

357

358 # Start async save — callbacks emit signals (thread-safe)

359 save_to_zip_async(

360 self.project,

361 file_path,

362 on_complete=lambda ok, err: bridge.finished.emit(ok, err or ""),

363 on_progress=lambda p, m: bridge.progress.emit(p, m),

364 )

365

366 # Show immediate feedback

367 self.show_status("Saving project in background...", 2000)

368 return True

369

370 @ribbon_action(label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File")

371 def heal_assets(self):

372 """Open the asset healing dialog to reconnect missing images"""

373 dialog = AssetHealDialog(self.project, self)

374 dialog.exec()

375

376 # Update the view to reflect any changes

377 self.update_view()

378

379 def _check_missing_assets(self) -> list:

380 """Check for missing assets in the project - returns list of missing paths"""

381 from pyPhotoAlbum.models import ImageData

382

383 missing = []

384 for page in self.project.pages:

385 for element in page.layout.elements:

386 if isinstance(element, ImageData) and element.image_path:

387 # Absolute paths need healing

388 if os.path.isabs(element.image_path):

389 missing.append(element.image_path)

390 # Paths not in assets/ need healing

391 elif not element.image_path.startswith("assets/"):

392 missing.append(element.image_path)

393 else:

394 # Check if file exists in assets using mixin

395 if not self.resolve_asset_path(element.image_path):

396 missing.append(element.image_path)

397 return list(set(missing)) # Remove duplicates

398

399 def _show_missing_assets_warning(self, missing_assets: list):

400 """Show a warning about missing assets"""

401 from PyQt6.QtWidgets import QMessageBox

402

403 # Build message with list of missing images

404 if len(missing_assets) <= 5:

405 asset_list = "\n".join(f" • {path}" for path in missing_assets)

406 else:

407 asset_list = "\n".join(f" • {path}" for path in missing_assets[:5])

408 asset_list += f"\n ... and {len(missing_assets) - 5} more"

409

410 msg = QMessageBox(cast(QWidget, self))

411 msg.setIcon(QMessageBox.Icon.Warning)

412 msg.setWindowTitle("Missing Assets")

413 msg.setText(f"{len(missing_assets)} image(s) could not be found in the assets folder:")

414 msg.setInformativeText(asset_list)

415 msg.setDetailedText(

416 "These images need to be reconnected using the 'Heal Assets' feature.\n\n"

417 "Go to: Home → Heal Assets\n\n"

418 "Add search paths where the original images might be located, "

419 "then click 'Attempt Healing' to find and import them."

420 )

421 msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)

422 btn = msg.button(QMessageBox.StandardButton.Open)

423 if btn is not None:

424 btn.setText("Open Heal Assets")

425

426 result = msg.exec()

427 if result == QMessageBox.StandardButton.Open:

428 self.heal_assets()

429

430 @ribbon_action(

431 label="Project Settings", tooltip="Configure project-wide page size and defaults", tab="Home", group="File"

432 )

433 @numerical_input(fields=[("width", "Width", "mm", 10, 1000), ("height", "Height", "mm", 10, 1000)])

434 def project_settings(self):

435 """Configure project-wide settings including default page size"""

436 # Create dialog

437 dialog = QDialog(self)

438 dialog.setWindowTitle("Project Settings")

439 dialog.setMinimumWidth(500)

440

441 layout = QVBoxLayout()

442

443 # Page size group

444 size_group = QGroupBox("Default Page Size")

445 size_layout = QVBoxLayout()

446

447 # Width

448 width_layout = QHBoxLayout()

449 width_layout.addWidget(QLabel("Width:"))

450 width_spinbox = QDoubleSpinBox()

451 width_spinbox.setRange(10, 1000)

452 width_spinbox.setValue(self.project.page_size_mm[0])

453 width_spinbox.setSuffix(" mm")

454 width_layout.addWidget(width_spinbox)

455 size_layout.addLayout(width_layout)

456

457 # Height

458 height_layout = QHBoxLayout()

459 height_layout.addWidget(QLabel("Height:"))

460 height_spinbox = QDoubleSpinBox()

461 height_spinbox.setRange(10, 1000)

462 height_spinbox.setValue(self.project.page_size_mm[1])

463 height_spinbox.setSuffix(" mm")

464 height_layout.addWidget(height_spinbox)

465 size_layout.addLayout(height_layout)

466

467 size_group.setLayout(size_layout)

468 layout.addWidget(size_group)

469

470 # DPI settings group

471 dpi_group = QGroupBox("DPI Settings")

472 dpi_layout = QVBoxLayout()

473

474 # Working DPI

475 working_dpi_layout = QHBoxLayout()

476 working_dpi_layout.addWidget(QLabel("Working DPI:"))

477 working_dpi_spinbox = QSpinBox()

478 working_dpi_spinbox.setRange(72, 1200)

479 working_dpi_spinbox.setValue(self.project.working_dpi)

480 working_dpi_layout.addWidget(working_dpi_spinbox)

481 dpi_layout.addLayout(working_dpi_layout)

482

483 # Export DPI

484 export_dpi_layout = QHBoxLayout()

485 export_dpi_layout.addWidget(QLabel("Export DPI:"))

486 export_dpi_spinbox = QSpinBox()

487 export_dpi_spinbox.setRange(72, 1200)

488 export_dpi_spinbox.setValue(self.project.export_dpi)

489 export_dpi_layout.addWidget(export_dpi_spinbox)

490 dpi_layout.addLayout(export_dpi_layout)

491

492 dpi_group.setLayout(dpi_layout)

493 layout.addWidget(dpi_group)

494

495 # Content scaling options (only if pages exist and size is changing)

496 scaling_group = None

497 scaling_buttons = None

498

499 scope_buttons = None

500 if self.project.pages:

501 scaling_group = QGroupBox("Apply to Existing Pages")

502 scaling_layout = QVBoxLayout()

503

504 # Scope: which pages to update

505 scaling_layout.addWidget(QLabel("Pages to update:"))

506 scope_buttons = QButtonGroup()

507 scope_non_manual = QRadioButton("Non-manual pages only")

508 scope_non_manual.setChecked(True)

509 scope_all = QRadioButton("All pages (override manual sizing)")

510 scope_buttons.addButton(scope_non_manual, 0)

511 scope_buttons.addButton(scope_all, 1)

512 scaling_layout.addWidget(scope_non_manual)

513 scaling_layout.addWidget(scope_all)

514

515 # Content scaling

516 scaling_layout.addWidget(QLabel("Content adjustment:"))

517 scaling_buttons = QButtonGroup()

518

519 proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)")

520 proportional_radio.setToolTip("Scale content uniformly to fit the new page size")

521 scaling_buttons.addButton(proportional_radio, 0)

522 scaling_layout.addWidget(proportional_radio)

523

524 stretch_radio = QRadioButton("Resize on both axes (stretch)")

525 stretch_radio.setToolTip("Scale width and height independently")

526 scaling_buttons.addButton(stretch_radio, 1)

527 scaling_layout.addWidget(stretch_radio)

528

529 reposition_radio = QRadioButton("Keep content size, reposition to center")

530 reposition_radio.setToolTip("Maintain element sizes but center them on new page")

531 scaling_buttons.addButton(reposition_radio, 2)

532 scaling_layout.addWidget(reposition_radio)

533

534 none_radio = QRadioButton("Don't adjust content (page size only)")

535 none_radio.setToolTip("Only change page size, leave content as-is")

536 none_radio.setChecked(True) # Default

537 scaling_buttons.addButton(none_radio, 3)

538 scaling_layout.addWidget(none_radio)

539

540 scaling_group.setLayout(scaling_layout)

541 layout.addWidget(scaling_group)

542

543 # Buttons

544 button_layout = QHBoxLayout()

545 cancel_btn = QPushButton("Cancel")

546 cancel_btn.clicked.connect(dialog.reject)

547 ok_btn = QPushButton("OK")

548 ok_btn.clicked.connect(dialog.accept)

549 ok_btn.setDefault(True)

550

551 button_layout.addStretch()

552 button_layout.addWidget(cancel_btn)

553 button_layout.addWidget(ok_btn)

554 layout.addLayout(button_layout)

555

556 dialog.setLayout(layout)

557

558 # Show dialog

559 if dialog.exec() == QDialog.DialogCode.Accepted:

560 # Get new values

561 new_width = width_spinbox.value()

562 new_height = height_spinbox.value()

563 new_working_dpi = working_dpi_spinbox.value()

564 new_export_dpi = export_dpi_spinbox.value()

565

566 # Determine scaling mode and scope

567 scaling_mode = "none"

568 include_manual = False

569 if scaling_buttons:

570 selected_id = scaling_buttons.checkedId()

571 modes = {0: "proportional", 1: "stretch", 2: "reposition", 3: "none"}

572 scaling_mode = modes.get(selected_id, "none")

573 if scope_buttons:

574 include_manual = scope_buttons.checkedId() == 1

575

576 # Apply settings

577 old_size = self.project.page_size_mm

578 self.project.page_size_mm = (new_width, new_height)

579 self.project.working_dpi = new_working_dpi

580 self.project.export_dpi = new_export_dpi

581

582 # Update existing pages

583 if self.project.pages and old_size != (new_width, new_height):

584 self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode, include_manual)

585

586 self.update_view()

587 self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000)

588 print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}")

589

590 def _apply_page_size_to_project(self, old_size, new_size, scaling_mode, include_manual=False):

591 """

592 Apply new page size to existing pages.

593

594 Args:

595 old_size: Old page size (width, height) in mm

596 new_size: New page size (width, height) in mm

597 scaling_mode: 'proportional', 'stretch', 'reposition', or 'none'

598 include_manual: If True, also resize manually-sized pages

599 """

600 old_width, old_height = old_size

601 new_width, new_height = new_size

602

603 width_ratio = new_width / old_width if old_width > 0 else 1.0

604 height_ratio = new_height / old_height if old_height > 0 else 1.0

605

606 for page in self.project.pages:

607 if page.is_cover:

608 continue

609 if page.manually_sized and not include_manual:

610 continue

611

612 # Update page size

613 old_page_width, old_page_height = page.layout.size

614

615 # For double spreads, maintain the 2x multiplier

616 if page.is_double_spread:

617 page.layout.size = (new_width * 2, new_height)

618 else:

619 page.layout.size = (new_width, new_height)

620

621 # Apply content scaling based on mode

622 if scaling_mode == "proportional":

623 # Use smallest ratio to fit content

624 scale = min(width_ratio, height_ratio)

625 self._scale_page_elements(page, scale, scale)

626 elif scaling_mode == "stretch":

627 # Scale independently on each axis

628 self._scale_page_elements(page, width_ratio, height_ratio)

629 elif scaling_mode == "reposition":

630 # Keep size, center content

631 self._reposition_page_elements(page, old_size, new_size)

632 # 'none' - do nothing to elements

633

634 def _scale_page_elements(self, page, x_scale, y_scale):

635 """

636 Scale all elements on a page

637

638 Args:

639 page: Page object

640 x_scale: Horizontal scale factor

641 y_scale: Vertical scale factor

642 """

643 for element in page.layout.elements:

644 # Scale position

645 x, y = element.position

646 element.position = (x * x_scale, y * y_scale)

647

648 # Scale size

649 width, height = element.size

650 element.size = (width * x_scale, height * y_scale)

651

652 def _reposition_page_elements(self, page, old_size, new_size):

653 """

654 Reposition elements to center them on the new page size

655

656 Args:

657 page: Page object

658 old_size: Old page size (width, height) in mm

659 new_size: New page size (width, height) in mm

660 """

661 old_width, old_height = old_size

662 new_width, new_height = new_size

663

664 x_offset = (new_width - old_width) / 2.0

665 y_offset = (new_height - old_height) / 2.0

666

667 for element in page.layout.elements:

668 x, y = element.position

669 element.position = (x + x_offset, y + y_offset)

670

671 @ribbon_action(label="Export PDF", tooltip="Export project to PDF", tab="Home", group="File")

672 def export_pdf(self):

673 """Export project to PDF using async backend (non-blocking)"""

674 # Check if we have pages to export

675 if not self.project or not self.project.pages:

676 self.show_status("No pages to export")

677 return

678

679 # Show file save dialog

680 file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)")

681

682 if not file_path:

683 return

684

685 # Ensure .pdf extension

686 if not file_path.lower().endswith(".pdf"):

687 file_path += ".pdf"

688

689 # Use async PDF export (non-blocking, UI stays responsive)

690 success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=self.project.export_dpi)

691 if success:

692 self.show_status("PDF export started...", 2000)

693 else:

694 self.show_status("PDF export failed to start", 3000)

695

696 @ribbon_action(

697 label="Clean Assets", tooltip="Find and remove duplicate or unused image files", tab="Home", group="File"

698 )

699 def clean_assets(self):

700 """Find and remove duplicate and unused asset files to save space"""

701 from PyQt6.QtWidgets import QProgressDialog, QCheckBox

702 from PyQt6.QtCore import Qt

703

704 # Helper to format bytes

705 def format_bytes(num_bytes):

706 if num_bytes >= 1024 * 1024:

707 return f"{num_bytes / (1024 * 1024):.1f} MB"

708 elif num_bytes >= 1024:

709 return f"{num_bytes / 1024:.1f} KB"

710 else:

711 return f"{num_bytes} bytes"

712

713 # Scan for issues with progress dialog

714 progress = QProgressDialog("Scanning assets...", "Cancel", 0, 100, self)

715 progress.setWindowTitle("Clean Assets")

716 progress.setWindowModality(Qt.WindowModality.WindowModal)

717 progress.setValue(10)

718

719 # Compute hashes for duplicate detection

720 self.project.asset_manager.compute_all_hashes()

721 progress.setValue(40)

722

723 if progress.wasCanceled():

724 return

725

726 # Get duplicate stats

727 dup_groups, dup_files, dup_bytes = self.project.asset_manager.get_duplicate_stats()

728 progress.setValue(60)

729

730 # Get unused stats

731 unused_files, unused_bytes = self.project.asset_manager.get_unused_stats()

732 progress.setValue(80)

733

734 progress.close()

735

736 # Check if there's anything to clean

737 if dup_files == 0 and unused_files == 0:

738 QMessageBox.information(

739 self, "Assets Clean", "No duplicate or unused files were found in your project assets."

740 )

741 return

742

743 # Build dialog with checkboxes for each cleanup type

744 dialog = QDialog(self)

745 dialog.setWindowTitle("Clean Assets")

746 dialog.setMinimumWidth(450)

747

748 layout = QVBoxLayout()

749

750 # Info label

751 info_label = QLabel("Select which cleanup operations to perform:")

752 layout.addWidget(info_label)

753

754 # Duplicates checkbox

755 dup_checkbox = None

756 if dup_files > 0:

757 dup_checkbox = QCheckBox(

758 f"Remove {dup_files} duplicate file(s) in {dup_groups} group(s) " f"(saves {format_bytes(dup_bytes)})"

759 )

760 dup_checkbox.setChecked(True)

761 dup_checkbox.setToolTip(

762 "Duplicate files have identical content but different names.\n"

763 "Image references will be automatically updated to use the kept file."

764 )

765 layout.addWidget(dup_checkbox)

766

767 # Unused checkbox

768 unused_checkbox = None

769 if unused_files > 0:

770 unused_checkbox = QCheckBox(f"Remove {unused_files} unused file(s) (saves {format_bytes(unused_bytes)})")

771 unused_checkbox.setChecked(True)

772 unused_checkbox.setToolTip(

773 "Unused files exist in the assets folder but are not referenced\n"

774 "by any image element in your project."

775 )

776 layout.addWidget(unused_checkbox)

777

778 # Summary

779 total_files = dup_files + unused_files

780 total_bytes = dup_bytes + unused_bytes

781 summary_label = QLabel(f"\nTotal potential savings: {format_bytes(total_bytes)} from {total_files} file(s)")

782 summary_label.setStyleSheet("font-weight: bold;")

783 layout.addWidget(summary_label)

784

785 # Buttons

786 button_layout = QHBoxLayout()

787 cancel_btn = QPushButton("Cancel")

788 cancel_btn.clicked.connect(dialog.reject)

789 clean_btn = QPushButton("Clean Selected")

790 clean_btn.clicked.connect(dialog.accept)

791 clean_btn.setDefault(True)

792

793 button_layout.addStretch()

794 button_layout.addWidget(cancel_btn)

795 button_layout.addWidget(clean_btn)

796 layout.addLayout(button_layout)

797

798 dialog.setLayout(layout)

799

800 if dialog.exec() != QDialog.DialogCode.Accepted:

801 return

802

803 # Perform selected cleanups

804 total_removed = 0

805 total_saved = 0

806

807 # Remove duplicates if selected

808 if dup_checkbox and dup_checkbox.isChecked():

809

810 def update_image_references(old_path: str, new_path: str):

811 """Update all ImageData elements that reference the old path"""

812 from pyPhotoAlbum.models import ImageData

813

814 for page in self.project.pages:

815 for element in page.layout.elements:

816 if isinstance(element, ImageData) and element.image_path == old_path:

817 element.image_path = new_path

818 element.mark_modified()

819 print(f"Updated image reference: {old_path} -> {new_path}")

820

821 removed, saved = self.project.asset_manager.deduplicate_assets(

822 update_references_callback=update_image_references

823 )

824 total_removed += removed

825 total_saved += saved

826

827 # Remove unused if selected

828 if unused_checkbox and unused_checkbox.isChecked():

829 removed, saved = self.project.asset_manager.remove_unused_assets()

830 total_removed += removed

831 total_saved += saved

832

833 if total_removed > 0:

834 # Mark project as dirty since we modified it

835 self.project.mark_dirty()

836

837 # Update view

838 self.update_view()

839

840 # Show result

841 QMessageBox.information(

842 self,

843 "Cleanup Complete",

844 f"Removed {total_removed} file(s).\n\n"

845 f"Saved {format_bytes(total_saved)} of disk space.\n\n"

846 f"Remember to save your project to preserve these changes.",

847 )

848

849 self.show_status(

850 f"Asset cleanup complete: removed {total_removed} files, saved {format_bytes(total_saved)}"

851 )

852 else:

853 self.show_status("No files were removed")

854

855 @ribbon_action(label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File")

856 def show_about(self):

857 """Show about dialog with version information"""

858 dialog = QDialog(self)

859 dialog.setWindowTitle("About pyPhotoAlbum")

860 dialog.setMinimumWidth(600)

861 dialog.setMinimumHeight(400)

862

863 layout = QVBoxLayout()

864

865 # Application info

866 app_info = QLabel("<h2>pyPhotoAlbum</h2>")

867 app_info.setWordWrap(True)

868 layout.addWidget(app_info)

869

870 description = QLabel(

871 "A photo album layout and design application with advanced "

872 "page composition features and PDF export capabilities."

873 )

874 description.setWordWrap(True)

875 layout.addWidget(description)

876

877 # Version information

878 version_text = QTextEdit()

879 version_text.setReadOnly(True)

880 version_text.setPlainText(format_version_info())

881 layout.addWidget(version_text)

882

883 # Close button

884 close_button = QPushButton("Close")

885 close_button.clicked.connect(dialog.accept)

886 layout.addWidget(close_button)

887

888 dialog.setLayout(layout)

889 dialog.exec()