Minor GUI refactor - Drag+drop issues introduced

This commit is contained in:
2025-05-16 08:47:47 +02:00
parent 1c1620d91a
commit 4bf2513f31
34 changed files with 2736 additions and 303 deletions

View File

@@ -8,38 +8,16 @@ from PySide6.QtWidgets import (
from PySide6.QtGui import QColor, QPalette, QMouseEvent # Added QMouseEvent
from PySide6.QtCore import Qt, QEvent
# Assuming load_asset_definitions, load_file_type_definitions, load_supplier_settings
# are in configuration.py at the root level.
# Adjust the import path if configuration.py is located elsewhere relative to this file.
# For example, if configuration.py is in the parent directory:
# from ..configuration import load_asset_definitions, load_file_type_definitions, load_supplier_settings
# Or if it's in the same directory (less likely for a root config file):
# from .configuration import ...
# Given the project structure, configuration.py is at the root.
import sys
import os
# Add project root to sys.path to allow direct import of configuration
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
try:
from configuration import (
load_asset_definitions, save_asset_definitions,
load_file_type_definitions, save_file_type_definitions,
load_supplier_settings, save_supplier_settings
)
except ImportError as e:
logging.error(f"Failed to import configuration functions: {e}. Ensure configuration.py is in the project root and accessible.")
# Provide dummy functions if import fails, so the UI can still be tested somewhat
def load_asset_definitions(): return {}
def save_asset_definitions(data): pass
def load_file_type_definitions(): return {}
def save_file_type_definitions(data): pass
def load_supplier_settings(): return {}
# def save_supplier_settings(data): pass
from PySide6.QtGui import QColor, QPalette, QMouseEvent
from PySide6.QtCore import Qt, QEvent
# Import the Configuration class
from configuration import Configuration, ConfigurationError
logger = logging.getLogger(__name__)
class DebugListWidget(QListWidget):
def mousePressEvent(self, event: QMouseEvent): # QMouseEvent needs to be imported from PySide6.QtGui
def mousePressEvent(self, event: QMouseEvent):
logger.info(f"DebugListWidget.mousePressEvent: pos={event.pos()}")
item = self.itemAt(event.pos())
if item:
@@ -50,8 +28,9 @@ class DebugListWidget(QListWidget):
logger.info("DebugListWidget.mousePressEvent: super call finished.")
class DefinitionsEditorDialog(QDialog):
def __init__(self, parent=None):
def __init__(self, config: Configuration, parent=None):
super().__init__(parent)
self.config = config # Store the Configuration object
self.setWindowTitle("Definitions Editor")
self.setGeometry(200, 200, 800, 600) # x, y, width, height

View File

@@ -1,7 +1,7 @@
from pathlib import Path
from PySide6.QtWidgets import QStyledItemDelegate, QLineEdit, QComboBox
from PySide6.QtCore import Qt, QModelIndex
from configuration import Configuration, ConfigurationError, load_base_config # Keep load_base_config for SupplierSearchDelegate
from configuration import Configuration, ConfigurationError # Keep load_base_config for SupplierSearchDelegate
from PySide6.QtWidgets import QListWidgetItem
import json

View File

@@ -10,7 +10,7 @@ from PySide6.QtWidgets import (
from PySide6.QtCore import Slot as pyqtSlot, Signal as pyqtSignal # Use PySide6 equivalents
# Assuming configuration module exists and has relevant functions later
from configuration import save_llm_config, ConfigurationError
from configuration import ConfigurationError
# For now, define path directly for initial structure
LLM_CONFIG_PATH = "config/llm_settings.json"
@@ -280,20 +280,26 @@ class LLMEditorWidget(QWidget):
# 1.d. Save Updated Content
try:
save_llm_config(target_file_content) # Save the potentially modified target_file_content
# Ensure the directory exists before saving
import os
os.makedirs(os.path.dirname(LLM_CONFIG_PATH), exist_ok=True)
with open(LLM_CONFIG_PATH, 'w', encoding='utf-8') as f:
json.dump(target_file_content, f, indent=4)
QMessageBox.information(self, "Save Successful", f"LLM settings saved to:\n{LLM_CONFIG_PATH}")
# Update original_llm_settings to reflect the newly saved state
self.original_llm_settings = copy.deepcopy(target_file_content)
self.save_button.setEnabled(False)
self._unsaved_changes = False
self.settings_saved.emit()
logger.info("LLM settings saved successfully.")
except ConfigurationError as e:
logger.error(f"Failed to save LLM settings: {e}")
QMessageBox.critical(self, "Save Error", f"Could not save LLM settings.\n\nError: {e}")
except (IOError, OSError) as e:
logger.error(f"Failed to write LLM settings file {LLM_CONFIG_PATH}: {e}")
QMessageBox.critical(self, "Save Error", f"Could not write LLM settings file.\n\nError: {e}")
self.save_button.setEnabled(True) # Keep save enabled
self._unsaved_changes = True
except Exception as e:

View File

@@ -24,6 +24,9 @@ class LLMPredictionHandler(BasePredictionHandler):
Handles the interaction with an LLM for predicting asset structures
based on a directory's file list. Inherits from BasePredictionHandler.
"""
# Define a constant for files not classified by the LLM
FILE_UNCLASSIFIED_BY_LLM = "FILE_UNCLASSIFIED_BY_LLM"
# Signals (prediction_ready, prediction_error, status_update) are inherited
# Changed 'config: Configuration' to 'settings: dict'
@@ -307,54 +310,67 @@ class LLMPredictionHandler(BasePredictionHandler):
valid_file_types = list(self.settings.get('file_type_definitions', {}).keys())
asset_rules_map: Dict[str, AssetRule] = {} # Maps group_name to AssetRule
# --- Process Individual Files and Build Rules ---
for file_data in response_data["individual_file_analysis"]:
# --- Map LLM File Analysis for Quick Lookup ---
llm_file_map: Dict[str, Dict[str, Any]] = {}
for file_data in response_data.get("individual_file_analysis", []):
if isinstance(file_data, dict):
file_path_rel = file_data.get("relative_file_path")
if file_path_rel and isinstance(file_path_rel, str):
llm_file_map[file_path_rel] = file_data
else:
log.warning(f"Skipping LLM file data entry with missing or invalid 'relative_file_path': {file_data}")
else:
log.warning(f"Skipping invalid LLM file data entry (not a dict): {file_data}")
# --- Process Actual Input Files and Reconcile with LLM Data ---
for file_path_rel in self.file_list:
# Check for cancellation within the loop
if self._is_cancelled:
log.info("LLM prediction cancelled during response parsing (files).")
return []
if not isinstance(file_data, dict):
log.warning(f"Skipping invalid file data entry (not a dict): {file_data}")
continue
file_data = llm_file_map.pop(file_path_rel, None) # Get data if exists, remove from map
file_path_rel = file_data.get("relative_file_path")
file_type = file_data.get("classified_file_type")
group_name = file_data.get("proposed_asset_group_name") # Can be string or null
if file_data:
# --- File found in LLM output - Use LLM Classification ---
file_type = file_data.get("classified_file_type")
group_name = file_data.get("proposed_asset_group_name") # Can be string or null
# --- Validate File Data ---
if not file_path_rel or not isinstance(file_path_rel, str):
log.warning(f"Missing or invalid 'relative_file_path' in file data: {file_data}. Skipping file.")
continue
# Validate file_type against definitions, unless it's FILE_IGNORE
if not file_type or not isinstance(file_type, str):
log.warning(f"Missing or invalid 'classified_file_type' for file '{file_path_rel}' from LLM. Defaulting to {self.FILE_UNCLASSIFIED_BY_LLM}.")
file_type = self.FILE_UNCLASSIFIED_BY_LLM
elif file_type != "FILE_IGNORE" and file_type not in valid_file_types:
log.warning(f"Invalid predicted_file_type '{file_type}' for file '{file_path_rel}' from LLM. Defaulting to EXTRA.")
file_type = "EXTRA"
if not file_type or not isinstance(file_type, str):
log.warning(f"Missing or invalid 'classified_file_type' for file '{file_path_rel}'. Skipping file.")
continue
# Handle FILE_IGNORE explicitly - do not create a rule for it
if file_type == "FILE_IGNORE":
log.debug(f"Ignoring file as per LLM prediction: {file_path_rel}")
continue
# Handle FILE_IGNORE explicitly
if file_type == "FILE_IGNORE":
log.debug(f"Ignoring file as per LLM prediction: {file_path_rel}")
continue # Skip creating a rule for this file
# Determine group name and asset type
if not group_name or not isinstance(group_name, str):
log.warning(f"File '{file_path_rel}' has missing, null, or invalid 'proposed_asset_group_name' ({group_name}) from LLM. Assigning to default asset.")
group_name = "Unclassified Files" # Default group name
asset_type = "UtilityMap" # Default asset type for unclassified files (or another sensible default)
else:
asset_type = response_data["asset_group_classifications"].get(group_name)
if not asset_type:
log.warning(f"No classification found in 'asset_group_classifications' for group '{group_name}' (proposed for file '{file_path_rel}'). Assigning to default asset.")
group_name = "Unclassified Files" # Default group name
asset_type = "UtilityMap" # Default asset type
elif asset_type not in valid_asset_types:
log.warning(f"Invalid asset_type '{asset_type}' found in 'asset_group_classifications' for group '{group_name}'. Assigning to default asset.")
group_name = "Unclassified Files" # Default group name
asset_type = "UtilityMap" # Default asset type
# Validate file_type against definitions
if file_type not in valid_file_types:
log.warning(f"Invalid predicted_file_type '{file_type}' for file '{file_path_rel}'. Defaulting to EXTRA.")
file_type = "EXTRA"
# --- Handle Grouping and Asset Type ---
if not group_name or not isinstance(group_name, str):
log.warning(f"File '{file_path_rel}' has missing, null, or invalid 'proposed_asset_group_name' ({group_name}). Cannot assign to an asset. Skipping file.")
continue
asset_type = response_data["asset_group_classifications"].get(group_name)
if not asset_type:
log.warning(f"No classification found in 'asset_group_classifications' for group '{group_name}' (proposed for file '{file_path_rel}'). Skipping file.")
continue
if asset_type not in valid_asset_types:
log.warning(f"Invalid asset_type '{asset_type}' found in 'asset_group_classifications' for group '{group_name}'. Skipping file '{file_path_rel}'.")
continue
else:
# --- File NOT found in LLM output - Assign Default Classification ---
log.warning(f"File '{file_path_rel}' from input list was NOT classified by LLM. Assigning type {self.FILE_UNCLASSIFIED_BY_LLM} and default asset.")
file_type = self.FILE_UNCLASSIFIED_BY_LLM
group_name = "Unclassified Files" # Default group name
asset_type = "UtilityMap" # Default asset type
# --- Construct Absolute Path ---
try:
@@ -373,25 +389,34 @@ class LLMPredictionHandler(BasePredictionHandler):
# Create new AssetRule if this is the first file for this group
log.debug(f"Creating new AssetRule for group '{group_name}' with type '{asset_type}'.")
asset_rule = AssetRule(asset_name=group_name, asset_type=asset_type)
asset_rule.parent_source = source_rule # Set parent back-reference
source_rule.assets.append(asset_rule)
asset_rules_map[group_name] = asset_rule
# If asset_rule already exists, ensure its type is consistent or handle conflicts if necessary.
# For now, we'll assume the first file dictates the asset type for the default group.
# For LLM-classified groups, the type comes from asset_group_classifications.
# --- Create and Add File Rule ---
file_rule = FileRule(
file_path=file_path_abs,
item_type=file_type,
item_type_override=file_type, # Initial override based on LLM
item_type_override=file_type, # Initial override based on classification (LLM or default)
target_asset_name_override=group_name,
output_format_override=None,
resolution_override=None,
channel_merge_instructions={}
)
file_rule.parent_asset = asset_rule # Set parent back-reference
asset_rule.files.append(file_rule)
log.debug(f"Added file '{file_path_rel}' (type: {file_type}) to asset '{group_name}'.")
# --- Handle LLM Hallucinations (Remaining entries in llm_file_map) ---
for file_path_rel, file_data in llm_file_map.items():
log.warning(f"LLM predicted file '{file_path_rel}' which was NOT in the actual input file list. Ignoring this hallucinated entry.")
# No FileRule is created for this hallucinated file.
# Log if no assets were created
if not source_rule.assets:
log.warning(f"LLM prediction for '{self.input_source_identifier}' resulted in zero valid assets after parsing.")
log.warning(f"LLM prediction for '{self.input_source_identifier}' resulted in zero valid assets after processing actual file list.")
return [source_rule] # Return list containing the single SourceRule

View File

@@ -23,15 +23,8 @@ from .unified_view_model import UnifiedViewModel
from rule_structure import SourceRule, AssetRule, FileRule
import configuration
try:
from configuration import ConfigurationError, load_base_config
except ImportError:
ConfigurationError = Exception
load_base_config = None
class configuration:
PRESETS_DIR = "Presets"
log = logging.getLogger(__name__)
from configuration import Configuration, ConfigurationError # Import Configuration class and Error
class MainPanelWidget(QWidget):
"""
@@ -57,7 +50,7 @@ class MainPanelWidget(QWidget):
blender_settings_changed = Signal(bool, str, str)
def __init__(self, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None):
def __init__(self, config: Configuration, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None):
"""
Initializes the MainPanelWidget.
@@ -67,6 +60,7 @@ class MainPanelWidget(QWidget):
file_type_keys: A list of available file type names (keys from FILE_TYPE_DEFINITIONS).
"""
super().__init__(parent)
self._config = config # Store the Configuration object
self.unified_model = unified_model
self.file_type_keys = file_type_keys if file_type_keys else []
self.llm_processing_active = False
@@ -91,21 +85,19 @@ class MainPanelWidget(QWidget):
output_layout.addWidget(self.browse_output_button)
main_layout.addLayout(output_layout)
if load_base_config:
try:
base_config = load_base_config()
output_base_dir_config = base_config.get('OUTPUT_BASE_DIR', '../Asset_Processor_Output')
default_output_dir = (self.project_root / output_base_dir_config).resolve()
self.output_path_edit.setText(str(default_output_dir))
log.info(f"MainPanelWidget: Default output directory set to: {default_output_dir}")
except ConfigurationError as e:
log.error(f"MainPanelWidget: Error reading base configuration for default output directory: {e}")
self.output_path_edit.setText("")
except Exception as e:
log.exception(f"MainPanelWidget: Error setting default output directory: {e}")
self.output_path_edit.setText("")
else:
log.warning("MainPanelWidget: load_base_config not available to set default output path.")
try:
# Access configuration directly from the stored object
# Use the output_directory_pattern from the Configuration object
output_pattern = self._config.output_directory_pattern
# Assuming the pattern is relative to the project root for the default
default_output_dir = (self.project_root / output_pattern).resolve()
self.output_path_edit.setText(str(default_output_dir))
log.info(f"MainPanelWidget: Default output directory set to: {default_output_dir} based on pattern '{output_pattern}'")
except ConfigurationError as e:
log.error(f"MainPanelWidget: Configuration Error setting default output directory: {e}")
self.output_path_edit.setText("")
except Exception as e:
log.exception(f"MainPanelWidget: Unexpected Error setting default output directory: {e}")
self.output_path_edit.setText("")
@@ -180,19 +172,14 @@ class MainPanelWidget(QWidget):
materials_layout.addWidget(self.browse_materials_blend_button)
blender_layout.addLayout(materials_layout)
if load_base_config:
try:
base_config = load_base_config()
default_ng_path = base_config.get('DEFAULT_NODEGROUP_BLEND_PATH', '')
default_mat_path = base_config.get('DEFAULT_MATERIALS_BLEND_PATH', '')
self.nodegroup_blend_path_input.setText(default_ng_path if default_ng_path else "")
self.materials_blend_path_input.setText(default_mat_path if default_mat_path else "")
except ConfigurationError as e:
log.error(f"MainPanelWidget: Error reading base configuration for default Blender paths: {e}")
except Exception as e:
log.error(f"MainPanelWidget: Error reading default Blender paths from config: {e}")
else:
log.warning("MainPanelWidget: load_base_config not available to set default Blender paths.")
try:
# Use hardcoded defaults as Configuration object does not expose these via public interface
default_ng_path = ''
default_mat_path = ''
self.nodegroup_blend_path_input.setText(default_ng_path if default_ng_path else "")
self.materials_blend_path_input.setText(default_mat_path if default_mat_path else "")
except Exception as e:
log.error(f"MainPanelWidget: Error setting default Blender paths: {e}")
self.nodegroup_blend_path_input.setEnabled(False)

View File

@@ -46,14 +46,13 @@ if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
try:
from configuration import Configuration, ConfigurationError, load_base_config
from configuration import Configuration, ConfigurationError
except ImportError as e:
print(f"ERROR: Failed to import backend modules: {e}")
print(f"Ensure GUI is run from project root or backend modules are in PYTHONPATH.")
Configuration = None
load_base_config = None
ConfigurationError = Exception
AssetProcessor = None
RuleBasedPredictionHandler = None
@@ -97,8 +96,9 @@ class MainWindow(QMainWindow):
start_prediction_signal = Signal(str, list, str)
start_backend_processing = Signal(list, dict)
def __init__(self):
def __init__(self, config: Configuration):
super().__init__()
self.config = config # Store the Configuration object
self.setWindowTitle("Asset Processor Tool")
self.resize(1200, 700)
@@ -132,7 +132,7 @@ class MainWindow(QMainWindow):
self.setCentralWidget(self.splitter)
# --- Create Models ---
self.unified_model = UnifiedViewModel()
self.unified_model = UnifiedViewModel(config=self.config)
# --- Instantiate Handlers that depend on the model ---
self.restructure_handler = AssetRestructureHandler(self.unified_model, self)
@@ -143,17 +143,16 @@ class MainWindow(QMainWindow):
# --- Load File Type Definitions for Rule Editor ---
file_type_keys = []
try:
base_cfg_data = load_base_config()
if base_cfg_data and "FILE_TYPE_DEFINITIONS" in base_cfg_data:
file_type_keys = list(base_cfg_data["FILE_TYPE_DEFINITIONS"].keys())
log.info(f"Loaded {len(file_type_keys)} FILE_TYPE_DEFINITIONS keys for RuleEditor.")
else:
log.warning("FILE_TYPE_DEFINITIONS not found in base_config. RuleEditor item_type dropdown might be empty.")
# Access configuration directly from the stored object using public methods
file_type_defs = self.config.get_file_type_definitions_with_examples()
file_type_keys = list(file_type_defs.keys())
log.info(f"Loaded {len(file_type_keys)} FILE_TYPE_DEFINITIONS keys for RuleEditor.")
except Exception as e:
log.exception(f"Error loading FILE_TYPE_DEFINITIONS for RuleEditor: {e}")
file_type_keys = [] # Ensure it's a list even on error
# Instantiate MainPanelWidget, passing the model, self (MainWindow) for context, and file_type_keys
self.main_panel_widget = MainPanelWidget(self.unified_model, self, file_type_keys=file_type_keys)
# Instantiate MainPanelWidget, passing the config, model, self (MainWindow) for context, and file_type_keys
self.main_panel_widget = MainPanelWidget(config=self.config, unified_model=self.unified_model, parent=self, file_type_keys=file_type_keys)
self.log_console = LogConsoleWidget(self)
# --- Create Left Pane with Static Selector and Stacked Editor ---
@@ -215,8 +214,8 @@ class MainWindow(QMainWindow):
}
self.qt_key_to_ftd_map = {}
try:
base_settings = load_base_config()
file_type_defs = base_settings.get('FILE_TYPE_DEFINITIONS', {})
# Access configuration directly from the stored object using public methods
file_type_defs = self.config.get_file_type_definitions_with_examples()
for ftd_key, ftd_value in file_type_defs.items():
if isinstance(ftd_value, dict) and 'keybind' in ftd_value:
char_key = ftd_value['keybind']
@@ -1340,6 +1339,7 @@ def run_gui():
"""Initializes and runs the Qt application."""
print("--- Reached run_gui() ---")
from PySide6.QtGui import QKeySequence
from configuration import Configuration # Import Configuration here for instantiation
app = QApplication(sys.argv)
@@ -1351,7 +1351,16 @@ def run_gui():
app.setPalette(palette)
window = MainWindow()
# Create a Configuration instance and pass it to MainWindow
try:
config = Configuration()
log.info("Configuration loaded successfully for GUI.")
except Exception as e:
log.critical(f"Failed to load configuration for GUI: {e}")
QMessageBox.critical(None, "Configuration Error", f"Failed to load application configuration:\n{e}\n\nApplication will exit.")
sys.exit(1) # Exit if configuration fails
window = MainWindow(config)
window.show()
sys.exit(app.exec())

View File

@@ -1,12 +1,12 @@
# gui/unified_view_model.py
import logging
log = logging.getLogger(__name__)
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot, QMimeData, QByteArray, QDataStream, QIODevice
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot, QMimeData, QByteArray, QDataStream, QIODevice, QPersistentModelIndex
from PySide6.QtGui import QColor
from pathlib import Path
from rule_structure import SourceRule, AssetRule, FileRule
from configuration import load_base_config
from typing import List
from configuration import Configuration # Import Configuration class
class CustomRoles:
MapTypeRole = Qt.UserRole + 1
@@ -46,8 +46,9 @@ class UnifiedViewModel(QAbstractItemModel):
# --- Drag and Drop MIME Type ---
MIME_TYPE = "application/x-filerule-index-list"
def __init__(self, parent=None):
def __init__(self, config: Configuration, parent=None):
super().__init__(parent)
self._config = config # Store the Configuration object
self._source_rules = []
# self._display_mode removed
self._asset_type_colors = {}
@@ -59,9 +60,9 @@ class UnifiedViewModel(QAbstractItemModel):
def _load_definitions(self):
"""Loads configuration and caches colors and type keys."""
try:
base_config = load_base_config()
asset_type_defs = base_config.get('ASSET_TYPE_DEFINITIONS', {})
file_type_defs = base_config.get('FILE_TYPE_DEFINITIONS', {})
# Access configuration directly from the stored object using public methods
asset_type_defs = self._config.get_asset_type_definitions()
file_type_defs = self._config.get_file_type_definitions_with_examples()
# Cache Asset Type Definitions (Keys and Colors)
self._asset_type_keys = sorted(list(asset_type_defs.keys()))
@@ -905,37 +906,22 @@ class UnifiedViewModel(QAbstractItemModel):
encoded_data = QByteArray()
stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.WriteOnly)
dragged_file_info = []
# Store QPersistentModelIndex for robustness
persistent_indices = []
for index in indexes:
if not index.isValid() or index.column() != 0:
continue
item = index.internalPointer()
if isinstance(item, FileRule):
parent_index = self.parent(index)
if parent_index.isValid():
# Store: source_row, source_parent_row, source_grandparent_row
# This allows reconstructing the index later
grandparent_index = self.parent(parent_index)
# Ensure grandparent_index is valid before accessing its row
if grandparent_index.isValid():
dragged_file_info.append((index.row(), parent_index.row(), grandparent_index.row()))
else:
# Handle case where grandparent is the root (shouldn't happen for FileRule, but safety)
# Or if parent() failed unexpectedly
log.warning(f"mimeData: Could not get valid grandparent index for FileRule at row {index.row()}, parent row {parent_index.row()}")
if index.isValid() and index.column() == 0:
item = index.internalPointer()
if isinstance(item, FileRule):
persistent_indices.append(QPersistentModelIndex(index))
log.debug(f"mimeData: Added persistent index for file: {Path(item.file_path).name}")
else:
log.warning(f"mimeData: Could not get parent index for FileRule at row {index.row()}")
# Write the number of items first, then each tuple
stream.writeInt8(len(dragged_file_info))
for info in dragged_file_info:
stream.writeInt8(info[0])
stream.writeInt8(info[1])
stream.writeInt8(info[2])
# Write the number of items first, then each persistent index
stream.writeInt32(len(persistent_indices)) # Use writeInt32 for potentially more items
for p_index in persistent_indices:
stream.writeQPersistentModelIndex(p_index)
mime_data.setData(self.MIME_TYPE, encoded_data)
log.debug(f"mimeData: Encoded {len(dragged_file_info)} FileRule indices.")
log.debug(f"mimeData: Encoded {len(persistent_indices)} FileRule persistent indices.")
return mime_data
def canDropMimeData(self, data: QMimeData, action: Qt.DropAction, row: int, column: int, parent: QModelIndex) -> bool:
@@ -970,75 +956,48 @@ class UnifiedViewModel(QAbstractItemModel):
encoded_data = data.data(self.MIME_TYPE)
stream = QDataStream(encoded_data, QIODevice.OpenModeFlag.ReadOnly)
num_items = stream.readInt8()
source_indices_info = []
# Read QPersistentModelIndex objects
persistent_indices = []
num_items = stream.readInt32()
log.debug(f"dropMimeData: Decoding {num_items} persistent indices.")
for _ in range(num_items):
source_row = stream.readInt8()
source_parent_row = stream.readInt8()
source_grandparent_row = stream.readInt8()
source_indices_info.append((source_row, source_parent_row, source_grandparent_row))
p_index = stream.readQPersistentModelIndex()
if p_index.isValid():
persistent_indices.append(p_index)
else:
log.warning("dropMimeData: Decoded invalid persistent index. Skipping.")
log.debug(f"dropMimeData: Decoded {len(source_indices_info)} source indices. Target Asset: '{target_asset_item.asset_name}'")
log.debug(f"dropMimeData: Decoded {len(persistent_indices)} valid persistent indices. Target Asset: '{target_asset_item.asset_name}'")
if not source_indices_info:
log.warning("dropMimeData: No valid source index information decoded.")
if not persistent_indices:
log.warning("dropMimeData: No valid persistent index information decoded.")
return False
# Keep track of original parents that might become empty
original_parents = set()
original_parents_to_check = set()
moved_files_new_indices = {}
# --- BEGIN FIX: Reconstruct all source indices BEFORE the move loop ---
source_indices_to_process = []
log.debug("Reconstructing initial source indices...")
for src_row, src_parent_row, src_grandparent_row in source_indices_info:
grandparent_index = self.index(src_grandparent_row, 0, QModelIndex())
if not grandparent_index.isValid():
log.error(f"dropMimeData: Failed initial reconstruction of grandparent index (row {src_grandparent_row}). Skipping item.")
continue
old_parent_index = self.index(src_parent_row, 0, grandparent_index)
if not old_parent_index.isValid():
log.error(f"dropMimeData: Failed initial reconstruction of old parent index (row {src_parent_row}). Skipping item.")
continue
source_file_index = self.index(src_row, 0, old_parent_index)
# Process moves using the persistent indices
for p_source_index in persistent_indices:
# Convert persistent index back to a model index
source_file_index = QModelIndex(p_source_index)
if not source_file_index.isValid():
# Log the specific parent it failed under for better debugging
parent_name = getattr(old_parent_index.internalPointer(), 'asset_name', 'Unknown Parent')
log.error(f"dropMimeData: Failed initial reconstruction of source file index (original row {src_row}) under parent '{parent_name}'. Skipping item.")
log.warning(f"dropMimeData: Persistent index is no longer valid. Skipping item.")
continue
# Check if the reconstructed index actually points to a FileRule
item_check = source_file_index.internalPointer()
if isinstance(item_check, FileRule):
source_indices_to_process.append(source_file_index)
log.debug(f" Successfully reconstructed index for file: {Path(item_check.file_path).name}")
else:
log.warning(f"dropMimeData: Initial reconstructed index (row {src_row}) does not point to a FileRule. Skipping.")
log.debug(f"Successfully reconstructed {len(source_indices_to_process)} valid source indices.")
# --- END FIX ---
# Process moves using the pre-calculated valid indices
for source_file_index in source_indices_to_process:
# Get the file item (already validated during reconstruction)
# Get the file item
file_item = source_file_index.internalPointer()
if not isinstance(file_item, FileRule):
log.error(f"dropMimeData: Index points to non-FileRule item after conversion. Skipping.")
continue
# Track original parent for cleanup (using the valid index)
old_parent_index = self.parent(source_file_index)
if old_parent_index.isValid():
old_parent_asset = old_parent_index.internalPointer()
if isinstance(old_parent_asset, AssetRule):
# Need grandparent row for the tuple key
grandparent_index = self.parent(old_parent_index)
if grandparent_index.isValid():
original_parents.add((grandparent_index.row(), old_parent_asset.asset_name))
else:
log.warning(f"Could not get grandparent index for original parent '{old_parent_asset.asset_name}' during cleanup tracking.")
else:
log.warning(f"Parent of file '{Path(file_item.file_path).name}' is not an AssetRule.")
# Track original parent for cleanup using the parent back-reference
old_parent_asset = getattr(file_item, 'parent_asset', None)
if old_parent_asset and isinstance(old_parent_asset, AssetRule):
original_parents_to_check.add(old_parent_asset)
else:
log.warning(f"Could not get valid parent index for file '{Path(file_item.file_path).name}' during cleanup tracking.")
log.warning(f"dropMimeData: File '{Path(file_item.file_path).name}' has no valid parent asset reference for cleanup tracking.")
# Perform the move using the model's method and the valid source_file_index
@@ -1052,8 +1011,9 @@ class UnifiedViewModel(QAbstractItemModel):
file_item.target_asset_name_override = target_asset_item.asset_name
# Need the *new* index of the moved file to emit dataChanged
try:
# Find the new row of the file item within the target parent's list
new_row = target_asset_item.files.index(file_item)
new_file_index_col0 = self.index(new_row, 0, parent)
# Create the index for the target asset column
new_file_index_target_col = self.index(new_row, self.COL_TARGET_ASSET, parent)
if new_file_index_target_col.isValid():
moved_files_new_indices[file_item.file_path] = new_file_index_target_col
@@ -1074,24 +1034,30 @@ class UnifiedViewModel(QAbstractItemModel):
self.dataChanged.emit(new_index, new_index, [Qt.DisplayRole, Qt.EditRole])
# --- Cleanup: Remove any original parent AssetRules that are now empty ---
log.debug(f"dropMimeData: Checking original parents for cleanup: {list(original_parents)}")
for gp_row, asset_name in list(original_parents):
log.debug(f"dropMimeData: Checking original parents for cleanup: {[asset.asset_name for asset in original_parents_to_check]}")
# Convert set to list to iterate and allow removal from the model
for asset_rule_to_check in list(original_parents_to_check):
try:
if 0 <= gp_row < len(self._source_rules):
source_rule = self._source_rules[gp_row]
# Find the asset rule within the correct source rule
asset_rule_to_check = next((asset for asset in source_rule.assets if asset.asset_name == asset_name), None)
if asset_rule_to_check and not asset_rule_to_check.files and asset_rule_to_check != target_asset_item:
log.info(f"dropMimeData: Attempting cleanup of now empty original parent: '{asset_rule_to_check.asset_name}'")
if not self.removeAssetRule(asset_rule_to_check):
log.warning(f"dropMimeData: Failed to remove empty original parent '{asset_rule_to_check.asset_name}'.")
elif not asset_rule_to_check:
log.warning(f"dropMimeData: Cleanup check failed. Could not find original parent asset '{asset_name}' in source rule at row {gp_row}.")
# Re-check if the asset is still in the model and is now empty
# Use parent back-reference to find the source rule
source_rule = getattr(asset_rule_to_check, 'parent_source', None)
if source_rule:
# Check if the asset rule is still in its parent's list
if asset_rule_to_check in source_rule.assets:
if not asset_rule_to_check.files and asset_rule_to_check is not target_asset_item:
log.info(f"dropMimeData: Attempting cleanup of now empty original parent: '{asset_rule_to_check.asset_name}'")
if not self.removeAssetRule(asset_rule_to_check):
log.warning(f"dropMimeData: Failed to remove empty original parent '{asset_rule_to_check.asset_name}'.")
elif asset_rule_to_check.files:
log.debug(f"dropMimeData: Original parent '{asset_rule_to_check.asset_name}' is not empty after moves. Skipping cleanup.")
# If it's the target asset, we don't remove it
else:
log.warning(f"dropMimeData: Cleanup check failed. Original parent asset '{asset_rule_to_check.asset_name}' not found in its source rule's list.")
else:
log.warning(f"dropMimeData: Cleanup check failed. Invalid grandparent row index {gp_row} found in original_parents set.")
log.warning(f"dropMimeData: Cleanup check failed. Original parent asset '{asset_rule_to_check.asset_name}' has no parent source reference.")
except Exception as e:
log.exception(f"dropMimeData: Error during cleanup check for parent '{asset_name}' (gp_row {gp_row}): {e}")
log.exception(f"dropMimeData: Error during cleanup check for parent '{asset_rule_to_check.asset_name}': {e}")
return True