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

1""" 

2Asset healing dialog for reconnecting missing images 

3""" 

4 

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 

21 

22 

23class AssetHealDialog(QDialog): 

24 """Dialog for healing missing asset paths""" 

25 

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() 

31 

32 self.setWindowTitle("Heal Missing Assets") 

33 self.resize(600, 500) 

34 

35 self._init_ui() 

36 self._scan_missing_assets() 

37 

38 def _init_ui(self): 

39 """Initialize the UI""" 

40 layout = QVBoxLayout() 

41 

42 # Missing assets group 

43 missing_group = QGroupBox("Missing Assets") 

44 missing_layout = QVBoxLayout() 

45 

46 self.missing_list = QListWidget() 

47 missing_layout.addWidget(self.missing_list) 

48 

49 missing_group.setLayout(missing_layout) 

50 layout.addWidget(missing_group) 

51 

52 # Search paths group 

53 search_group = QGroupBox("Search Paths") 

54 search_layout = QVBoxLayout() 

55 

56 self.search_list = QListWidget() 

57 search_layout.addWidget(self.search_list) 

58 

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) 

64 

65 remove_path_btn = QPushButton("Remove Selected") 

66 remove_path_btn.clicked.connect(self._remove_search_path) 

67 button_layout.addWidget(remove_path_btn) 

68 

69 search_layout.addLayout(button_layout) 

70 search_group.setLayout(search_layout) 

71 layout.addWidget(search_group) 

72 

73 # Action buttons 

74 action_layout = QHBoxLayout() 

75 

76 heal_btn = QPushButton("Attempt Healing") 

77 heal_btn.clicked.connect(self._attempt_healing) 

78 action_layout.addWidget(heal_btn) 

79 

80 close_btn = QPushButton("Close") 

81 close_btn.clicked.connect(self.accept) 

82 action_layout.addWidget(close_btn) 

83 

84 layout.addLayout(action_layout) 

85 

86 self.setLayout(layout) 

87 

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 

91 

92 self.missing_assets.clear() 

93 self.missing_list.clear() 

94 

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 = "" 

102 

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" 

117 

118 if needs_healing: 

119 self.missing_assets.add(element.image_path) 

120 print(f"Asset needs healing: {element.image_path} ({reason})") 

121 

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) 

130 

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 ) 

136 

137 if directory: 

138 if directory not in self.search_paths: 

139 self.search_paths.append(directory) 

140 self.search_list.addItem(directory) 

141 

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) 

148 

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 

152 

153 healed_count = 0 

154 imported_count = 0 

155 still_missing = [] 

156 

157 # Update asset resolution context with search paths (for rendering after heal) 

158 set_asset_resolution_context(self.project.folder_path, self.search_paths) 

159 

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) 

169 

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) 

174 

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}") 

182 

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}") 

188 

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 

197 

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 

203 

204 if found_path: 

205 healed_count += 1 

206 

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 

222 

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 

227 

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) 

235 

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)}" 

242 

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" 

248 

249 QMessageBox.information(self, "Healing Results", message) 

250 

251 # Reset asset resolution context to project folder only (no search paths for rendering) 

252 set_asset_resolution_context(self.project.folder_path) 

253 

254 # Rescan to update the list 

255 self._scan_missing_assets()