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
256 lines
9.8 KiB
Python
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()
|