Files
pyPhotoAlbum/pyPhotoAlbum/asset_heal_dialog.py
T
dtourolle f6ed11b0bc
Python CI / test (push) Successful in 1m20s
Lint / lint (push) Successful in 1m4s
Tests / test (3.11) (push) Successful in 1m27s
Tests / test (3.12) (push) Successful in 2m25s
Tests / test (3.13) (push) Successful in 2m52s
Tests / test (3.14) (push) Successful in 1m9s
black formatting
2025-11-27 23:07:16 +01:00

256 lines
9.8 KiB
Python

"""
Asset healing dialog for reconnecting missing images
"""
import os
import shutil
from typing import List, Dict, Set
from PyQt6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QLabel,
QPushButton,
QListWidget,
QListWidgetItem,
QFileDialog,
QGroupBox,
QMessageBox,
)
from PyQt6.QtCore import Qt
class AssetHealDialog(QDialog):
"""Dialog for healing missing asset paths"""
def __init__(self, project, parent=None):
super().__init__(parent)
self.project = project
self.search_paths: List[str] = []
self.missing_assets: Set[str] = set()
self.setWindowTitle("Heal Missing Assets")
self.resize(600, 500)
self._init_ui()
self._scan_missing_assets()
def _init_ui(self):
"""Initialize the UI"""
layout = QVBoxLayout()
# Missing assets group
missing_group = QGroupBox("Missing Assets")
missing_layout = QVBoxLayout()
self.missing_list = QListWidget()
missing_layout.addWidget(self.missing_list)
missing_group.setLayout(missing_layout)
layout.addWidget(missing_group)
# Search paths group
search_group = QGroupBox("Search Paths")
search_layout = QVBoxLayout()
self.search_list = QListWidget()
search_layout.addWidget(self.search_list)
# Add/Remove buttons
button_layout = QHBoxLayout()
add_path_btn = QPushButton("Add Search Path...")
add_path_btn.clicked.connect(self._add_search_path)
button_layout.addWidget(add_path_btn)
remove_path_btn = QPushButton("Remove Selected")
remove_path_btn.clicked.connect(self._remove_search_path)
button_layout.addWidget(remove_path_btn)
search_layout.addLayout(button_layout)
search_group.setLayout(search_layout)
layout.addWidget(search_group)
# Action buttons
action_layout = QHBoxLayout()
heal_btn = QPushButton("Attempt Healing")
heal_btn.clicked.connect(self._attempt_healing)
action_layout.addWidget(heal_btn)
close_btn = QPushButton("Close")
close_btn.clicked.connect(self.accept)
action_layout.addWidget(close_btn)
layout.addLayout(action_layout)
self.setLayout(layout)
def _scan_missing_assets(self):
"""Scan project for missing assets - only assets in project's assets folder are valid"""
from pyPhotoAlbum.models import ImageData
self.missing_assets.clear()
self.missing_list.clear()
# Check all pages for images that need healing
# Images MUST be in the project's assets folder - absolute paths or external paths need healing
for page in self.project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
needs_healing = False
reason = ""
# Absolute paths need healing (should be relative to assets/)
if os.path.isabs(element.image_path):
needs_healing = True
reason = "absolute path"
# Paths not starting with assets/ need healing
elif not element.image_path.startswith("assets/"):
needs_healing = True
reason = "not in assets folder"
else:
# Relative path in assets/ - check if file exists
full_path = os.path.join(self.project.folder_path, element.image_path)
if not os.path.exists(full_path):
needs_healing = True
reason = "file missing"
if needs_healing:
self.missing_assets.add(element.image_path)
print(f"Asset needs healing: {element.image_path} ({reason})")
# Display missing assets
if self.missing_assets:
for asset in sorted(self.missing_assets):
self.missing_list.addItem(asset)
else:
item = QListWidgetItem("No missing assets found!")
item.setForeground(Qt.GlobalColor.darkGreen)
self.missing_list.addItem(item)
def _add_search_path(self):
"""Add a search path"""
directory = QFileDialog.getExistingDirectory(
self, "Select Search Path for Assets", "", QFileDialog.Option.ShowDirsOnly
)
if directory:
if directory not in self.search_paths:
self.search_paths.append(directory)
self.search_list.addItem(directory)
def _remove_search_path(self):
"""Remove selected search path"""
current_row = self.search_list.currentRow()
if current_row >= 0:
self.search_paths.pop(current_row)
self.search_list.takeItem(current_row)
def _attempt_healing(self):
"""Attempt to heal missing assets by resolving stored paths and using search paths"""
from pyPhotoAlbum.models import ImageData, set_asset_resolution_context
healed_count = 0
imported_count = 0
still_missing = []
# Update asset resolution context with search paths (for rendering after heal)
set_asset_resolution_context(self.project.folder_path, self.search_paths)
# Build mapping of missing paths to elements
path_to_elements: Dict[str, List] = {}
for page in self.project.pages:
for element in page.layout.elements:
if isinstance(element, ImageData) and element.image_path:
if element.image_path in self.missing_assets:
if element.image_path not in path_to_elements:
path_to_elements[element.image_path] = []
path_to_elements[element.image_path].append(element)
# Try to find and import each missing asset
for asset_path in self.missing_assets:
found_path = None
filename = os.path.basename(asset_path)
# FIRST: Try to resolve the stored path directly from project folder
# This handles paths like "../../home/user/Photos/image.jpg"
if not os.path.isabs(asset_path):
resolved = os.path.normpath(os.path.join(self.project.folder_path, asset_path))
if os.path.exists(resolved):
found_path = resolved
print(f"Resolved relative path: {asset_path}{resolved}")
# SECOND: If it's an absolute path, check if it exists directly
if not found_path and os.path.isabs(asset_path):
if os.path.exists(asset_path):
found_path = asset_path
print(f"Found at absolute path: {asset_path}")
# THIRD: Search in user-provided search paths
if not found_path:
for search_path in self.search_paths:
# Try direct match by filename
candidate = os.path.join(search_path, filename)
if os.path.exists(candidate):
found_path = candidate
break
# Try with same relative path structure
candidate = os.path.join(search_path, asset_path)
if os.path.exists(candidate):
found_path = candidate
break
if found_path:
healed_count += 1
# Check if the found file needs to be imported
# (i.e., it's not already in the assets folder)
needs_import = True
if not os.path.isabs(asset_path) and asset_path.startswith("assets/"):
# It's already a relative assets path, just missing from disk
# Copy it to the correct location
dest_path = os.path.join(self.project.folder_path, asset_path)
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
shutil.copy2(found_path, dest_path)
print(f"Restored: {asset_path} from {found_path}")
else:
# It's an absolute path or external path - need to import it
try:
new_asset_path = self.project.asset_manager.import_asset(found_path)
imported_count += 1
# Update all elements using this path
if asset_path in path_to_elements:
for element in path_to_elements[asset_path]:
element.image_path = new_asset_path
print(f"Imported and updated: {asset_path}{new_asset_path}")
except Exception as e:
print(f"Error importing {found_path}: {e}")
still_missing.append(asset_path)
continue
else:
still_missing.append(asset_path)
# Report results
message = f"Healing complete!\n\n"
message += f"Assets found: {healed_count}\n"
if imported_count > 0:
message += f"Assets imported to project: {imported_count}\n"
message += f"Still missing: {len(still_missing)}"
if still_missing:
message += f"\n\nStill missing:\n"
message += "\n".join(f" - {asset}" for asset in still_missing[:10])
if len(still_missing) > 10:
message += f"\n ... and {len(still_missing) - 10} more"
QMessageBox.information(self, "Healing Results", message)
# Reset asset resolution context to project folder only (no search paths for rendering)
set_asset_resolution_context(self.project.folder_path)
# Rescan to update the list
self._scan_missing_assets()