Coverage for pyPhotoAlbum/mixins/operations/file_ops.py: 73%
521 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1"""
2File operations mixin for pyPhotoAlbum
3"""
5import os
6from typing import TYPE_CHECKING, Optional, cast
8from PyQt6.QtCore import QObject, pyqtSignal
9from 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)
26from pyPhotoAlbum.decorators import ribbon_action, numerical_input
27from pyPhotoAlbum.project import Project, Page
28from pyPhotoAlbum.async_project_loader import AsyncProjectLoader
29from pyPhotoAlbum.loading_widget import LoadingWidget
30from pyPhotoAlbum.project_serializer import save_to_zip, save_to_zip_async
31from pyPhotoAlbum.models import set_asset_resolution_context
32from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION
33from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog
36class _SaveBridge(QObject):
37 """Thread-safe signal bridge for async save callbacks.
39 Signals can be safely emitted from any thread; connected slots run on
40 the main (GUI) thread via Qt's queued connection.
41 """
43 progress = pyqtSignal(int, str)
44 finished = pyqtSignal(bool, str)
47class FileOperationsMixin:
48 """Mixin providing file-related operations"""
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 ...
55 def show_error(self, title: str, message: str) -> None:
56 """Expected from ApplicationStateMixin"""
57 ...
59 def resolve_asset_path(self, path: str) -> Optional[str]:
60 """Expected from asset path mixin"""
61 ...
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)
71 layout = QVBoxLayout()
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)
83 # Default page size group
84 size_group = QGroupBox("Default Page Size")
85 size_layout = QVBoxLayout()
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)
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)
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)
112 # Add common size presets
113 presets_layout = QHBoxLayout()
114 presets_layout.addWidget(QLabel("Presets:"))
116 def set_preset(w, h):
117 width_spinbox.setValue(w)
118 height_spinbox.setValue(h)
120 preset_a4 = QPushButton("A4 (210×297)")
121 preset_a4.clicked.connect(lambda: set_preset(210, 297))
122 presets_layout.addWidget(preset_a4)
124 preset_a5 = QPushButton("A5 (148×210)")
125 preset_a5.clicked.connect(lambda: set_preset(148, 210))
126 presets_layout.addWidget(preset_a5)
128 preset_square = QPushButton("Square (200×200)")
129 preset_square.clicked.connect(lambda: set_preset(200, 200))
130 presets_layout.addWidget(preset_square)
132 presets_layout.addStretch()
133 size_layout.addLayout(presets_layout)
135 size_group.setLayout(size_layout)
136 layout.addWidget(size_group)
138 # DPI settings group
139 dpi_group = QGroupBox("DPI Settings")
140 dpi_layout = QVBoxLayout()
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)
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)
160 dpi_group.setLayout(dpi_layout)
161 layout.addWidget(dpi_group)
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)
171 button_layout.addStretch()
172 button_layout.addWidget(cancel_btn)
173 button_layout.addWidget(create_btn)
174 layout.addLayout(button_layout)
176 dialog.setLayout(layout)
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()
187 # Cleanup old project if it exists
188 if hasattr(self, "project") and self.project:
189 self.project.cleanup()
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
197 # Set asset resolution context
198 set_asset_resolution_context(self.project.folder_path)
200 # Update view
201 self.update_view()
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")
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 )
216 if file_path:
217 print(f"Opening project: {file_path}")
219 # Create loading widget if not exists
220 if not hasattr(self, "_loading_widget"):
221 self._loading_widget = LoadingWidget(self)
223 # Show loading widget
224 self._loading_widget.show_loading("Opening project...")
226 # Create and configure async loader
227 self._project_loader = AsyncProjectLoader(file_path)
228 self._opening_file_path = file_path # Store for later
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)
235 # Start async loading
236 self._project_loader.start()
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)
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()
250 # Set new project
251 self.project = project
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()
259 self.gl_widget.current_page_index = 0 # Reset to first page
261 # Hide loading widget
262 if hasattr(self, "_loading_widget"):
263 self._loading_widget.hide_loading()
265 # Update view (this will trigger progressive image loading)
266 self.update_view()
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}")
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()
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)
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.
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
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
302 if not file_path:
303 file_path, _ = QFileDialog.getSaveFileName(
304 self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" # type: ignore[arg-type]
305 )
307 if not file_path:
308 return False
310 self._save_in_progress = True
311 print(f"Saving project to: {file_path}")
313 # Create loading widget if not exists
314 if not hasattr(self, "_loading_widget"):
315 self._loading_widget = LoadingWidget(self)
317 # Show loading widget
318 self._loading_widget.show_loading("Saving project...")
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]
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
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
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)
355 bridge.progress.connect(_on_progress)
356 bridge.finished.connect(_on_finished)
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 )
366 # Show immediate feedback
367 self.show_status("Saving project in background...", 2000)
368 return True
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()
376 # Update the view to reflect any changes
377 self.update_view()
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
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
399 def _show_missing_assets_warning(self, missing_assets: list):
400 """Show a warning about missing assets"""
401 from PyQt6.QtWidgets import QMessageBox
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"
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")
426 result = msg.exec()
427 if result == QMessageBox.StandardButton.Open:
428 self.heal_assets()
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)
441 layout = QVBoxLayout()
443 # Page size group
444 size_group = QGroupBox("Default Page Size")
445 size_layout = QVBoxLayout()
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)
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)
467 size_group.setLayout(size_layout)
468 layout.addWidget(size_group)
470 # DPI settings group
471 dpi_group = QGroupBox("DPI Settings")
472 dpi_layout = QVBoxLayout()
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)
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)
492 dpi_group.setLayout(dpi_layout)
493 layout.addWidget(dpi_group)
495 # Content scaling options (only if pages exist and size is changing)
496 scaling_group = None
497 scaling_buttons = None
499 scope_buttons = None
500 if self.project.pages:
501 scaling_group = QGroupBox("Apply to Existing Pages")
502 scaling_layout = QVBoxLayout()
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)
515 # Content scaling
516 scaling_layout.addWidget(QLabel("Content adjustment:"))
517 scaling_buttons = QButtonGroup()
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)
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)
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)
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)
540 scaling_group.setLayout(scaling_layout)
541 layout.addWidget(scaling_group)
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)
551 button_layout.addStretch()
552 button_layout.addWidget(cancel_btn)
553 button_layout.addWidget(ok_btn)
554 layout.addLayout(button_layout)
556 dialog.setLayout(layout)
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()
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
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
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)
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}")
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.
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
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
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
612 # Update page size
613 old_page_width, old_page_height = page.layout.size
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)
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
634 def _scale_page_elements(self, page, x_scale, y_scale):
635 """
636 Scale all elements on a page
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)
648 # Scale size
649 width, height = element.size
650 element.size = (width * x_scale, height * y_scale)
652 def _reposition_page_elements(self, page, old_size, new_size):
653 """
654 Reposition elements to center them on the new page size
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
664 x_offset = (new_width - old_width) / 2.0
665 y_offset = (new_height - old_height) / 2.0
667 for element in page.layout.elements:
668 x, y = element.position
669 element.position = (x + x_offset, y + y_offset)
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
679 # Show file save dialog
680 file_path, _ = QFileDialog.getSaveFileName(self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)")
682 if not file_path:
683 return
685 # Ensure .pdf extension
686 if not file_path.lower().endswith(".pdf"):
687 file_path += ".pdf"
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)
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
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"
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)
719 # Compute hashes for duplicate detection
720 self.project.asset_manager.compute_all_hashes()
721 progress.setValue(40)
723 if progress.wasCanceled():
724 return
726 # Get duplicate stats
727 dup_groups, dup_files, dup_bytes = self.project.asset_manager.get_duplicate_stats()
728 progress.setValue(60)
730 # Get unused stats
731 unused_files, unused_bytes = self.project.asset_manager.get_unused_stats()
732 progress.setValue(80)
734 progress.close()
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
743 # Build dialog with checkboxes for each cleanup type
744 dialog = QDialog(self)
745 dialog.setWindowTitle("Clean Assets")
746 dialog.setMinimumWidth(450)
748 layout = QVBoxLayout()
750 # Info label
751 info_label = QLabel("Select which cleanup operations to perform:")
752 layout.addWidget(info_label)
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)
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)
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)
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)
793 button_layout.addStretch()
794 button_layout.addWidget(cancel_btn)
795 button_layout.addWidget(clean_btn)
796 layout.addLayout(button_layout)
798 dialog.setLayout(layout)
800 if dialog.exec() != QDialog.DialogCode.Accepted:
801 return
803 # Perform selected cleanups
804 total_removed = 0
805 total_saved = 0
807 # Remove duplicates if selected
808 if dup_checkbox and dup_checkbox.isChecked():
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
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}")
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
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
833 if total_removed > 0:
834 # Mark project as dirty since we modified it
835 self.project.mark_dirty()
837 # Update view
838 self.update_view()
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 )
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")
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)
863 layout = QVBoxLayout()
865 # Application info
866 app_info = QLabel("<h2>pyPhotoAlbum</h2>")
867 app_info.setWordWrap(True)
868 layout.addWidget(app_info)
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)
877 # Version information
878 version_text = QTextEdit()
879 version_text.setReadOnly(True)
880 version_text.setPlainText(format_version_info())
881 layout.addWidget(version_text)
883 # Close button
884 close_button = QPushButton("Close")
885 close_button.clicked.connect(dialog.accept)
886 layout.addWidget(close_button)
888 dialog.setLayout(layout)
889 dialog.exec()