Coverage for pyPhotoAlbum/asset_heal_dialog.py: 100%
154 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"""
2Asset healing dialog for reconnecting missing images
3"""
5import os
6import shutil
7from typing import List, Dict, Set
8from PyQt6.QtWidgets import (
9 QDialog,
10 QVBoxLayout,
11 QHBoxLayout,
12 QLabel,
13 QPushButton,
14 QListWidget,
15 QListWidgetItem,
16 QFileDialog,
17 QGroupBox,
18 QMessageBox,
19)
20from PyQt6.QtCore import Qt
23class AssetHealDialog(QDialog):
24 """Dialog for healing missing asset paths"""
26 def __init__(self, project, parent=None):
27 super().__init__(parent)
28 self.project = project
29 self.search_paths: List[str] = []
30 self.missing_assets: Set[str] = set()
32 self.setWindowTitle("Heal Missing Assets")
33 self.resize(600, 500)
35 self._init_ui()
36 self._scan_missing_assets()
38 def _init_ui(self):
39 """Initialize the UI"""
40 layout = QVBoxLayout()
42 # Missing assets group
43 missing_group = QGroupBox("Missing Assets")
44 missing_layout = QVBoxLayout()
46 self.missing_list = QListWidget()
47 missing_layout.addWidget(self.missing_list)
49 missing_group.setLayout(missing_layout)
50 layout.addWidget(missing_group)
52 # Search paths group
53 search_group = QGroupBox("Search Paths")
54 search_layout = QVBoxLayout()
56 self.search_list = QListWidget()
57 search_layout.addWidget(self.search_list)
59 # Add/Remove buttons
60 button_layout = QHBoxLayout()
61 add_path_btn = QPushButton("Add Search Path...")
62 add_path_btn.clicked.connect(self._add_search_path)
63 button_layout.addWidget(add_path_btn)
65 remove_path_btn = QPushButton("Remove Selected")
66 remove_path_btn.clicked.connect(self._remove_search_path)
67 button_layout.addWidget(remove_path_btn)
69 search_layout.addLayout(button_layout)
70 search_group.setLayout(search_layout)
71 layout.addWidget(search_group)
73 # Action buttons
74 action_layout = QHBoxLayout()
76 heal_btn = QPushButton("Attempt Healing")
77 heal_btn.clicked.connect(self._attempt_healing)
78 action_layout.addWidget(heal_btn)
80 close_btn = QPushButton("Close")
81 close_btn.clicked.connect(self.accept)
82 action_layout.addWidget(close_btn)
84 layout.addLayout(action_layout)
86 self.setLayout(layout)
88 def _scan_missing_assets(self):
89 """Scan project for missing assets - only assets in project's assets folder are valid"""
90 from pyPhotoAlbum.models import ImageData
92 self.missing_assets.clear()
93 self.missing_list.clear()
95 # Check all pages for images that need healing
96 # Images MUST be in the project's assets folder - absolute paths or external paths need healing
97 for page in self.project.pages:
98 for element in page.layout.elements:
99 if isinstance(element, ImageData) and element.image_path:
100 needs_healing = False
101 reason = ""
103 # Absolute paths need healing (should be relative to assets/)
104 if os.path.isabs(element.image_path):
105 needs_healing = True
106 reason = "absolute path"
107 # Paths not starting with assets/ need healing
108 elif not element.image_path.startswith("assets/"):
109 needs_healing = True
110 reason = "not in assets folder"
111 else:
112 # Relative path in assets/ - check if file exists
113 full_path = os.path.join(self.project.folder_path, element.image_path)
114 if not os.path.exists(full_path):
115 needs_healing = True
116 reason = "file missing"
118 if needs_healing:
119 self.missing_assets.add(element.image_path)
120 print(f"Asset needs healing: {element.image_path} ({reason})")
122 # Display missing assets
123 if self.missing_assets:
124 for asset in sorted(self.missing_assets):
125 self.missing_list.addItem(asset)
126 else:
127 item = QListWidgetItem("No missing assets found!")
128 item.setForeground(Qt.GlobalColor.darkGreen)
129 self.missing_list.addItem(item)
131 def _add_search_path(self):
132 """Add a search path"""
133 directory = QFileDialog.getExistingDirectory(
134 self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
135 )
137 if directory:
138 if directory not in self.search_paths:
139 self.search_paths.append(directory)
140 self.search_list.addItem(directory)
142 def _remove_search_path(self):
143 """Remove selected search path"""
144 current_row = self.search_list.currentRow()
145 if current_row >= 0:
146 self.search_paths.pop(current_row)
147 self.search_list.takeItem(current_row)
149 def _attempt_healing(self):
150 """Attempt to heal missing assets by resolving stored paths and using search paths"""
151 from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
153 healed_count = 0
154 imported_count = 0
155 still_missing = []
157 # Update asset resolution context with search paths (for rendering after heal)
158 set_asset_resolution_context(self.project.folder_path, self.search_paths)
160 # Build mapping of missing paths to elements
161 path_to_elements: Dict[str, List] = {}
162 for page in self.project.pages:
163 for element in page.layout.elements:
164 if isinstance(element, ImageData) and element.image_path:
165 if element.image_path in self.missing_assets:
166 if element.image_path not in path_to_elements:
167 path_to_elements[element.image_path] = []
168 path_to_elements[element.image_path].append(element)
170 # Try to find and import each missing asset
171 for asset_path in self.missing_assets:
172 found_path = None
173 filename = os.path.basename(asset_path)
175 # FIRST: Try to resolve the stored path directly from project folder
176 # This handles paths like "../../home/user/Photos/image.jpg"
177 if not os.path.isabs(asset_path):
178 resolved = os.path.normpath(os.path.join(self.project.folder_path, asset_path))
179 if os.path.exists(resolved):
180 found_path = resolved
181 print(f"Resolved relative path: {asset_path} → {resolved}")
183 # SECOND: If it's an absolute path, check if it exists directly
184 if not found_path and os.path.isabs(asset_path):
185 if os.path.exists(asset_path):
186 found_path = asset_path
187 print(f"Found at absolute path: {asset_path}")
189 # THIRD: Search in user-provided search paths
190 if not found_path:
191 for search_path in self.search_paths:
192 # Try direct match by filename
193 candidate = os.path.join(search_path, filename)
194 if os.path.exists(candidate):
195 found_path = candidate
196 break
198 # Try with same relative path structure
199 candidate = os.path.join(search_path, asset_path)
200 if os.path.exists(candidate):
201 found_path = candidate
202 break
204 if found_path:
205 healed_count += 1
207 # Check if the found file needs to be imported
208 # (i.e., it's not already in the assets folder)
209 needs_import = True
210 if not os.path.isabs(asset_path) and asset_path.startswith("assets/"):
211 # It's already a relative assets path, just missing from disk
212 # Copy it to the correct location
213 dest_path = os.path.join(self.project.folder_path, asset_path)
214 os.makedirs(os.path.dirname(dest_path), exist_ok=True)
215 shutil.copy2(found_path, dest_path)
216 print(f"Restored: {asset_path} from {found_path}")
217 else:
218 # It's an absolute path or external path - need to import it
219 try:
220 new_asset_path = self.project.asset_manager.import_asset(found_path)
221 imported_count += 1
223 # Update all elements using this path
224 if asset_path in path_to_elements:
225 for element in path_to_elements[asset_path]:
226 element.image_path = new_asset_path
228 print(f"Imported and updated: {asset_path} → {new_asset_path}")
229 except Exception as e:
230 print(f"Error importing {found_path}: {e}")
231 still_missing.append(asset_path)
232 continue
233 else:
234 still_missing.append(asset_path)
236 # Report results
237 message = f"Healing complete!\n\n"
238 message += f"Assets found: {healed_count}\n"
239 if imported_count > 0:
240 message += f"Assets imported to project: {imported_count}\n"
241 message += f"Still missing: {len(still_missing)}"
243 if still_missing:
244 message += f"\n\nStill missing:\n"
245 message += "\n".join(f" - {asset}" for asset in still_missing[:10])
246 if len(still_missing) > 10:
247 message += f"\n ... and {len(still_missing) - 10} more"
249 QMessageBox.information(self, "Healing Results", message)
251 # Reset asset resolution context to project folder only (no search paths for rendering)
252 set_asset_resolution_context(self.project.folder_path)
254 # Rescan to update the list
255 self._scan_missing_assets()