1 """

2 Asset healing dialog for reconnecting missing images

3 """

4

5 import os

6 import shutil

7 from typing import List, Dict, Set

8 from PyQt6.QtWidgets import (

9 QDialog,

10 QVBoxLayout,

11 QHBoxLayout,

12 QLabel,

13 QPushButton,

14 QListWidget,

15 QListWidgetItem,

16 QFileDialog,

17 QGroupBox,

18 QMessageBox,

19 )

20 from PyQt6.QtCore import Qt

21

22

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