Major Comment and codebase cleanup

This commit is contained in:
2025-05-06 22:47:26 +02:00
parent ddb5a43a21
commit 932b39fd01
109 changed files with 622 additions and 10137 deletions

View File

@@ -3,7 +3,7 @@ import logging
from PySide6.QtCore import QObject, Slot, QModelIndex
from PySide6.QtGui import QColor # Might be needed if copying logic directly, though unlikely now
from pathlib import Path
from .unified_view_model import UnifiedViewModel # Use relative import
from .unified_view_model import UnifiedViewModel
from rule_structure import SourceRule, AssetRule, FileRule
log = logging.getLogger(__name__)
@@ -25,7 +25,7 @@ class AssetRestructureHandler(QObject):
log.debug("AssetRestructureHandler initialized.")
@Slot(FileRule, str, QModelIndex)
def handle_target_asset_override(self, file_rule_item: FileRule, new_target_name: str, index: QModelIndex): # Ensure FileRule is imported
def handle_target_asset_override(self, file_rule_item: FileRule, new_target_name: str, index: QModelIndex):
"""
Slot connected to UnifiedViewModel.targetAssetOverrideChanged.
Orchestrates model changes based on the new target asset path.
@@ -35,7 +35,7 @@ class AssetRestructureHandler(QObject):
new_target_name: The new target asset path (string).
index: The QModelIndex of the changed item (passed by the signal).
"""
if not isinstance(file_rule_item, FileRule): # Check the correct parameter
if not isinstance(file_rule_item, FileRule):
log.warning(f"Handler received targetAssetOverrideChanged for non-FileRule item: {type(file_rule_item)}. Aborting.")
return
@@ -47,14 +47,12 @@ class AssetRestructureHandler(QObject):
if effective_new_target_name == "": effective_new_target_name = None # Treat empty string as None
# --- Get necessary context ---
# Use file_rule_item directly
old_parent_asset = getattr(file_rule_item, 'parent_asset', None)
if not old_parent_asset:
log.error(f"Handler: File item '{Path(file_rule_item.file_path).name}' has no parent asset. Cannot restructure.")
# Note: Data change already happened in setData, cannot easily revert here.
return
# Use file_rule_item directly
source_rule = getattr(old_parent_asset, 'parent_source', None)
if not source_rule:
log.error(f"Handler: Could not find SourceRule for parent asset '{old_parent_asset.asset_name}'. Cannot restructure.")
@@ -81,7 +79,7 @@ class AssetRestructureHandler(QObject):
except ValueError:
log.error(f"Handler: Could not find SourceRule index while looking for target parent '{effective_new_target_name}'.")
target_parent_asset = None # Reset if index is invalid
break # Found the asset
break
# 2. Handle Move or Creation
if target_parent_asset: # An existing AssetRule to move to was found
@@ -91,7 +89,7 @@ class AssetRestructureHandler(QObject):
# The 'index' parameter IS the QModelIndex of the FileRule being changed.
# No need to re-fetch or re-validate it if the signal emits it correctly.
# The core issue was using a stale index to get the *object*, now we *have* the object.
source_file_qmodelindex = index # Use the index passed by the signal
source_file_qmodelindex = index
if not source_file_qmodelindex or not source_file_qmodelindex.isValid(): # Should always be valid if signal emits it
log.error(f"Handler: Received invalid QModelIndex for source file '{Path(file_rule_item.file_path).name}'. Cannot move.")
@@ -114,7 +112,7 @@ class AssetRestructureHandler(QObject):
target_parent_asset = new_asset_qmodelindex.internalPointer() # Get the newly created AssetRule object
target_parent_index = new_asset_qmodelindex # The QModelIndex of the new AssetRule
source_file_qmodelindex = index # Use the index passed by the signal
source_file_qmodelindex = index
if not source_file_qmodelindex or not source_file_qmodelindex.isValid(): # Should always be valid
log.error(f"Handler: Received invalid QModelIndex for source file '{Path(file_rule_item.file_path).name}'. Cannot move to new asset.")
self.model.removeAssetRule(target_parent_asset) # Attempt to clean up newly created asset
@@ -185,8 +183,8 @@ class AssetRestructureHandler(QObject):
return QModelIndex()
return QModelIndex()
@Slot(AssetRule, str, QModelIndex) # Updated signature
def handle_asset_name_changed(self, asset_rule_item: AssetRule, new_name: str, index: QModelIndex): # Ensure AssetRule is imported
@Slot(AssetRule, str, QModelIndex)
def handle_asset_name_changed(self, asset_rule_item: AssetRule, new_name: str, index: QModelIndex):
"""
Slot connected to UnifiedViewModel.assetNameChanged.
Handles logic when an AssetRule's name is changed.

View File

@@ -1,4 +1,3 @@
# gui/base_prediction_handler.py
import logging
import time
from abc import ABC, abstractmethod
@@ -16,7 +15,7 @@ except ImportError:
class SourceRule: pass
from abc import ABCMeta
from PySide6.QtCore import QObject # Ensure QObject is imported if not already
from PySide6.QtCore import QObject
# Combine metaclasses to avoid conflict between QObject and ABC
class QtABCMeta(type(QObject), ABCMeta):
@@ -52,7 +51,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
super().__init__(parent)
self.input_source_identifier = input_source_identifier
self._is_running = False
self._is_cancelled = False # Added cancellation flag
self._is_cancelled = False
@property
def is_running(self) -> bool:
@@ -65,7 +64,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
Main execution slot intended to be connected to QThread.started.
Handles the overall process: setup, execution, error handling, signaling.
"""
log.debug(f"--> Entered BasePredictionHandler.run() for {self.input_source_identifier}") # ADDED DEBUG LOG
log.debug(f"--> Entered BasePredictionHandler.run() for {self.input_source_identifier}")
if self._is_running:
log.warning(f"Handler for '{self.input_source_identifier}' is already running. Aborting.")
return
@@ -75,7 +74,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
return
self._is_running = True
self._is_cancelled = False # Ensure cancel flag is reset at start
self._is_cancelled = False
thread_id = QThread.currentThread() # Use currentThread() for PySide6
log.info(f"[{time.time():.4f}][T:{thread_id}] Starting prediction run for: {self.input_source_identifier}")
self.status_update.emit(f"Starting analysis for '{Path(self.input_source_identifier).name}'...")
@@ -99,7 +98,7 @@ class BasePredictionHandler(QObject, ABC, metaclass=QtABCMeta):
error_msg = f"Error analyzing '{Path(self.input_source_identifier).name}': {e}"
self.prediction_error.emit(self.input_source_identifier, error_msg)
# Status update might be redundant if error is shown elsewhere, but can be useful
# self.status_update.emit(f"Error: {e}")
# Status update might be redundant if error is shown elsewhere, but can be useful
finally:
# --- Cleanup ---

View File

@@ -1,4 +1,3 @@
# gui/config_editor_dialog.py
import json
from PySide6.QtWidgets import (
@@ -7,14 +6,13 @@ from PySide6.QtWidgets import (
QPushButton, QFileDialog, QLabel, QTableWidget,
QTableWidgetItem, QDialogButtonBox, QMessageBox, QListWidget,
QListWidgetItem, QFormLayout, QGroupBox, QStackedWidget,
QHeaderView, QSizePolicy # Added QHeaderView and QSizePolicy
QHeaderView, QSizePolicy
)
from PySide6.QtGui import QColor, QPainter
from PySide6.QtCore import Qt, QEvent
from PySide6.QtWidgets import QColorDialog, QStyledItemDelegate, QApplication
# Assuming configuration.py is in the parent directory or accessible
# Adjust import path if necessary
try:
from configuration import load_base_config, save_base_config
except ImportError:
@@ -30,7 +28,6 @@ class ColorDelegate(QStyledItemDelegate):
if isinstance(color_str, str) and color_str.startswith('#'):
color = QColor(color_str)
if color.isValid():
# Fill the background with the color
painter.fillRect(option.rect, color)
# Optionally draw text (e.g., the hex code) centered
# painter.drawText(option.rect, Qt.AlignCenter, color_str)
@@ -198,19 +195,19 @@ class ConfigEditorDialog(QDialog):
output_dir_layout.addWidget(output_dir_edit)
output_dir_layout.addWidget(output_dir_button)
form_layout.addRow(output_dir_label, output_dir_layout)
self.widgets["OUTPUT_BASE_DIR"] = output_dir_edit # Store reference
self.widgets["OUTPUT_BASE_DIR"] = output_dir_edit
# 2. EXTRA_FILES_SUBDIR: QLineEdit
extra_subdir_label = QLabel("Subdirectory for Extra Files:")
extra_subdir_edit = QLineEdit()
form_layout.addRow(extra_subdir_label, extra_subdir_edit)
self.widgets["EXTRA_FILES_SUBDIR"] = extra_subdir_edit # Store reference
self.widgets["EXTRA_FILES_SUBDIR"] = extra_subdir_edit
# 3. METADATA_FILENAME: QLineEdit
metadata_label = QLabel("Metadata Filename:")
metadata_edit = QLineEdit()
form_layout.addRow(metadata_label, metadata_edit)
self.widgets["METADATA_FILENAME"] = metadata_edit # Store reference
self.widgets["METADATA_FILENAME"] = metadata_edit
layout.addLayout(form_layout)
layout.addStretch() # Keep stretch at the end
@@ -245,10 +242,8 @@ class ConfigEditorDialog(QDialog):
self.widgets.pop("RESPECT_VARIANT_MAP_TYPES", None)
self.widgets.pop("ASPECT_RATIO_DECIMALS", None)
# Main layout for this tab
main_tab_layout = QVBoxLayout()
# Form layout for simple input fields
form_layout = QFormLayout()
# 1. TARGET_FILENAME_PATTERN: QLineEdit
@@ -276,7 +271,6 @@ class ConfigEditorDialog(QDialog):
main_tab_layout.addLayout(form_layout)
# Add the main layout to the tab's provided layout
layout.addLayout(main_tab_layout)
layout.addStretch() # Keep stretch at the end of the tab's main layout
@@ -315,7 +309,6 @@ class ConfigEditorDialog(QDialog):
for key in keys_to_clear:
self.widgets.pop(key, None)
# Main layout for this tab
main_tab_layout = QVBoxLayout()
# --- IMAGE_RESOLUTIONS Section ---
@@ -332,7 +325,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Resolution (px)" column
# TODO: Connect add/remove buttons signals
resolutions_layout.addWidget(resolutions_table)
self.widgets["IMAGE_RESOLUTIONS_TABLE"] = resolutions_table # Store table reference
self.widgets["IMAGE_RESOLUTIONS_TABLE"] = resolutions_table
resolutions_button_layout = QHBoxLayout()
add_res_button = QPushButton("Add Row")
@@ -398,7 +391,6 @@ class ConfigEditorDialog(QDialog):
main_tab_layout.addLayout(form_layout)
# Add the main layout to the tab's provided layout
layout.addLayout(main_tab_layout)
layout.addStretch() # Keep stretch at the end of the tab's main layout
@@ -436,7 +428,6 @@ class ConfigEditorDialog(QDialog):
self.widgets.pop("MAP_BIT_DEPTH_RULES_TABLE", None)
# Overall QVBoxLayout for the "Definitions" tab
overall_layout = QVBoxLayout()
# --- Top Widget: DEFAULT_ASSET_CATEGORY ---
@@ -527,7 +518,6 @@ class ConfigEditorDialog(QDialog):
file_types_button_layout.addStretch()
file_types_layout.addLayout(file_types_button_layout)
# Add the overall layout to the main tab layout provided
layout.addLayout(overall_layout)
layout.addStretch() # Keep stretch at the end of the tab's main layout
@@ -642,7 +632,7 @@ class ConfigEditorDialog(QDialog):
nodegroup_layout.addWidget(nodegroup_widget)
nodegroup_layout.addWidget(nodegroup_button)
form_layout.addRow(nodegroup_label, nodegroup_layout)
self.widgets["DEFAULT_NODEGROUP_BLEND_PATH"] = nodegroup_widget # Store reference
self.widgets["DEFAULT_NODEGROUP_BLEND_PATH"] = nodegroup_widget
# 2. DEFAULT_MATERIALS_BLEND_PATH: QLineEdit + QPushButton
materials_label = QLabel("Default Materials Library (.blend):")
@@ -655,7 +645,7 @@ class ConfigEditorDialog(QDialog):
materials_layout.addWidget(materials_widget)
materials_layout.addWidget(materials_button)
form_layout.addRow(materials_label, materials_layout)
self.widgets["DEFAULT_MATERIALS_BLEND_PATH"] = materials_widget # Store reference
self.widgets["DEFAULT_MATERIALS_BLEND_PATH"] = materials_widget
# 3. BLENDER_EXECUTABLE_PATH: QLineEdit + QPushButton
blender_label = QLabel("Blender Executable Path:")
@@ -668,7 +658,7 @@ class ConfigEditorDialog(QDialog):
blender_layout.addWidget(blender_widget)
blender_layout.addWidget(blender_button)
form_layout.addRow(blender_label, blender_layout)
self.widgets["BLENDER_EXECUTABLE_PATH"] = blender_widget # Store reference
self.widgets["BLENDER_EXECUTABLE_PATH"] = blender_widget
layout.addLayout(form_layout)
layout.addStretch()
@@ -686,7 +676,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Examples" column (QLineEdit)
layout.addWidget(table)
self.widgets["ASSET_TYPE_DEFINITIONS_TABLE"] = table # Store table reference
self.widgets["ASSET_TYPE_DEFINITIONS_TABLE"] = table
def create_file_type_definitions_table_widget(self, layout, definitions_data):
"""Creates a QTableWidget for editing file type definitions."""
@@ -703,7 +693,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Bit Depth Rule" column (QComboBox)
layout.addWidget(table)
self.widgets["FILE_TYPE_DEFINITIONS_TABLE"] = table # Store table reference
self.widgets["FILE_TYPE_DEFINITIONS_TABLE"] = table
def create_image_resolutions_table_widget(self, layout, resolutions_data):
"""Creates a QTableWidget for editing image resolutions."""
@@ -717,7 +707,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Resolution (px)" column (e.g., QLineEdit with validation or two SpinBoxes)
layout.addWidget(table)
self.widgets["IMAGE_RESOLUTIONS_TABLE"] = table # Store table reference
self.widgets["IMAGE_RESOLUTIONS_TABLE"] = table
def create_map_bit_depth_rules_table_widget(self, layout, rules_data: dict):
"""Creates a QTableWidget for editing map bit depth rules (Map Type -> Rule)."""
@@ -731,7 +721,7 @@ class ConfigEditorDialog(QDialog):
# TODO: Implement custom delegate for "Rule" column (QComboBox)
layout.addWidget(table)
self.widgets["MAP_BIT_DEPTH_RULES_TABLE"] = table # Store table reference
self.widgets["MAP_BIT_DEPTH_RULES_TABLE"] = table
def create_map_merge_rules_widget(self, layout, rules_data):
@@ -803,7 +793,7 @@ class ConfigEditorDialog(QDialog):
group_layout.addWidget(input_table)
self.merge_rule_details_layout.addRow(group)
self.merge_rule_widgets["inputs_table"] = input_table # Store table reference
self.merge_rule_widgets["inputs_table"] = input_table
# defaults: QTableWidget (Fixed Rows: R, G, B, A. Columns: "Channel", "Default Value"). Label: "Channel Defaults (if input missing)".
@@ -824,7 +814,7 @@ class ConfigEditorDialog(QDialog):
group_layout.addWidget(defaults_table)
self.merge_rule_details_layout.addRow(group)
self.merge_rule_widgets["defaults_table"] = defaults_table # Store table reference
self.merge_rule_widgets["defaults_table"] = defaults_table
# output_bit_depth: QComboBox (Options: "respect_inputs", "force_8bit", "force_16bit"). Label: "Output Bit Depth".
@@ -1175,18 +1165,6 @@ class ConfigEditorDialog(QDialog):
row += 1
# Removed duplicated methods:
# - create_map_merge_rules_widget (duplicate of lines 684-689)
# - populate_merge_rules_list (duplicate of lines 691-698)
# - display_merge_rule_details (duplicate of lines 699-788)
# - browse_path (duplicate of lines 790-801)
# - pick_color (duplicate of lines 802-807)
# - save_settings (duplicate of lines 808-874)
# - populate_widgets_from_settings (duplicate of lines 875-933)
# - populate_asset_definitions_table (duplicate of lines 935-969)
# - populate_file_type_definitions_table (duplicate of lines 971-1005)
# - populate_image_resolutions_table (duplicate of lines 1006-1018)
# - populate_map_bit_depth_rules_table (duplicate of lines 1020-1027)
# Example usage (for testing the dialog independently)

View File

@@ -1,41 +1,34 @@
from pathlib import Path
# gui/delegates.py
from PySide6.QtWidgets import QStyledItemDelegate, QLineEdit, QComboBox
from PySide6.QtCore import Qt, QModelIndex
# Import Configuration and ConfigurationError
from configuration import Configuration, ConfigurationError, load_base_config # Keep load_base_config for SupplierSearchDelegate
from PySide6.QtWidgets import QListWidgetItem # Import QListWidgetItem
from PySide6.QtWidgets import QListWidgetItem
import json
import logging
import os # Added for path manipulation if needed, though json.dump handles creation
from PySide6.QtWidgets import QCompleter # Added QCompleter
import os
from PySide6.QtWidgets import QCompleter
# Configure logging
log = logging.getLogger(__name__)
SUPPLIERS_CONFIG_PATH = "config/suppliers.json"
class LineEditDelegate(QStyledItemDelegate):
"""Delegate for editing string values using a QLineEdit."""
def createEditor(self, parent, option, index):
# Creates the QLineEdit editor widget used for editing.
editor = QLineEdit(parent)
return editor
def setEditorData(self, editor: QLineEdit, index: QModelIndex):
# Sets the editor's initial data based on the model's data.
# Use EditRole to get the raw data suitable for editing.
value = index.model().data(index, Qt.EditRole)
editor.setText(str(value) if value is not None else "")
def setModelData(self, editor: QLineEdit, model, index: QModelIndex):
# Commits the editor's data back to the model.
value = editor.text()
# Pass the potentially modified text back to the model's setData.
model.setData(index, value, Qt.EditRole)
def updateEditorGeometry(self, editor, option, index):
# Ensures the editor widget is placed correctly within the cell.
editor.setGeometry(option.rect)
@@ -51,10 +44,9 @@ class ComboBoxDelegate(QStyledItemDelegate):
# REMOVED self.main_window store
def createEditor(self, parent, option, index: QModelIndex):
# Creates the QComboBox editor widget.
editor = QComboBox(parent)
column = index.column()
model = index.model() # GET model from index
model = index.model()
# Add a "clear" option first, associating None with it.
editor.addItem("---", None) # UserData = None
@@ -74,8 +66,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
items_keys = model._asset_type_keys # Use cached keys
elif column == COL_ITEM_TYPE:
items_keys = model._file_type_keys # Use cached keys
# else: # Handle other columns if necessary (optional)
# log.debug(f"ComboBoxDelegate applied to unexpected column: {column}")
except Exception as e:
log.error(f"Error getting keys from UnifiedViewModel in ComboBoxDelegate: {e}")
@@ -98,7 +88,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
return editor
def setEditorData(self, editor: QComboBox, index: QModelIndex):
# Sets the combo box's current item based on the model's string data.
# Get the current string value (or None) from the model via EditRole.
value = index.model().data(index, Qt.EditRole) # This should be a string or None
@@ -115,7 +104,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
def setModelData(self, editor: QComboBox, model, index: QModelIndex):
# Commits the selected combo box data (string or None) back to the model.
# Get the UserData associated with the currently selected item.
# This will be the string value or None (for the "---" option).
value = editor.currentData() # This is either the string or None
@@ -123,7 +111,6 @@ class ComboBoxDelegate(QStyledItemDelegate):
model.setData(index, value, Qt.EditRole)
def updateEditorGeometry(self, editor, option, index):
# Ensures the editor widget is placed correctly within the cell.
editor.setGeometry(option.rect)
class SupplierSearchDelegate(QStyledItemDelegate):

View File

@@ -9,7 +9,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 # Import necessary items
from configuration import save_llm_config, ConfigurationError
# For now, define path directly for initial structure
LLM_CONFIG_PATH = "config/llm_settings.json"
@@ -102,10 +102,8 @@ class LLMEditorWidget(QWidget):
def _connect_signals(self):
"""Connect signals to slots."""
# Save button
self.save_button.clicked.connect(self._save_settings)
# Fields triggering unsaved changes
self.prompt_editor.textChanged.connect(self._mark_unsaved)
self.endpoint_url_edit.textChanged.connect(self._mark_unsaved)
self.api_key_edit.textChanged.connect(self._mark_unsaved)
@@ -113,7 +111,6 @@ class LLMEditorWidget(QWidget):
self.temperature_spinbox.valueChanged.connect(self._mark_unsaved)
self.timeout_spinbox.valueChanged.connect(self._mark_unsaved)
# Example management buttons and tab close signal
self.add_example_button.clicked.connect(self._add_example_tab)
self.delete_example_button.clicked.connect(self._delete_current_example_tab)
self.examples_tab_widget.tabCloseRequested.connect(self._remove_example_tab)
@@ -145,7 +142,7 @@ class LLMEditorWidget(QWidget):
example_text = json.dumps(example, indent=4)
example_editor = QTextEdit()
example_editor.setPlainText(example_text)
example_editor.textChanged.connect(self._mark_unsaved) # Connect here
example_editor.textChanged.connect(self._mark_unsaved)
self.examples_tab_widget.addTab(example_editor, f"Example {i+1}")
except TypeError as e:
logger.error(f"Error formatting example {i+1}: {e}. Skipping.")
@@ -277,7 +274,7 @@ class LLMEditorWidget(QWidget):
logger.debug("Adding new example tab.")
new_example_editor = QTextEdit()
new_example_editor.setPlaceholderText("Enter example JSON here...")
new_example_editor.textChanged.connect(self._mark_unsaved) # Connect signal
new_example_editor.textChanged.connect(self._mark_unsaved)
# Determine the next example number
next_example_num = self.examples_tab_widget.count() + 1

View File

@@ -1,5 +1,5 @@
import os
import json # Added for direct config loading
import json
import logging
from pathlib import Path
@@ -8,18 +8,14 @@ from PySide6.QtCore import QObject, Signal, QThread, Slot, QTimer
# --- Backend Imports ---
# Assuming these might be needed based on MainWindow's usage
try:
# Removed load_base_config import
# Removed Configuration import as we load manually now
from configuration import ConfigurationError # Keep error class
from .llm_prediction_handler import LLMPredictionHandler # Backend handler
from rule_structure import SourceRule # For signal emission type hint
except ImportError as e:
logging.getLogger(__name__).critical(f"Failed to import backend modules for LLMInteractionHandler: {e}")
LLMPredictionHandler = None
# load_base_config = None # Removed
ConfigurationError = Exception
SourceRule = None # Define as None if import fails
# Configuration = None # Removed
log = logging.getLogger(__name__)
# Define config file paths relative to this handler's location
@@ -97,7 +93,7 @@ class LLMInteractionHandler(QObject):
def queue_llm_requests_batch(self, requests: list[tuple[str, list | None]]):
"""Adds multiple requests to the LLM processing queue."""
added_count = 0
log.debug(f"Queueing batch. Current queue content: {self.llm_processing_queue}") # ADDED DEBUG LOG
log.debug(f"Queueing batch. Current queue content: {self.llm_processing_queue}")
for input_path, file_list in requests:
is_in_queue = any(item[0] == input_path for item in self.llm_processing_queue)
if not is_in_queue:
@@ -108,7 +104,6 @@ class LLMInteractionHandler(QObject):
if added_count > 0:
log.info(f"Added {added_count} requests to LLM queue. New size: {len(self.llm_processing_queue)}")
# If not currently processing, start the queue
if not self._is_processing:
QTimer.singleShot(0, self._process_next_llm_item)
@@ -269,9 +264,9 @@ class LLMInteractionHandler(QObject):
# log.debug(f"--> Entered LLMPredictionHandler.run() for {self.input_path}")
self.llm_prediction_thread.start()
log.debug(f"LLM prediction thread start() called for {input_path_str}. Is running: {self.llm_prediction_thread.isRunning()}") # ADDED DEBUG LOG
log.debug(f"LLM prediction thread start() called for {input_path_str}. Is running: {self.llm_prediction_thread.isRunning()}")
# Log success *after* start() is called successfully
log.debug(f"Successfully initiated LLM prediction thread for {input_path_str}.") # MOVED/REWORDED LOG
log.debug(f"Successfully initiated LLM prediction thread for {input_path_str}.")
except Exception as e:
# --- Handle errors during setup/start ---
@@ -357,8 +352,6 @@ class LLMInteractionHandler(QObject):
# Pass the potentially None file_list. _start_llm_prediction handles extraction if needed.
self._start_llm_prediction(next_dir, file_list=file_list)
# --- DO NOT pop item here. Item is popped in _handle_llm_result or _handle_llm_error ---
# Log message moved into the try block of _start_llm_prediction
# log.debug(f"Successfully started LLM prediction thread for {next_dir}. Item remains in queue until finished.")
except Exception as e:
# This block now catches errors from _start_llm_prediction itself
log.exception(f"Error occurred *during* _start_llm_prediction call for {next_dir}: {e}")

View File

@@ -1,25 +1,23 @@
import os
import json
import requests
import re # Added import for regex
import logging # Add logging
from pathlib import Path # Add Path for basename
from PySide6.QtCore import QObject, Slot # Keep QObject for parent type hint, Slot for cancel if kept separate
import re
import logging
from pathlib import Path
from PySide6.QtCore import QObject, Slot
# Removed Signal, QThread as they are handled by BasePredictionHandler or caller
from typing import List, Dict, Any
# Assuming rule_structure defines SourceRule, AssetRule, FileRule etc.
# Adjust the import path if necessary based on project structure
from rule_structure import SourceRule, AssetRule, FileRule # Ensure AssetRule and FileRule are imported
from rule_structure import SourceRule, AssetRule, FileRule
# Assuming configuration loads app_settings.json
# Adjust the import path if necessary
# Removed Configuration import
# from configuration import Configuration
# from configuration import load_base_config # No longer needed here
from .base_prediction_handler import BasePredictionHandler # Import base class
from .base_prediction_handler import BasePredictionHandler
log = logging.getLogger(__name__) # Setup logger
log = logging.getLogger(__name__)
class LLMPredictionHandler(BasePredictionHandler):
"""
@@ -42,8 +40,8 @@ class LLMPredictionHandler(BasePredictionHandler):
"""
super().__init__(input_source_identifier, parent)
# input_source_identifier is stored by the base class as self.input_source_identifier
self.file_list = file_list # Store the provided relative file list
self.settings = settings # Store the settings dictionary
self.file_list = file_list
self.settings = settings
# Access LLM settings via self.settings['key']
# _is_running and _is_cancelled are handled by the base class
@@ -68,10 +66,9 @@ class LLMPredictionHandler(BasePredictionHandler):
log.info(f"Performing LLM prediction for: {self.input_source_identifier}")
base_name = Path(self.input_source_identifier).name
# Use the file list passed during initialization
if not self.file_list:
log.warning(f"No files provided for LLM prediction for {self.input_source_identifier}. Returning empty list.")
self.status_update.emit(f"No files found for {base_name}.") # Use base signal
self.status_update.emit(f"No files found for {base_name}.")
return [] # Return empty list, not an error
# Check for cancellation before preparing prompt
@@ -82,7 +79,6 @@ class LLMPredictionHandler(BasePredictionHandler):
# --- Prepare Prompt ---
self.status_update.emit(f"Preparing LLM input for {base_name}...")
try:
# Pass relative file list
prompt = self._prepare_prompt(self.file_list)
except Exception as e:
log.exception("Error preparing LLM prompt.")
@@ -128,13 +124,11 @@ class LLMPredictionHandler(BasePredictionHandler):
"""
Prepares the full prompt string to send to the LLM using stored settings.
"""
# Access settings via the settings dictionary
prompt_template = self.settings.get('predictor_prompt')
if not prompt_template:
raise ValueError("LLM predictor prompt template content is empty or missing in settings.")
# Access definitions and examples directly from the settings dictionary
asset_defs = json.dumps(self.settings.get('asset_type_definitions', {}), indent=4)
# Combine file type defs and examples (assuming structure from Configuration class)
file_type_defs_combined = {}
@@ -151,7 +145,6 @@ class LLMPredictionHandler(BasePredictionHandler):
# Format *relative* file list as a single string with newlines
file_list_str = "\n".join(relative_file_list)
# Replace placeholders
prompt = prompt_template.replace('{ASSET_TYPE_DEFINITIONS}', asset_defs)
prompt = prompt.replace('{FILE_TYPE_DEFINITIONS}', file_defs)
prompt = prompt.replace('{EXAMPLE_INPUT_OUTPUT_PAIRS}', examples)
@@ -174,51 +167,39 @@ class LLMPredictionHandler(BasePredictionHandler):
ValueError: If the endpoint URL is not configured or the response is invalid.
requests.exceptions.RequestException: For other request-related errors.
"""
endpoint_url = self.settings.get('endpoint_url') # Get from settings dict
endpoint_url = self.settings.get('endpoint_url')
if not endpoint_url:
raise ValueError("LLM endpoint URL is not configured in settings.")
headers = {
"Content-Type": "application/json",
}
api_key = self.settings.get('api_key') # Get from settings dict
api_key = self.settings.get('api_key')
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
# Construct payload based on OpenAI Chat Completions format
payload = {
# Use configured model name from settings dict
"model": self.settings.get('model_name', 'local-model'),
"messages": [{"role": "user", "content": prompt}],
# Use configured temperature from settings dict
"temperature": self.settings.get('temperature', 0.5),
# Add max_tokens if needed/configurable:
# "max_tokens": self.settings.get('max_tokens'), # Example if added to settings
# Ensure the LLM is instructed to return JSON in the prompt itself
# Some models/endpoints support a specific json mode:
# "response_format": { "type": "json_object" } # If supported by endpoint
}
# Status update emitted by _perform_prediction before calling this
# self.status_update.emit(f"Sending request to LLM at {endpoint_url}...")
print(f"--- Calling LLM API: {endpoint_url} ---")
# print(f"--- Payload Preview ---\n{json.dumps(payload, indent=2)[:500]}...\n--- END Payload Preview ---")
# Note: Exceptions raised here (Timeout, RequestException, ValueError)
# will be caught by the _perform_prediction method's handler.
# Make the POST request with a timeout
response = requests.post(
endpoint_url,
headers=headers,
json=payload,
timeout=self.settings.get('request_timeout', 120) # Use settings dict (with default)
timeout=self.settings.get('request_timeout', 120)
)
response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx)
# Parse the JSON response
response_data = response.json()
# print(f"--- LLM Raw Response ---\n{json.dumps(response_data, indent=2)}\n--- END Raw Response ---") # Debugging
# Extract content - structure depends on the API (OpenAI format assumed)
if "choices" in response_data and len(response_data["choices"]) > 0:
@@ -243,10 +224,7 @@ class LLMPredictionHandler(BasePredictionHandler):
# will be caught by the _perform_prediction method's handler.
# --- Sanitize Input String ---
clean_json_str = llm_response_json_str.strip()
# 1. Remove multi-line /* */ comments
clean_json_str = re.sub(r'/\*.*?\*/', '', clean_json_str, flags=re.DOTALL)
clean_json_str = re.sub(r'/\*.*?\*/', '', llm_response_json_str.strip(), flags=re.DOTALL)
# 2. Remove single-line // comments (handle potential URLs carefully)
# Only remove // if it's likely a comment (e.g., whitespace before it,
@@ -298,14 +276,12 @@ class LLMPredictionHandler(BasePredictionHandler):
# 3. Remove markdown code fences
clean_json_str = clean_json_str.strip()
if clean_json_str.startswith("```json"):
clean_json_str = clean_json_str[7:] # Remove ```json\n
clean_json_str = clean_json_str[7:].strip()
if clean_json_str.endswith("```"):
clean_json_str = clean_json_str[:-3] # Remove ```
clean_json_str = clean_json_str.strip() # Remove any extra whitespace
clean_json_str = clean_json_str[:-3].strip()
# 4. Remove <think> tags (just in case)
clean_json_str = re.sub(r'<think>.*?</think>', '', clean_json_str, flags=re.DOTALL | re.IGNORECASE)
clean_json_str = clean_json_str.strip()
clean_json_str = re.sub(r'<think>.*?</think>', '', clean_json_str, flags=re.DOTALL | re.IGNORECASE).strip()
# --- Parse Sanitized JSON ---
try:
@@ -327,7 +303,6 @@ class LLMPredictionHandler(BasePredictionHandler):
# --- Prepare for Rule Creation ---
source_rule = SourceRule(input_path=self.input_source_identifier)
# Get valid types directly from the settings dictionary
valid_asset_types = list(self.settings.get('asset_type_definitions', {}).keys())
valid_file_types = list(self.settings.get('file_type_definitions', {}).keys())
asset_rules_map: Dict[str, AssetRule] = {} # Maps group_name to AssetRule
@@ -369,17 +344,17 @@ class LLMPredictionHandler(BasePredictionHandler):
# --- 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 # Skip files that cannot be grouped
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 # Skip files belonging to unclassified groups
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 # Skip files belonging to groups with invalid types
continue
# --- Construct Absolute Path ---
try:
@@ -400,14 +375,13 @@ class LLMPredictionHandler(BasePredictionHandler):
asset_rule = AssetRule(asset_name=group_name, asset_type=asset_type)
source_rule.assets.append(asset_rule)
asset_rules_map[group_name] = asset_rule
# else: use existing asset_rule
# --- 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
target_asset_name_override=group_name, # Use the group name
target_asset_name_override=group_name,
output_format_override=None,
is_gloss_source=False,
resolution_override=None,
@@ -422,5 +396,3 @@ class LLMPredictionHandler(BasePredictionHandler):
log.warning(f"LLM prediction for '{self.input_source_identifier}' resulted in zero valid assets after parsing.")
return [source_rule] # Return list containing the single SourceRule
# Removed conceptual example usage comments

View File

@@ -1,4 +1,3 @@
# gui/log_console_widget.py
import logging
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QTextEdit, QLabel, QSizePolicy
@@ -18,26 +17,19 @@ class LogConsoleWidget(QWidget):
def _init_ui(self):
"""Initializes the UI elements for the log console."""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 5, 0, 0) # Add some top margin
layout.setContentsMargins(0, 5, 0, 0)
log_console_label = QLabel("Log Console:")
self.log_console_output = QTextEdit()
self.log_console_output.setReadOnly(True)
# self.log_console_output.setMaximumHeight(150) # Let the parent layout control height
self.log_console_output.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Allow vertical expansion
layout.addWidget(log_console_label)
layout.addWidget(self.log_console_output)
# Initially hidden, visibility controlled by MainWindow
self.setVisible(False)
@Slot(str)
def _append_log_message(self, message):
"""Appends a log message to the QTextEdit console."""
self.log_console_output.append(message)
# Auto-scroll to the bottom
self.log_console_output.verticalScrollBar().setValue(self.log_console_output.verticalScrollBar().maximum())
# Note: Visibility is controlled externally via setVisible(),
# so the _toggle_log_console_visibility slot is not needed here.

View File

@@ -4,10 +4,9 @@ import json
import logging
import time
from pathlib import Path
import functools # Ensure functools is imported directly for partial
from functools import partial
from PySide6.QtWidgets import QApplication # Added for processEvents
from PySide6.QtWidgets import QApplication
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTableView,
QPushButton, QComboBox, QTableWidget, QTableWidgetItem, QHeaderView,
@@ -16,27 +15,20 @@ from PySide6.QtWidgets import (
QFormLayout, QGroupBox, QAbstractItemView, QSizePolicy, QTreeView, QMenu
)
from PySide6.QtCore import Qt, Signal, Slot, QPoint, QModelIndex, QTimer
from PySide6.QtGui import QColor, QAction, QPalette, QClipboard, QGuiApplication # Added QGuiApplication for clipboard
from PySide6.QtGui import QColor, QAction, QPalette, QClipboard, QGuiApplication
# --- Local GUI Imports ---
# Import delegates and models needed by the panel
from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate, ItemTypeSearchDelegate # Added ItemTypeSearchDelegate
from .unified_view_model import UnifiedViewModel # Assuming UnifiedViewModel is passed in
from .delegates import LineEditDelegate, ComboBoxDelegate, SupplierSearchDelegate, ItemTypeSearchDelegate
from .unified_view_model import UnifiedViewModel
# --- Backend Imports ---
# Import Rule Structures if needed for context menus etc.
from rule_structure import SourceRule, AssetRule, FileRule
# Import config loading if defaults are needed directly here (though better passed from MainWindow)
# Import configuration directly for PRESETS_DIR access
import configuration
try:
from configuration import ConfigurationError, load_base_config
except ImportError:
ConfigurationError = Exception
load_base_config = None
# Define PRESETS_DIR fallback if configuration module fails to load entirely
class configuration:
PRESETS_DIR = "Presets" # Fallback path
PRESETS_DIR = "Presets"
log = logging.getLogger(__name__)
@@ -49,27 +41,20 @@ class MainPanelWidget(QWidget):
- Processing controls (Start, Cancel, Clear, LLM Re-interpret)
"""
# --- Signals Emitted by the Panel ---
# Request to add new input paths (e.g., from drag/drop handled by MainWindow)
# add_paths_requested = Signal(list) # Maybe not needed if MainWindow handles drop directly
# Request to start the main processing job
process_requested = Signal(dict) # Emits dict with settings: output_dir, overwrite, workers, blender_enabled, ng_path, mat_path
process_requested = Signal(dict)
# Request to cancel the ongoing processing job
cancel_requested = Signal()
# Request to clear the current queue/view
clear_queue_requested = Signal()
# Request to re-interpret selected items using LLM
llm_reinterpret_requested = Signal(list) # Emits list of source paths
preset_reinterpret_requested = Signal(list, str) # Emits list[source_paths], preset_name
llm_reinterpret_requested = Signal(list)
preset_reinterpret_requested = Signal(list, str)
# Notify when the output directory changes
output_dir_changed = Signal(str)
# Notify when Blender settings change
blender_settings_changed = Signal(bool, str, str) # enabled, ng_path, mat_path
blender_settings_changed = Signal(bool, str, str)
def __init__(self, unified_model: UnifiedViewModel, parent=None, file_type_keys: list[str] | None = None):
"""
@@ -83,9 +68,8 @@ class MainPanelWidget(QWidget):
super().__init__(parent)
self.unified_model = unified_model
self.file_type_keys = file_type_keys if file_type_keys else []
self.llm_processing_active = False # Track if LLM is running (set by MainWindow)
self.llm_processing_active = False
# Get project root for resolving default paths if needed here
script_dir = Path(__file__).parent
self.project_root = script_dir.parent
@@ -95,9 +79,8 @@ class MainPanelWidget(QWidget):
def _setup_ui(self):
"""Sets up the UI elements for the panel."""
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(5, 5, 5, 5) # Reduce margins
main_layout.setContentsMargins(5, 5, 5, 5)
# --- Output Directory Selection ---
output_layout = QHBoxLayout()
self.output_dir_label = QLabel("Output Directory:")
self.output_path_edit = QLineEdit()
@@ -107,8 +90,6 @@ class MainPanelWidget(QWidget):
output_layout.addWidget(self.browse_output_button)
main_layout.addLayout(output_layout)
# --- Set Initial Output Path (Copied from MainWindow) ---
# Consider passing this default path from MainWindow instead of reloading config here
if load_base_config:
try:
base_config = load_base_config()
@@ -127,36 +108,27 @@ class MainPanelWidget(QWidget):
self.output_path_edit.setText("")
# --- Unified View Setup ---
self.unified_view = QTreeView()
self.unified_view.setModel(self.unified_model) # Set the passed-in model
self.unified_view.setModel(self.unified_model)
# Instantiate Delegates
lineEditDelegate = LineEditDelegate(self.unified_view)
# ComboBoxDelegate needs access to MainWindow's get_llm_source_preset_name,
# which might require passing MainWindow or a callback here.
# For now, let's assume it can work without it or we adapt it later.
# TODO: Revisit ComboBoxDelegate dependency
comboBoxDelegate = ComboBoxDelegate(self) # Pass only parent (self)
supplierSearchDelegate = SupplierSearchDelegate(self) # Pass parent
# Pass file_type_keys to ItemTypeSearchDelegate
comboBoxDelegate = ComboBoxDelegate(self)
supplierSearchDelegate = SupplierSearchDelegate(self)
itemTypeSearchDelegate = ItemTypeSearchDelegate(self.file_type_keys, self)
# Set Delegates for Columns
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_SUPPLIER, supplierSearchDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ASSET_TYPE, comboBoxDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_TARGET_ASSET, lineEditDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, itemTypeSearchDelegate) # Use ItemTypeSearchDelegate
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, lineEditDelegate) # Assign LineEditDelegate for AssetRule names
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_ITEM_TYPE, itemTypeSearchDelegate)
self.unified_view.setItemDelegateForColumn(UnifiedViewModel.COL_NAME, lineEditDelegate)
# Configure View Appearance
self.unified_view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self.unified_view.setAlternatingRowColors(True)
self.unified_view.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.unified_view.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed)
self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) # Allow multi-select for re-interpret
self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
# Configure Header Resize Modes
header = self.unified_view.header()
header.setStretchLastSection(False)
header.setSectionResizeMode(UnifiedViewModel.COL_NAME, QHeaderView.ResizeMode.ResizeToContents)
@@ -165,31 +137,23 @@ class MainPanelWidget(QWidget):
header.setSectionResizeMode(UnifiedViewModel.COL_ASSET_TYPE, QHeaderView.ResizeMode.ResizeToContents)
header.setSectionResizeMode(UnifiedViewModel.COL_ITEM_TYPE, QHeaderView.ResizeMode.ResizeToContents)
# Enable custom context menu
self.unified_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
# --- Enable Drag and Drop ---
self.unified_view.setDragEnabled(True)
self.unified_view.setAcceptDrops(True)
self.unified_view.setDropIndicatorShown(True)
self.unified_view.setDefaultDropAction(Qt.MoveAction)
# Use InternalMove for handling drops within the model itself
self.unified_view.setDragDropMode(QAbstractItemView.InternalMove)
# Ensure ExtendedSelection is set (already done above, but good practice)
self.unified_view.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
# --- End Drag and Drop ---
# Add the Unified View to the main layout
main_layout.addWidget(self.unified_view, 1) # Give it stretch factor 1
main_layout.addWidget(self.unified_view, 1)
# --- Progress Bar ---
self.progress_bar = QProgressBar()
self.progress_bar.setValue(0)
self.progress_bar.setTextVisible(True)
self.progress_bar.setFormat("Idle") # Initial format
self.progress_bar.setFormat("Idle")
main_layout.addWidget(self.progress_bar)
# --- Blender Integration Controls ---
blender_group = QGroupBox("Blender Post-Processing")
blender_layout = QVBoxLayout(blender_group)
@@ -197,7 +161,6 @@ class MainPanelWidget(QWidget):
self.blender_integration_checkbox.setToolTip("If checked, attempts to run create_nodegroups.py and create_materials.py in Blender.")
blender_layout.addWidget(self.blender_integration_checkbox)
# Nodegroup Blend Path
nodegroup_layout = QHBoxLayout()
nodegroup_layout.addWidget(QLabel("Nodegroup .blend:"))
self.nodegroup_blend_path_input = QLineEdit()
@@ -207,7 +170,6 @@ class MainPanelWidget(QWidget):
nodegroup_layout.addWidget(self.browse_nodegroup_blend_button)
blender_layout.addLayout(nodegroup_layout)
# Materials Blend Path
materials_layout = QHBoxLayout()
materials_layout.addWidget(QLabel("Materials .blend:"))
self.materials_blend_path_input = QLineEdit()
@@ -217,8 +179,6 @@ class MainPanelWidget(QWidget):
materials_layout.addWidget(self.browse_materials_blend_button)
blender_layout.addLayout(materials_layout)
# Initialize paths from config (Copied from MainWindow)
# Consider passing these defaults from MainWindow
if load_base_config:
try:
base_config = load_base_config()
@@ -234,15 +194,13 @@ class MainPanelWidget(QWidget):
log.warning("MainPanelWidget: load_base_config not available to set default Blender paths.")
# Disable Blender controls initially if checkbox is unchecked
self.nodegroup_blend_path_input.setEnabled(False)
self.browse_nodegroup_blend_button.setEnabled(False)
self.materials_blend_path_input.setEnabled(False)
self.browse_materials_blend_button.setEnabled(False)
main_layout.addWidget(blender_group) # Add the group box to the main layout
main_layout.addWidget(blender_group)
# --- Bottom Controls ---
bottom_controls_layout = QHBoxLayout()
self.overwrite_checkbox = QCheckBox("Overwrite Existing")
self.overwrite_checkbox.setToolTip("If checked, existing output folders for processed assets will be deleted and replaced.")
@@ -263,11 +221,6 @@ class MainPanelWidget(QWidget):
bottom_controls_layout.addWidget(self.workers_spinbox)
bottom_controls_layout.addStretch(1)
# --- LLM Re-interpret Button (Removed, functionality moved to context menu) ---
# self.llm_reinterpret_button = QPushButton("Re-interpret Selected with LLM")
# self.llm_reinterpret_button.setToolTip("Re-run LLM interpretation on the selected source items.")
# self.llm_reinterpret_button.setEnabled(False) # Initially disabled
# bottom_controls_layout.addWidget(self.llm_reinterpret_button)
self.clear_queue_button = QPushButton("Clear Queue")
self.start_button = QPushButton("Start Processing")
@@ -281,37 +234,30 @@ class MainPanelWidget(QWidget):
def _connect_signals(self):
"""Connect internal UI signals to slots or emit panel signals."""
# Output Directory
self.browse_output_button.clicked.connect(self._browse_for_output_directory)
self.output_path_edit.editingFinished.connect(self._on_output_path_changed) # Emit signal when user finishes editing
self.output_path_edit.editingFinished.connect(self._on_output_path_changed)
# Unified View
self.unified_view.customContextMenuRequested.connect(self._show_unified_view_context_menu)
# Blender Controls
self.blender_integration_checkbox.toggled.connect(self._toggle_blender_controls)
self.browse_nodegroup_blend_button.clicked.connect(self._browse_for_nodegroup_blend)
self.browse_materials_blend_button.clicked.connect(self._browse_for_materials_blend)
# Emit signal when paths change
self.nodegroup_blend_path_input.editingFinished.connect(self._emit_blender_settings_changed)
self.materials_blend_path_input.editingFinished.connect(self._emit_blender_settings_changed)
self.blender_integration_checkbox.toggled.connect(self._emit_blender_settings_changed)
# Bottom Buttons
self.clear_queue_button.clicked.connect(self.clear_queue_requested) # Emit signal directly
self.start_button.clicked.connect(self._on_start_processing_clicked) # Use slot to gather data
self.cancel_button.clicked.connect(self.cancel_requested) # Emit signal directly
# self.llm_reinterpret_button.clicked.connect(self._on_llm_reinterpret_clicked) # Removed button connection
self.clear_queue_button.clicked.connect(self.clear_queue_requested)
self.start_button.clicked.connect(self._on_start_processing_clicked)
self.cancel_button.clicked.connect(self.cancel_requested)
# --- Slots for Internal UI Logic ---
@Slot()
def _browse_for_output_directory(self):
"""Opens a dialog to select the output directory."""
current_path = self.output_path_edit.text()
if not current_path or not Path(current_path).is_dir():
current_path = str(self.project_root) # Use project root as fallback
current_path = str(self.project_root)
directory = QFileDialog.getExistingDirectory(
self,
@@ -321,7 +267,7 @@ class MainPanelWidget(QWidget):
)
if directory:
self.output_path_edit.setText(directory)
self._on_output_path_changed() # Explicitly call the change handler
self._on_output_path_changed()
@Slot()
def _on_output_path_changed(self):
@@ -335,7 +281,6 @@ class MainPanelWidget(QWidget):
self.browse_nodegroup_blend_button.setEnabled(checked)
self.materials_blend_path_input.setEnabled(checked)
self.browse_materials_blend_button.setEnabled(checked)
# No need to emit here, the checkbox toggle signal is connected separately
def _browse_for_blend_file(self, line_edit_widget: QLineEdit):
"""Opens a dialog to select a .blend file and updates the line edit."""
@@ -350,7 +295,7 @@ class MainPanelWidget(QWidget):
)
if file_path:
line_edit_widget.setText(file_path)
line_edit_widget.editingFinished.emit() # Trigger editingFinished to emit change signal
line_edit_widget.editingFinished.emit()
@Slot()
def _browse_for_nodegroup_blend(self):
@@ -376,7 +321,6 @@ class MainPanelWidget(QWidget):
QMessageBox.warning(self, "Missing Output Directory", "Please select an output directory.")
return
# Basic validation (MainWindow should do more thorough validation)
try:
Path(output_dir).mkdir(parents=True, exist_ok=True)
except Exception as e:
@@ -393,8 +337,6 @@ class MainPanelWidget(QWidget):
}
self.process_requested.emit(settings)
# Removed _update_llm_reinterpret_button_state as the button is removed.
# Context menu actions will handle their own enabled state or rely on _on_llm_reinterpret_clicked checks.
def _get_unique_source_dirs_from_selection(self, selected_indexes: list[QModelIndex]) -> set[str]:
"""
@@ -407,19 +349,17 @@ class MainPanelWidget(QWidget):
log.error("Unified view model not found.")
return unique_source_dirs
processed_source_paths = set() # To avoid processing duplicates if multiple cells of the same source are selected
processed_source_paths = set()
for index in selected_indexes:
if not index.isValid():
continue
# Use the model's getItem method for robust node retrieval
item_node = model.getItem(index)
source_rule_node = None
# Find the parent SourceRule node by traversing upwards using the index
source_rule_node = None
current_index = index # Start with the index of the selected item
current_index = index
while current_index.isValid():
current_item = model.getItem(current_index)
if isinstance(current_item, SourceRule):
@@ -429,11 +369,9 @@ class MainPanelWidget(QWidget):
# If loop finishes without break, source_rule_node remains None
if source_rule_node:
# Use input_path attribute as defined in SourceRule
source_path = getattr(source_rule_node, 'input_path', None)
if source_path and source_path not in processed_source_paths:
source_path_obj = Path(source_path)
# Check if it's a directory or a zip file (common input types)
if source_path_obj.is_dir() or (source_path_obj.is_file() and source_path_obj.suffix.lower() == '.zip'):
log.debug(f"Identified source path for re-interpretation: {source_path}")
unique_source_dirs.add(source_path)
@@ -471,9 +409,7 @@ class MainPanelWidget(QWidget):
def _on_reinterpret_preset_selected(self, preset_name: str, index: QModelIndex):
"""Handles the selection of a preset from the re-interpret context sub-menu."""
log.info(f"Preset re-interpretation requested: Preset='{preset_name}', Index='{index.row()},{index.column()}'")
# Reuse logic from _on_llm_reinterpret_clicked to get selected source paths
selected_indexes = self.unified_view.selectionModel().selectedIndexes()
# Use the helper method to get all selected source paths, not just the one clicked
unique_source_dirs = self._get_unique_source_dirs_from_selection(selected_indexes)
if not unique_source_dirs:
@@ -494,12 +430,11 @@ class MainPanelWidget(QWidget):
model = self.unified_view.model()
if not model: return
item_node = model.getItem(index) # Use model's method
item_node = model.getItem(index)
# Find the SourceRule node associated with the clicked index
# Find the SourceRule node associated with the clicked index
source_rule_node = None
current_index = index # Start with the clicked index
current_index = index
while current_index.isValid():
current_item = model.getItem(current_index)
if isinstance(current_item, SourceRule):
@@ -510,11 +445,9 @@ class MainPanelWidget(QWidget):
menu = QMenu(self)
# --- Re-interpret Menu ---
if source_rule_node: # Only show if we clicked on or within a SourceRule item
reinterpet_menu = menu.addMenu("Re-interpret selected source")
# Get Preset Names (Option B: Direct File Listing)
preset_names = []
try:
presets_dir = configuration.PRESETS_DIR
@@ -523,17 +456,15 @@ class MainPanelWidget(QWidget):
if filename.endswith(".json") and filename != "_template.json":
preset_name = os.path.splitext(filename)[0]
preset_names.append(preset_name)
preset_names.sort() # Sort alphabetically
preset_names.sort()
else:
log.warning(f"Presets directory not found or not a directory: {presets_dir}")
except Exception as e:
log.exception(f"Error listing presets in {configuration.PRESETS_DIR}: {e}")
# Populate Sub-Menu with Presets
if preset_names:
for preset_name in preset_names:
preset_action = QAction(preset_name, self)
# Pass the preset name and the *clicked* index (though the slot will get all selected)
preset_action.triggered.connect(functools.partial(self._on_reinterpret_preset_selected, preset_name, index))
reinterpet_menu.addAction(preset_action)
else:
@@ -542,39 +473,31 @@ class MainPanelWidget(QWidget):
reinterpet_menu.addAction(no_presets_action)
# Add LLM Option (Static)
reinterpet_menu.addSeparator()
llm_action = QAction("LLM", self)
# Connect to the existing slot that handles LLM re-interpretation requests
llm_action.triggered.connect(self._on_llm_reinterpret_clicked)
# Disable if LLM is currently processing
llm_action.setEnabled(not self.llm_processing_active)
reinterpet_menu.addAction(llm_action)
menu.addSeparator() # Separator before other actions
menu.addSeparator()
# --- Other Actions (like Copy LLM Example) ---
if source_rule_node: # Check again if it's a source item for this action
copy_llm_example_action = QAction("Copy LLM Example to Clipboard", self)
copy_llm_example_action.setToolTip("Copies a JSON structure representing the input files and predicted output, suitable for LLM examples.")
# Pass the found source_rule_node
copy_llm_example_action.triggered.connect(lambda: self._copy_llm_example_to_clipboard(source_rule_node))
menu.addAction(copy_llm_example_action)
# menu.addSeparator() # Removed redundant separator
# Add other general actions here if needed...
if not menu.isEmpty():
menu.exec(self.unified_view.viewport().mapToGlobal(point))
@Slot(SourceRule) # Accept SourceRule directly
@Slot(SourceRule)
def _copy_llm_example_to_clipboard(self, source_rule_node: SourceRule | None):
"""Copies a JSON structure for the given SourceRule node to the clipboard."""
if not source_rule_node:
log.warning(f"No SourceRule node provided to copy LLM example.")
return
# We already have the source_rule_node passed in
source_rule: SourceRule = source_rule_node
log.info(f"Attempting to generate LLM example JSON for source: {source_rule.input_path}")
@@ -612,11 +535,10 @@ class MainPanelWidget(QWidget):
try:
json_string = json.dumps(llm_example, indent=2)
clipboard = QGuiApplication.clipboard() # Use QGuiApplication
clipboard = QGuiApplication.clipboard()
if clipboard:
clipboard.setText(json_string)
log.info(f"Copied LLM example JSON to clipboard for source: {source_rule.input_path}")
# Cannot show status bar message here
else:
log.error("Failed to get system clipboard.")
except Exception as e:
@@ -630,10 +552,10 @@ class MainPanelWidget(QWidget):
"""Updates the progress bar display."""
if total_count > 0:
percentage = int((current_count / total_count) * 100)
log.debug(f"Updating progress bar: current={current_count}, total={total_count}, calculated_percentage={percentage}") # DEBUG LOG
log.debug(f"Updating progress bar: current={current_count}, total={total_count}, calculated_percentage={percentage}")
self.progress_bar.setValue(percentage)
self.progress_bar.setFormat(f"%p% ({current_count}/{total_count})")
QApplication.processEvents() # Force GUI update
QApplication.processEvents()
else:
self.progress_bar.setValue(0)
self.progress_bar.setFormat("0/0")
@@ -642,7 +564,6 @@ class MainPanelWidget(QWidget):
def set_progress_bar_text(self, text: str):
"""Sets the text format of the progress bar."""
self.progress_bar.setFormat(text)
# Reset value if setting text like "Idle" or "Waiting..."
if not "%" in text:
self.progress_bar.setValue(0)
@@ -650,7 +571,6 @@ class MainPanelWidget(QWidget):
@Slot(bool)
def set_controls_enabled(self, enabled: bool):
"""Enables or disables controls within the panel."""
# Enable/disable most controls based on the 'enabled' flag
self.output_path_edit.setEnabled(enabled)
self.browse_output_button.setEnabled(enabled)
self.unified_view.setEnabled(enabled)
@@ -670,8 +590,6 @@ class MainPanelWidget(QWidget):
self.materials_blend_path_input.setEnabled(blender_paths_enabled)
self.browse_materials_blend_button.setEnabled(blender_paths_enabled)
# LLM button removed, no need to update its state here.
# Context menu actions enable/disable themselves based on context (e.g., llm_processing_active).
@Slot(bool)
@@ -696,11 +614,11 @@ class MainPanelWidget(QWidget):
# No button state to update directly, but context menu will check this flag when built.
# TODO: Add method to get current output path if needed by MainWindow before processing
def get_output_directory(self) -> str:
def get_output_directory() -> str:
return self.output_path_edit.text().strip()
# TODO: Add method to get current Blender settings if needed by MainWindow before processing
def get_blender_settings(self) -> dict:
def get_blender_settings() -> dict:
return {
"enabled": self.blender_integration_checkbox.isChecked(),
"nodegroup_blend_path": self.nodegroup_blend_path_input.text(),
@@ -708,16 +626,14 @@ class MainPanelWidget(QWidget):
}
# TODO: Add method to get current worker count if needed by MainWindow before processing
def get_worker_count(self) -> int:
def get_worker_count() -> int:
return self.workers_spinbox.value()
# TODO: Add method to get current overwrite setting if needed by MainWindow before processing
def get_overwrite_setting(self) -> bool:
def get_overwrite_setting() -> bool:
return self.overwrite_checkbox.isChecked()
# --- Delegate Dependency ---
# This method might be needed by ComboBoxDelegate if it relies on MainWindow's logic
def get_llm_source_preset_name(self) -> str | None:
def get_llm_source_preset_name() -> str | None:
"""
Placeholder for providing context to delegates.
Ideally, the required info (like last preset name) should be passed

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,12 @@
# gui/rule_based_prediction_handler.py
import logging
from pathlib import Path
import time
import os
import re # Import regex
import tempfile # Added for temporary extraction directory
import zipfile # Added for zip file handling
# import patoolib # Potential import for rar/7z - Add later if zip works
from collections import defaultdict, Counter # Added Counter
from typing import List, Dict, Any # For type hinting
import re
import tempfile
import zipfile
from collections import defaultdict, Counter
from typing import List, Dict, Any
# --- PySide6 Imports ---
from PySide6.QtCore import QObject, Slot # Keep QObject for parent type hint, Slot for classify_files if kept as method
@@ -22,30 +20,23 @@ if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
try:
from configuration import Configuration, ConfigurationError # load_base_config might not be needed here
from configuration import Configuration, ConfigurationError
from rule_structure import SourceRule, AssetRule, FileRule
from .base_prediction_handler import BasePredictionHandler # Import the base class
from .base_prediction_handler import BasePredictionHandler
BACKEND_AVAILABLE = True
except ImportError as e:
# Update error message source
print(f"ERROR (RuleBasedPredictionHandler): Failed to import backend/config/base modules: {e}")
# Define placeholders if imports fail
Configuration = None
load_base_config = None # Placeholder
load_base_config = None
ConfigurationError = Exception
# AssetProcessingError = Exception
SourceRule, AssetRule, FileRule = (None,)*3 # Placeholder for rule structures
# Removed: AssetType, ItemType = (None,)*2 # Placeholder for types
# Removed: app_config = None # Placeholder for config
SourceRule, AssetRule, FileRule = (None,)*3
BACKEND_AVAILABLE = False
log = logging.getLogger(__name__)
# Basic config if logger hasn't been set up elsewhere
if not log.hasHandlers():
logging.basicConfig(level=logging.INFO, format='%(levelname)s (RuleBasedPredictHandler): %(message)s')
# Helper function for classification (can be moved outside class if preferred)
def classify_files(file_list: List[str], config: Configuration) -> Dict[str, List[Dict[str, Any]]]:
"""
Analyzes a list of files based on configuration rules using a two-pass approach
@@ -71,16 +62,15 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
Returns an empty dict if classification fails or no files are provided.
"""
temp_grouped_files = defaultdict(list)
extra_files_to_associate = [] # Store tuples: (file_path_str, filename) for Pass 2 association
primary_asset_names = set() # Store asset names derived *only* from primary map files (populated in Pass 1)
primary_assignments = set() # Stores tuples: (asset_name, target_type) (populated *only* in Pass 1)
processed_in_pass1 = set() # Keep track of files handled in Pass 1
extra_files_to_associate = []
primary_asset_names = set()
primary_assignments = set()
processed_in_pass1 = set()
# --- Validation ---
if not file_list or not config:
log.warning("Classification skipped: Missing file list or config.")
return {}
# Access compiled regex directly from the config object
if not hasattr(config, 'compiled_map_keyword_regex') or not config.compiled_map_keyword_regex:
log.warning("Classification skipped: Missing compiled map keyword regex in config.")
if not hasattr(config, 'compiled_extra_regex'):
@@ -143,12 +133,10 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
if match:
log.debug(f"PASS 1: File '{filename}' matched PRIORITIZED bit depth variant for type '{target_type}'.")
matched_item_type = target_type
is_gloss_flag = False # Bit depth variants are typically not gloss
is_gloss_flag = False
# Check if primary already assigned (safety for overlapping patterns)
if (asset_name, matched_item_type) in primary_assignments:
log.warning(f"PASS 1: Primary assignment ({asset_name}, {matched_item_type}) already exists. File '{filename}' will be handled in Pass 2.")
# Don't process here, let Pass 2 handle it as a general map or extra
else:
primary_assignments.add((asset_name, matched_item_type))
log.debug(f" PASS 1: Added primary assignment: ({asset_name}, {matched_item_type})")
@@ -163,9 +151,6 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
processed_in_pass1.add(file_path_str)
processed = True
break # Stop checking other variant patterns for this file
# Log if not processed in this pass
# if not processed:
# log.debug(f"PASS 1: File '{filename}' did not match any prioritized variant.")
log.debug(f"--- Finished Pass 1. Primary assignments made: {primary_assignments} ---")
@@ -174,7 +159,7 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
for file_path_str in file_list:
if file_path_str in processed_in_pass1:
log.debug(f"PASS 2: Skipping '{Path(file_path_str).name}' (processed in Pass 1).")
continue # Skip files already classified as prioritized variants
continue
file_path = Path(file_path_str)
filename = file_path.name
@@ -186,20 +171,18 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
for extra_pattern in compiled_extra_regex:
if extra_pattern.search(filename):
log.debug(f"PASS 2: File '{filename}' matched EXTRA pattern: {extra_pattern.pattern}")
# Don't group yet, just collect for later association
extra_files_to_associate.append((file_path_str, filename))
is_extra = True
break
if is_extra:
continue # Move to the next file if it's an extra
continue
# 2. Check for General Map Files in Pass 2
for target_type, patterns_list in compiled_map_regex.items():
for compiled_regex, original_keyword, rule_index in patterns_list:
match = compiled_regex.search(filename)
if match:
# Access rule details
is_gloss_flag = False
try:
map_type_mapping_list = config.map_type_mapping
@@ -213,24 +196,21 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
if (asset_name, target_type) in primary_assignments:
log.debug(f"PASS 2: File '{filename}' matched '{original_keyword}' for type '{target_type}', but primary already assigned via Pass 1. Classifying as EXTRA.")
matched_item_type = "EXTRA"
is_gloss_flag = False # Extras are not gloss sources
is_gloss_flag = False
else:
# No prioritized variant exists, assign the general map type
log.debug(f"PASS 2: File '{filename}' matched '{original_keyword}' for item_type '{target_type}'.")
matched_item_type = target_type
# Do NOT add to primary_assignments here - only Pass 1 does that.
# Do NOT add to primary_asset_names here either.
temp_grouped_files[asset_name].append({
'file_path': file_path_str,
'item_type': matched_item_type, # Could be target_type or EXTRA
'item_type': matched_item_type,
'asset_name': asset_name,
'is_gloss_source': is_gloss_flag
})
is_map = True
break # Stop checking patterns for this file
break
if is_map:
break # Stop checking target types for this file
break
# 3. Handle Unmatched Files in Pass 2 (Not Extra, Not Map)
if not is_extra and not is_map:
@@ -246,13 +226,12 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
# --- Determine Primary Asset Name for Extra Association (using Pass 1 results) ---
final_primary_asset_name = None
if primary_asset_names: # Use names derived only from Pass 1 (prioritized variants)
# Find the most common name among those derived from primary maps identified in Pass 1
if primary_asset_names:
primary_map_asset_names_pass1 = [
f_info['asset_name']
for asset_files in temp_grouped_files.values()
for f_info in asset_files
if f_info['asset_name'] in primary_asset_names and (f_info['asset_name'], f_info['item_type']) in primary_assignments # Ensure it was a Pass 1 assignment
if f_info['asset_name'] in primary_asset_names and (f_info['asset_name'], f_info['item_type']) in primary_assignments
]
if primary_map_asset_names_pass1:
name_counts = Counter(primary_map_asset_names_pass1)
@@ -267,7 +246,6 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
log.warning("Primary asset names set (from Pass 1) was populated, but no corresponding groups found. Falling back.")
if not final_primary_asset_name:
# Fallback: No primary maps found in Pass 1. Use the first asset group found overall.
if temp_grouped_files and extra_files_to_associate:
fallback_name = sorted(temp_grouped_files.keys())[0]
final_primary_asset_name = fallback_name
@@ -282,7 +260,6 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
if final_primary_asset_name and extra_files_to_associate:
log.debug(f"Associating {len(extra_files_to_associate)} extra file(s) with primary asset '{final_primary_asset_name}'")
for file_path_str, filename in extra_files_to_associate:
# Check if file already exists in the group (e.g., if somehow classified twice)
if not any(f['file_path'] == file_path_str for f in temp_grouped_files[final_primary_asset_name]):
temp_grouped_files[final_primary_asset_name].append({
'file_path': file_path_str,
@@ -293,7 +270,6 @@ def classify_files(file_list: List[str], config: Configuration) -> Dict[str, Lis
else:
log.debug(f"Skipping duplicate association of extra file: {filename}")
elif extra_files_to_associate:
# Logged warning above if final_primary_asset_name couldn't be determined
pass
@@ -321,8 +297,6 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
super().__init__(input_source_identifier, parent)
self.original_input_paths = original_input_paths
self.preset_name = preset_name
# _is_running is handled by the base class
# Keep track of the current request being processed by this persistent handler
self._current_input_path = None
self._current_file_list = None
self._current_preset_name = None
@@ -341,18 +315,16 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
# Allow re-triggering for the *same* source if needed (e.g., preset changed)
if self._is_running and self._current_input_path != input_source_identifier:
log.warning(f"RuleBasedPredictionHandler is busy with '{self._current_input_path}'. Ignoring request for '{input_source_identifier}'.")
# Optionally emit an error signal specific to this condition
# self.prediction_error.emit(input_source_identifier, "Handler busy with another prediction.")
return
self._is_running = True
self._is_cancelled = False # Reset cancellation flag for new request
self._is_cancelled = False
self._current_input_path = input_source_identifier
self._current_file_list = original_input_paths
self._current_preset_name = preset_name
log.info(f"Starting rule-based prediction for: {input_source_identifier} using preset: {preset_name}")
self.status_update.emit(f"Starting analysis for '{Path(input_source_identifier).name}'...") # Use base signal
self.status_update.emit(f"Starting analysis for '{Path(input_source_identifier).name}'...")
source_rules_list = []
try:
@@ -362,9 +334,8 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
if not preset_name:
log.warning("No preset selected for prediction.")
self.status_update.emit("No preset selected.")
# Emit empty list for non-critical issues, signal completion
self.prediction_ready.emit(input_source_identifier, [])
self._is_running = False # Mark as finished
self._is_running = False
return
source_path = Path(input_source_identifier)
@@ -391,15 +362,13 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
if not classified_assets:
log.warning(f"Classification yielded no assets for source '{input_source_identifier}'.")
self.status_update.emit("No assets identified from files.")
# Emit empty list, signal completion
self.prediction_ready.emit(input_source_identifier, [])
self._is_running = False # Mark as finished
self._is_running = False
return
# --- Build the Hierarchy ---
self.status_update.emit(f"Building rule hierarchy for '{source_path.name}'...")
try:
# (Hierarchy building logic remains the same as before)
supplier_identifier = config.supplier_name
source_rule = SourceRule(
input_path=input_source_identifier,
@@ -407,7 +376,6 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
preset_name=preset_name
)
asset_rules = []
# asset_type_definitions = config._core_settings.get('ASSET_TYPE_DEFINITIONS', {}) # Use accessor
file_type_definitions = config._core_settings.get('FILE_TYPE_DEFINITIONS', {})
for asset_name, files_info in classified_assets.items():
@@ -415,7 +383,7 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
if not files_info: continue
asset_category_rules = config.asset_category_rules
asset_type_definitions = config.get_asset_type_definitions() # Use new accessor
asset_type_definitions = config.get_asset_type_definitions()
asset_type_keys = list(asset_type_definitions.keys())
# Initialize predicted_asset_type using the validated default
@@ -427,9 +395,8 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
# Check for Model type based on file patterns
if "Model" in asset_type_keys:
model_patterns_regex = config.compiled_model_regex # Already compiled
model_patterns_regex = config.compiled_model_regex
for f_info in files_info:
# Only consider files not marked as EXTRA or FILE_IGNORE for model classification
if f_info['item_type'] in ["EXTRA", "FILE_IGNORE"]:
continue
file_path_obj = Path(f_info['file_path'])
@@ -447,9 +414,9 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
decal_keywords = asset_category_rules.get('decal_keywords', [])
for keyword in decal_keywords:
# Ensure keyword is a string before trying to escape it
if isinstance(keyword, str) and keyword: # Added check for non-empty string
if isinstance(keyword, str) and keyword:
try:
if re.search(r'\b' + re.escape(keyword) + r'\b', asset_name, re.IGNORECASE): # Match whole word
if re.search(r'\b' + re.escape(keyword) + r'\b', asset_name, re.IGNORECASE):
predicted_asset_type = "Decal"
determined_by_rule = True
log.debug(f"Asset '{asset_name}' classified as 'Decal' due to keyword '{keyword}'.")
@@ -457,7 +424,7 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
except re.error as e_re:
log.warning(f"Regex error with decal_keyword '{keyword}': {e_re}")
if determined_by_rule:
pass # Already logged if Decal
pass
# 2. If not determined by specific rules, check for Surface (if not Model/Decal by rule)
if not determined_by_rule and predicted_asset_type == config.default_asset_category and "Surface" in asset_type_keys:
@@ -489,13 +456,10 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
log.debug(f"Asset '{asset_name}' classified as 'Surface' due to material indicators.")
# 3. Final validation: Ensure predicted_asset_type is a valid key.
# config.default_asset_category is already validated to be a key.
if predicted_asset_type not in asset_type_keys:
log.warning(f"Derived AssetType '{predicted_asset_type}' for asset '{asset_name}' is not in ASSET_TYPE_DEFINITIONS. "
f"Falling back to default: '{config.default_asset_category}'.")
predicted_asset_type = config.default_asset_category
# This case should ideally not be hit if logic above correctly uses asset_type_keys
# and default_asset_category is valid.
asset_rule = AssetRule(asset_name=asset_name, asset_type=predicted_asset_type)
file_rules = []
@@ -512,9 +476,6 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
log.warning(f"Predicted ItemType '{base_item_type}' (checked as '{final_item_type}') for file '{file_info['file_path']}' is not in FILE_TYPE_DEFINITIONS. Setting to FILE_IGNORE.")
final_item_type = "FILE_IGNORE"
# standard_map_type is no longer stored on FileRule
# It will be looked up from config when needed for naming/output
# Remove the logic that determined and assigned it here.
is_gloss_source_value = file_info.get('is_gloss_source', False)
@@ -540,22 +501,20 @@ class RuleBasedPredictionHandler(BasePredictionHandler):
# --- Emit Success Signal ---
log.info(f"Rule-based prediction finished successfully for '{input_source_identifier}'.")
self.prediction_ready.emit(input_source_identifier, source_rules_list) # Use base signal
self.prediction_ready.emit(input_source_identifier, source_rules_list)
except Exception as e:
# --- Emit Error Signal ---
log.exception(f"Error during rule-based prediction for '{input_source_identifier}': {e}")
error_msg = f"Error analyzing '{Path(input_source_identifier).name}': {e}"
self.prediction_error.emit(input_source_identifier, error_msg) # Use base signal
self.prediction_error.emit(input_source_identifier, error_msg)
finally:
# --- Cleanup ---
self._is_running = False
self._current_input_path = None # Clear current task info
self._current_input_path = None
self._current_file_list = None
self._current_preset_name = None
log.info(f"Finished rule-based prediction run for: {input_source_identifier}")
def is_running(self) -> bool:
"""Returns True if the handler is currently processing a prediction request."""
# The _is_running flag is managed by the base class or the run_prediction method
return self._is_running

View File

@@ -18,7 +18,7 @@ from PySide6.QtGui import QAction # Keep QAction if needed for context menus wit
# Assuming project root is parent of the directory containing this file
script_dir = Path(__file__).parent
project_root = script_dir.parent
PRESETS_DIR = project_root / "Presets" # Corrected path
PRESETS_DIR = project_root / "Presets"
TEMPLATE_PATH = PRESETS_DIR / "_template.json"
APP_SETTINGS_PATH_LOCAL = project_root / "config" / "app_settings.json"
@@ -51,10 +51,10 @@ class PresetEditorWidget(QWidget):
self._init_ui()
# --- Initial State ---
self._ftd_keys = self._get_file_type_definition_keys() # Load FTD keys
self._clear_editor() # Clear/disable editor fields initially
self._set_editor_enabled(False) # Disable editor initially
self.populate_presets() # Populate preset list
self._ftd_keys = self._get_file_type_definition_keys()
self._clear_editor()
self._set_editor_enabled(False)
self.populate_presets()
# --- Connect Editor Signals ---
self._connect_editor_change_signals()
@@ -91,7 +91,7 @@ class PresetEditorWidget(QWidget):
selector_layout.addWidget(QLabel("Presets:"))
self.editor_preset_list = QListWidget()
self.editor_preset_list.currentItemChanged.connect(self._load_selected_preset_for_editing)
selector_layout.addWidget(self.editor_preset_list) # Corrected: Add to selector_layout
selector_layout.addWidget(self.editor_preset_list)
list_button_layout = QHBoxLayout()
self.editor_new_button = QPushButton("New")
@@ -101,7 +101,7 @@ class PresetEditorWidget(QWidget):
list_button_layout.addWidget(self.editor_new_button)
list_button_layout.addWidget(self.editor_delete_button)
selector_layout.addLayout(list_button_layout)
main_layout.addWidget(self.selector_container) # Add selector container to main layout
main_layout.addWidget(self.selector_container)
# Editor Tabs
self.json_editor_container = QWidget()
@@ -121,7 +121,7 @@ class PresetEditorWidget(QWidget):
save_button_layout = QHBoxLayout()
self.editor_save_button = QPushButton("Save")
self.editor_save_as_button = QPushButton("Save As...")
self.editor_save_button.setEnabled(False) # Disabled initially
self.editor_save_button.setEnabled(False)
self.editor_save_button.clicked.connect(self._save_current_preset)
self.editor_save_as_button.clicked.connect(self._save_preset_as)
save_button_layout.addStretch()
@@ -129,7 +129,7 @@ class PresetEditorWidget(QWidget):
save_button_layout.addWidget(self.editor_save_as_button)
editor_layout.addLayout(save_button_layout)
main_layout.addWidget(self.json_editor_container) # Add editor container to main layout
main_layout.addWidget(self.json_editor_container)
def _create_editor_general_tab(self):
"""Creates the widgets and layout for the 'General & Naming' editor tab."""
@@ -206,7 +206,7 @@ class PresetEditorWidget(QWidget):
list_widget = QListWidget()
list_widget.setAlternatingRowColors(True)
list_widget.setEditTriggers(QAbstractItemView.EditTrigger.DoubleClicked | QAbstractItemView.EditTrigger.SelectedClicked | QAbstractItemView.EditTrigger.EditKeyPressed)
setattr(self, attribute_name, list_widget) # Store list widget on the instance
setattr(self, attribute_name, list_widget)
add_button = QPushButton("+")
remove_button = QPushButton("-")
@@ -231,7 +231,7 @@ class PresetEditorWidget(QWidget):
# Connections
add_button.clicked.connect(partial(self._editor_add_list_item, list_widget))
remove_button.clicked.connect(partial(self._editor_remove_list_item, list_widget))
list_widget.itemChanged.connect(self._mark_editor_unsaved) # Mark unsaved on item edit
list_widget.itemChanged.connect(self._mark_editor_unsaved)
def _setup_table_widget_with_controls(self, parent_layout, label_text, attribute_name, columns):
"""Adds a QTableWidget with Add/Remove buttons to a layout."""
@@ -239,7 +239,7 @@ class PresetEditorWidget(QWidget):
table_widget.setColumnCount(len(columns))
table_widget.setHorizontalHeaderLabels(columns)
table_widget.setAlternatingRowColors(True)
setattr(self, attribute_name, table_widget) # Store table widget
setattr(self, attribute_name, table_widget)
add_button = QPushButton("+ Row")
remove_button = QPushButton("- Row")
@@ -259,7 +259,7 @@ class PresetEditorWidget(QWidget):
# Connections
add_button.clicked.connect(partial(self._editor_add_table_row, table_widget))
remove_button.clicked.connect(partial(self._editor_remove_table_row, table_widget))
table_widget.itemChanged.connect(self._mark_editor_unsaved) # Mark unsaved on item edit
table_widget.itemChanged.connect(self._mark_editor_unsaved)
# --- Preset Population and Handling ---
def populate_presets(self):
@@ -271,14 +271,12 @@ class PresetEditorWidget(QWidget):
self.editor_preset_list.clear()
log.debug("Preset list cleared.")
# Add the "Select a Preset" placeholder item
placeholder_item = QListWidgetItem("--- Select a Preset ---")
placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsSelectable & ~Qt.ItemFlag.ItemIsEditable)
placeholder_item.setData(Qt.ItemDataRole.UserRole, "__PLACEHOLDER__")
self.editor_preset_list.addItem(placeholder_item)
log.debug("Added '--- Select a Preset ---' placeholder item.")
# Add LLM Option
llm_item = QListWidgetItem("- LLM Interpretation -")
llm_item.setData(Qt.ItemDataRole.UserRole, "__LLM__") # Special identifier
self.editor_preset_list.addItem(llm_item)
@@ -287,7 +285,6 @@ class PresetEditorWidget(QWidget):
if not PRESETS_DIR.is_dir():
msg = f"Error: Presets directory not found at {PRESETS_DIR}"
log.error(msg)
# Consider emitting a status signal to MainWindow?
return
presets = sorted([f for f in PRESETS_DIR.glob("*.json") if f.is_file() and not f.name.startswith('_')])
@@ -298,13 +295,13 @@ class PresetEditorWidget(QWidget):
else:
for preset_path in presets:
item = QListWidgetItem(preset_path.stem)
item.setData(Qt.ItemDataRole.UserRole, preset_path) # Store full path
item.setData(Qt.ItemDataRole.UserRole, preset_path)
self.editor_preset_list.addItem(item)
log.info(f"Loaded {len(presets)} presets into editor list.")
# Select the "Select a Preset" item by default
log.debug("Preset list populated. Selecting '--- Select a Preset ---' item.")
self.editor_preset_list.setCurrentItem(placeholder_item) # Select the placeholder item
self.editor_preset_list.setCurrentItem(placeholder_item)
# --- Preset Editor Methods ---
@@ -335,7 +332,7 @@ class PresetEditorWidget(QWidget):
combo_box.addItems(self._ftd_keys)
else:
log.warning("FILE_TYPE_DEFINITIONS keys not available for ComboBox in map_type_mapping.")
combo_box.currentIndexChanged.connect(self._mark_editor_unsaved) # Mark unsaved on change
combo_box.currentIndexChanged.connect(self._mark_editor_unsaved)
table_widget.setCellWidget(row_count, 0, combo_box)
# Column 1: Input Keywords (QTableWidgetItem)
table_widget.setItem(row_count, 1, QTableWidgetItem(""))
@@ -358,9 +355,6 @@ class PresetEditorWidget(QWidget):
if self._is_loading_editor: return
self.editor_unsaved_changes = True
self.editor_save_button.setEnabled(True)
# Update window title (handled by MainWindow) - maybe emit signal?
# preset_name = Path(self.current_editing_preset_path).name if self.current_editing_preset_path else 'New Preset'
# self.window().setWindowTitle(f"Asset Processor Tool - {preset_name}*") # Access parent window
def _connect_editor_change_signals(self):
"""Connect signals from all editor widgets to mark_editor_unsaved."""
@@ -417,7 +411,6 @@ class PresetEditorWidget(QWidget):
self.current_editing_preset_path = None
self.editor_unsaved_changes = False
self.editor_save_button.setEnabled(False)
# self.window().setWindowTitle("Asset Processor Tool") # Reset window title (handled by MainWindow)
self._set_editor_enabled(False)
finally:
self._is_loading_editor = False
@@ -465,7 +458,7 @@ class PresetEditorWidget(QWidget):
else:
log.warning("FILE_TYPE_DEFINITIONS keys not available for ComboBox in map_type_mapping during population.")
combo_box.currentIndexChanged.connect(self._mark_editor_unsaved) # Connect signal
combo_box.currentIndexChanged.connect(self._mark_editor_unsaved)
self.editor_table_map_type_mapping.setCellWidget(i, 0, combo_box)
# Column 1: Input Keywords (QTableWidgetItem)
@@ -514,7 +507,6 @@ class PresetEditorWidget(QWidget):
self.current_editing_preset_path = file_path
self.editor_unsaved_changes = False
self.editor_save_button.setEnabled(False)
# self.window().setWindowTitle(f"Asset Processor Tool - {file_path.name}") # Handled by MainWindow
log.info(f"Preset '{file_path.name}' loaded into editor.")
except json.JSONDecodeError as json_err:
log.error(f"Invalid JSON in {file_path.name}: {json_err}")
@@ -562,7 +554,7 @@ class PresetEditorWidget(QWidget):
log.debug(f"Loading preset for editing: {current_item.text()}")
preset_path = item_data
self._load_preset_for_editing(preset_path)
self._last_valid_preset_name = preset_path.stem # Store the name
self._last_valid_preset_name = preset_path.stem
mode = "preset"
preset_name = self._last_valid_preset_name
else:
@@ -656,10 +648,8 @@ class PresetEditorWidget(QWidget):
with open(self.current_editing_preset_path, 'w', encoding='utf-8') as f: f.write(content_to_save)
self.editor_unsaved_changes = False
self.editor_save_button.setEnabled(False)
# self.window().setWindowTitle(f"Asset Processor Tool - {self.current_editing_preset_path.name}") # Handled by MainWindow
self.presets_changed_signal.emit() # Signal that presets changed
self.presets_changed_signal.emit()
log.info("Preset saved successfully.")
# Refresh list within the editor
self.populate_presets()
# Reselect the saved item
items = self.editor_preset_list.findItems(self.current_editing_preset_path.stem, Qt.MatchFlag.MatchExactly)
@@ -690,11 +680,10 @@ class PresetEditorWidget(QWidget):
if reply == QMessageBox.StandardButton.No: log.debug("Save As overwrite cancelled."); return False
log.info(f"Saving preset as: {save_path.name}")
with open(save_path, 'w', encoding='utf-8') as f: f.write(content_to_save)
self.current_editing_preset_path = save_path # Update current path
self.current_editing_preset_path = save_path
self.editor_unsaved_changes = False
self.editor_save_button.setEnabled(False)
# self.window().setWindowTitle(f"Asset Processor Tool - {save_path.name}") # Handled by MainWindow
self.presets_changed_signal.emit() # Signal change
self.presets_changed_signal.emit()
log.info("Preset saved successfully (Save As).")
# Refresh list and select the new item
self.populate_presets()
@@ -718,18 +707,15 @@ class PresetEditorWidget(QWidget):
self._populate_editor_from_data(template_data)
# Override specific fields for a new preset
self.editor_preset_name.setText("NewPreset")
# self.window().setWindowTitle("Asset Processor Tool - New Preset*") # Handled by MainWindow
except Exception as e:
log.exception(f"Error loading template preset file {TEMPLATE_PATH}: {e}")
QMessageBox.critical(self, "Error", f"Could not load template preset file:\n{TEMPLATE_PATH}\n\nError: {e}")
self._clear_editor()
# self.window().setWindowTitle("Asset Processor Tool - New Preset*") # Handled by MainWindow
self.editor_supplier_name.setText("MySupplier") # Set a default supplier name
self.editor_supplier_name.setText("MySupplier")
else:
log.warning("Presets/_template.json not found. Creating empty preset.")
# self.window().setWindowTitle("Asset Processor Tool - New Preset*") # Handled by MainWindow
self.editor_preset_name.setText("NewPreset")
self.editor_supplier_name.setText("MySupplier") # Set a default supplier name
self.editor_supplier_name.setText("MySupplier")
self._set_editor_enabled(True)
self.editor_unsaved_changes = True
self.editor_save_button.setEnabled(True)
@@ -761,8 +747,7 @@ class PresetEditorWidget(QWidget):
preset_path.unlink()
log.info("Preset deleted successfully.")
if self.current_editing_preset_path == preset_path: self._clear_editor()
self.presets_changed_signal.emit() # Signal change
# Refresh list
self.presets_changed_signal.emit()
self.populate_presets()
except Exception as e:
log.exception(f"Error deleting preset file {preset_path}: {e}")

View File

@@ -1,15 +1,14 @@
import logging # Import logging
import logging
import time # For logging timestamps
from PySide6.QtCore import QAbstractTableModel, Qt, QModelIndex, QSortFilterProxyModel, QThread # Import QThread
from PySide6.QtCore import QAbstractTableModel, Qt, QModelIndex, QSortFilterProxyModel, QThread
from PySide6.QtGui import QColor
log = logging.getLogger(__name__) # Get logger
log = logging.getLogger(__name__)
# Define colors for alternating asset groups
COLOR_ASSET_GROUP_1 = QColor("#292929") # Dark grey 1
COLOR_ASSET_GROUP_2 = QColor("#343434") # Dark grey 2
# Define text colors for statuses
class PreviewTableModel(QAbstractTableModel):
"""
Custom table model for the GUI preview table.
@@ -54,11 +53,11 @@ class PreviewTableModel(QAbstractTableModel):
self._headers_detailed = ["Status", "Predicted Asset", "Original Path", "Predicted Output", "Details", "Additional Files"] # Added new column header
self._sorted_unique_assets = [] # Store sorted unique asset names for coloring
self._headers_simple = ["Input Path"]
self.set_data(data or []) # Initialize data and simple_data
self.set_data(data or [])
def set_simple_mode(self, enabled: bool):
"""Toggles the model between detailed and simple view modes."""
thread_id = QThread.currentThread() # Get current thread object
thread_id = QThread.currentThread()
log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered PreviewTableModel.set_simple_mode(enabled={enabled}). Current mode: {self._simple_mode}")
if self._simple_mode != enabled:
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
@@ -78,7 +77,6 @@ class PreviewTableModel(QAbstractTableModel):
if parent.isValid():
return 0
row_count = len(self._simple_data) if self._simple_mode else len(self._table_rows) # Use _table_rows for detailed mode
# log.debug(f"PreviewTableModel.rowCount called. Mode: {self._simple_mode}, Row Count: {row_count}")
return row_count
def columnCount(self, parent=QModelIndex()):
@@ -86,7 +84,6 @@ class PreviewTableModel(QAbstractTableModel):
if parent.isValid():
return 0
col_count = len(self._headers_simple) if self._simple_mode else len(self._headers_detailed) # Use updated headers_detailed
# log.debug(f"PreviewTableModel.columnCount called. Mode: {self._simple_mode}, Column Count: {col_count}")
return col_count
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole):
@@ -100,8 +97,7 @@ class PreviewTableModel(QAbstractTableModel):
# --- Simple Mode ---
if self._simple_mode:
if row >= len(self._simple_data):
# log.warning(f"data called with out of bounds row in simple mode: {row}/{len(self._simple_data)}")
return None # Bounds check
return None
source_asset_path = self._simple_data[row]
if role == Qt.ItemDataRole.DisplayRole:
if col == self.COL_SIMPLE_PATH:
@@ -113,12 +109,10 @@ class PreviewTableModel(QAbstractTableModel):
# --- Detailed Mode ---
if row >= len(self._table_rows): # Use _table_rows
# log.warning(f"data called with out of bounds row in detailed mode: {row}/{len(self._table_rows)}")
return None # Bounds check
return None
row_data = self._table_rows[row] # Get data from the structured row
# --- Handle Custom Internal Roles ---
# These roles are now handled by the proxy model based on the structured data
if role == self.ROLE_RAW_STATUS:
# Return status of the main file if it exists, otherwise a placeholder for additional rows
main_file = row_data.get('main_file')
@@ -132,7 +126,7 @@ class PreviewTableModel(QAbstractTableModel):
main_file = row_data.get('main_file')
if main_file:
raw_status = main_file.get('status', '[No Status]')
details = main_file.get('details', '') # Get details for parsing
details = main_file.get('details', '')
# Implement status text simplification
if raw_status == "Unmatched Extra":
@@ -268,14 +262,6 @@ class PreviewTableModel(QAbstractTableModel):
return None
# --- Handle Text Alignment Role ---
if role == Qt.ItemDataRole.TextAlignmentRole:
if col == self.COL_ORIGINAL_PATH:
return int(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
elif col == self.COL_ADDITIONAL_FILES:
return int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
# For other columns, return default alignment (or None)
return None
return None
@@ -291,7 +277,7 @@ class PreviewTableModel(QAbstractTableModel):
def set_data(self, data: list):
"""Sets the model's data, extracts simple data, and emits signals."""
# Removed diagnostic import here
thread_id = QThread.currentThread() # Get current thread object
thread_id = QThread.currentThread()
log.info(f"[{time.time():.4f}][T:{thread_id}] --> Entered PreviewTableModel.set_data. Received {len(data)} items.")
log.info(f"[{time.time():.4f}][T:{thread_id}] Calling beginResetModel()...")
self.beginResetModel()
@@ -357,7 +343,7 @@ class PreviewTableModel(QAbstractTableModel):
def clear_data(self):
"""Clears the model's data."""
thread_id = QThread.currentThread() # Get current thread object
thread_id = QThread.currentThread()
log.info(f"[{time.time():.4f}][T:{thread_id}] PreviewTableModel.clear_data called.")
self.set_data([])
@@ -398,21 +384,18 @@ class PreviewSortFilterProxyModel(QSortFilterProxyModel):
"""
model = self.sourceModel()
if not model:
# log.debug("ProxyModel.lessThan: No source model.")
return super().lessThan(left, right) # Fallback if no source model
# If in simple mode, sort by the simple path column
if isinstance(model, PreviewTableModel) and model._simple_mode:
left_path = model.data(left.siblingAtColumn(model.COL_SIMPLE_PATH), Qt.ItemDataRole.DisplayRole)
right_path = model.data(right.siblingAtColumn(model.COL_SIMPLE_PATH), Qt.ItemDataRole.DisplayRole)
# log.debug(f"ProxyModel.lessThan (Simple Mode): Comparing '{left_path}' < '{right_path}'")
if not left_path: return True
if not right_path: return False
return left_path < right_path
# --- Detailed Mode Sorting ---
# log.debug("ProxyModel.lessThan (Detailed Mode).")
# Get the full row data from the source model's _table_rows
left_row_data = model._table_rows[left.row()]
right_row_data = model._table_rows[right.row()]

View File

@@ -3,16 +3,12 @@ from PySide6.QtWidgets import (QApplication, QWidget, QVBoxLayout, QLabel, QLine
QFormLayout, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox)
from PySide6.QtCore import Signal, Slot, QObject
# Assuming rule_structure.py is in the parent directory or accessible via PYTHONPATH
# from ..rule_structure import SourceRule, AssetRule, FileRule # Adjust import based on actual structure
# For now, we'll use placeholder classes or assume rule_structure is directly importable
# from rule_structure import SourceRule, AssetRule, FileRule # Assuming direct import is possible
class RuleEditorWidget(QWidget):
"""
A widget to display and edit hierarchical processing rules (Source, Asset, File).
"""
rule_updated = Signal(object) # Signal emitted when a rule is updated
rule_updated = Signal(object)
def __init__(self, asset_types: list[str] | None = None, file_types: list[str] | None = None, parent=None):
"""
@@ -24,8 +20,8 @@ class RuleEditorWidget(QWidget):
parent: The parent widget.
"""
super().__init__(parent)
self.asset_types = asset_types if asset_types else [] # Store asset types
self.file_types = file_types if file_types else [] # Store file types
self.asset_types = asset_types if asset_types else []
self.file_types = file_types if file_types else []
self.current_rule_type = None
self.current_rule_object = None
@@ -65,7 +61,6 @@ class RuleEditorWidget(QWidget):
editor_widget = self._create_editor_widget(attr_name, attr_value)
if editor_widget:
self.form_layout.addRow(label, editor_widget)
# Connect signal to update rule object
self._connect_editor_signal(editor_widget, attr_name)
def _create_editor_widget(self, attr_name, attr_value):
@@ -79,7 +74,6 @@ class RuleEditorWidget(QWidget):
# Handle None case for override: if None, don't select anything or select a placeholder
if attr_value is None and attr_name == 'asset_type_override':
# Optionally add a placeholder like "<None>" or "<Default>"
# widget.insertItem(0, "<Default>") # Example placeholder
widget.setCurrentIndex(-1) # No selection or placeholder
elif attr_value in self.asset_types:
widget.setCurrentText(attr_value)
@@ -114,12 +108,6 @@ class RuleEditorWidget(QWidget):
widget = QLineEdit()
widget.setText(str(attr_value) if attr_value is not None else "")
return widget
# Add more types as needed
# elif isinstance(attr_value, list):
# # Example for a simple list of strings
# widget = QLineEdit()
# widget.setText(", ".join(map(str, attr_value)))
# return widget
else:
# For unsupported types, just display the value
label = QLabel(str(attr_value))
@@ -140,7 +128,6 @@ class RuleEditorWidget(QWidget):
elif isinstance(editor_widget, QComboBox):
# Use currentTextChanged to get the string value directly
editor_widget.currentTextChanged.connect(lambda text: self._update_rule_attribute(attr_name, text))
# Add connections for other widget types
def _update_rule_attribute(self, attr_name, value):
"""
@@ -162,7 +149,6 @@ class RuleEditorWidget(QWidget):
converted_value = value # Fallback for other types
setattr(self.current_rule_object, attr_name, converted_value)
self.rule_updated.emit(self.current_rule_object)
# print(f"Updated {attr_name} to {converted_value} in {self.current_rule_type}") # Debugging
except ValueError:
# Handle potential conversion errors (e.g., non-numeric input for int/float)
print(f"Error converting value '{value}' for attribute '{attr_name}'")
@@ -202,8 +188,8 @@ if __name__ == '__main__':
file_setting_y: str = "default_file_string"
# Example usage: Provide asset types during instantiation
asset_types_from_config = ["Surface", "Model", "Decal", "Atlas", "UtilityMap"] # Example list
file_types_from_config = ["MAP_COL", "MAP_NRM", "MAP_METAL", "MAP_ROUGH", "MAP_AO", "MAP_DISP", "MAP_REFL", "MAP_SSS", "MAP_FUZZ", "MAP_IDMAP", "MAP_MASK", "MAP_IMPERFECTION", "MODEL", "EXTRA", "FILE_IGNORE"] # Example list
asset_types_from_config = ["Surface", "Model", "Decal", "Atlas", "UtilityMap"]
file_types_from_config = ["MAP_COL", "MAP_NRM", "MAP_METAL", "MAP_ROUGH", "MAP_AO", "MAP_DISP", "MAP_REFL", "MAP_SSS", "MAP_FUZZ", "MAP_IDMAP", "MAP_MASK", "MAP_IMPERFECTION", "MODEL", "EXTRA", "FILE_IGNORE"]
editor = RuleEditorWidget(asset_types=asset_types_from_config, file_types=file_types_from_config)
# Test loading different rule types
@@ -212,8 +198,6 @@ if __name__ == '__main__':
file_rule = FileRule()
editor.load_rule(source_rule, "SourceRule")
# editor.load_rule(asset_rule, "AssetRule")
# editor.load_rule(file_rule, "FileRule")
editor.show()

View File

@@ -1,6 +1,6 @@
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot
from PySide6.QtGui import QIcon # Assuming we might want icons later
from rule_structure import SourceRule, AssetRule, FileRule # Import rule structures
from rule_structure import SourceRule, AssetRule, FileRule
class RuleHierarchyModel(QAbstractItemModel):
"""
@@ -57,15 +57,6 @@ class RuleHierarchyModel(QAbstractItemModel):
else:
return None
# Add other roles as needed (e.g., Qt.ItemDataRole.DecorationRole for icons)
# elif role == Qt.ItemDataRole.DecorationRole:
# if isinstance(item, SourceRule):
# return QIcon("icons/source.png") # Placeholder icon
# elif isinstance(item, AssetRule):
# return QIcon("icons/asset.png") # Placeholder icon
# elif isinstance(item, FileRule):
# return QIcon("icons/file.png") # Placeholder icon
# else:
# return None
return None

View File

@@ -1,12 +1,12 @@
# gui/unified_view_model.py
import logging # Added for debugging
log = logging.getLogger(__name__) # Added for debugging
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot, QMimeData, QByteArray, QDataStream, QIODevice # Added Signal and Slot, QMimeData, QByteArray, QDataStream, QIODevice
from PySide6.QtGui import QColor # Added for background role
from pathlib import Path # Added for file_name extraction
from rule_structure import SourceRule, AssetRule, FileRule # Removed AssetType, ItemType import
from configuration import load_base_config # Import load_base_config
from typing import List # Added for type hinting
import logging
log = logging.getLogger(__name__)
from PySide6.QtCore import QAbstractItemModel, QModelIndex, Qt, Signal, Slot, QMimeData, QByteArray, QDataStream, QIODevice
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
class CustomRoles:
MapTypeRole = Qt.UserRole + 1
@@ -15,7 +15,7 @@ class CustomRoles:
class UnifiedViewModel(QAbstractItemModel):
# --- Color Constants for Row Backgrounds ---
# Old colors removed, using config now + fixed source color
SOURCE_RULE_COLOR = QColor("#306091") # Fixed color for SourceRule rows
SOURCE_RULE_COLOR = QColor("#306091")
# -----------------------------------------
"""
@@ -24,7 +24,7 @@ class UnifiedViewModel(QAbstractItemModel):
"""
# Signal emitted when a FileRule's target asset override changes.
# Carries the FileRule object and the new target asset path (or None).
targetAssetOverrideChanged = Signal(FileRule, str, QModelIndex) # Emit FileRule object, new value, and index
targetAssetOverrideChanged = Signal(FileRule, str, QModelIndex)
# Signal emitted when an AssetRule's name changes.
# Carries the AssetRule object, the new name, and the index.
@@ -44,17 +44,17 @@ class UnifiedViewModel(QAbstractItemModel):
# COL_OUTPUT_PATH = 6 # Removed
# --- Drag and Drop MIME Type ---
MIME_TYPE = "application/x-filerule-index-list" # Custom MIME type
MIME_TYPE = "application/x-filerule-index-list"
def __init__(self, parent=None):
super().__init__(parent)
self._source_rules = [] # Now stores a list of SourceRule objects
self._source_rules = []
# self._display_mode removed
self._asset_type_colors = {}
self._file_type_colors = {}
self._asset_type_keys = [] # Store asset type keys
self._file_type_keys = [] # Store file type keys
self._load_definitions() # Load colors and keys
self._asset_type_keys = []
self._file_type_keys = []
self._load_definitions()
def _load_definitions(self):
"""Loads configuration and caches colors and type keys."""
@@ -91,24 +91,24 @@ class UnifiedViewModel(QAbstractItemModel):
self._asset_type_keys = []
self._file_type_keys = []
def load_data(self, source_rules_list: list): # Accepts a list
def load_data(self, source_rules_list: list):
"""Loads or reloads the model with a list of SourceRule objects."""
# Consider if color cache needs refreshing if config can change dynamically
# self._load_and_cache_colors() # Uncomment if config can change and needs refresh
self.beginResetModel()
self._source_rules = source_rules_list if source_rules_list else [] # Assign the new list
self._source_rules = source_rules_list if source_rules_list else []
# Ensure back-references for parent lookup are set on the NEW items
for source_rule in self._source_rules:
for asset_rule in source_rule.assets:
asset_rule.parent_source = source_rule # Set parent SourceRule
asset_rule.parent_source = source_rule
for file_rule in asset_rule.files:
file_rule.parent_asset = asset_rule # Set parent AssetRule
file_rule.parent_asset = asset_rule
self.endResetModel()
def clear_data(self):
"""Clears the model data."""
self.beginResetModel()
self._source_rules = [] # Clear the list
self._source_rules = []
self.endResetModel()
def get_all_source_rules(self) -> list:
@@ -123,14 +123,13 @@ class UnifiedViewModel(QAbstractItemModel):
# Parent is the invisible root. Children are the SourceRules.
return len(self._source_rules)
# Always use detailed logic
parent_item = parent.internalPointer()
if isinstance(parent_item, SourceRule):
return len(parent_item.assets)
elif isinstance(parent_item, AssetRule):
return len(parent_item.files)
elif isinstance(parent_item, FileRule):
return 0 # FileRules have no children
return 0
return 0 # Should not happen for valid items
@@ -165,15 +164,15 @@ class UnifiedViewModel(QAbstractItemModel):
elif isinstance(child_item, FileRule):
# Parent is an AssetRule. Find its row within its parent SourceRule.
parent_item = getattr(child_item, 'parent_asset', None) # Get parent AssetRule
parent_item = getattr(child_item, 'parent_asset', None)
if parent_item:
grandparent_item = getattr(parent_item, 'parent_source', None) # Get the SourceRule
grandparent_item = getattr(parent_item, 'parent_source', None)
if grandparent_item:
try:
parent_row = grandparent_item.assets.index(parent_item)
# We need the index of the grandparent (SourceRule) to create the parent index
grandparent_row = self._source_rules.index(grandparent_item)
return self.createIndex(parent_row, 0, parent_item) # Create index for the AssetRule parent
return self.createIndex(parent_row, 0, parent_item)
except ValueError:
return QModelIndex() # Parent AssetRule or Grandparent SourceRule not found in respective lists
else:
@@ -201,7 +200,6 @@ class UnifiedViewModel(QAbstractItemModel):
# Parent is a valid index, get its item
parent_item = parent.internalPointer()
# Always use detailed logic
child_item = None
if isinstance(parent_item, SourceRule):
if row < len(parent_item.assets):
@@ -221,7 +219,7 @@ class UnifiedViewModel(QAbstractItemModel):
def data(self, index: QModelIndex, role: int = Qt.DisplayRole):
"""Returns the data stored under the given role for the item referred to by the index."""
if not index.isValid(): # Check only index validity, data list might be empty but valid
if not index.isValid():
return None
item = index.internalPointer()
@@ -229,15 +227,14 @@ class UnifiedViewModel(QAbstractItemModel):
# --- Handle Background Role ---
if role == Qt.BackgroundRole:
# item is already fetched at line 172
if isinstance(item, SourceRule):
return self.SOURCE_RULE_COLOR # Use the class constant
return self.SOURCE_RULE_COLOR
elif isinstance(item, AssetRule):
# Determine effective asset type
asset_type = item.asset_type_override if item.asset_type_override else item.asset_type
if asset_type:
# Use cached color
return self._asset_type_colors.get(asset_type) # Returns None if not found
return self._asset_type_colors.get(asset_type)
else:
return None # Fallback if no asset_type determined
elif isinstance(item, FileRule):
@@ -257,7 +254,7 @@ class UnifiedViewModel(QAbstractItemModel):
# Should not happen if structure is correct, but fallback to default
return None
# --- End New Logic ---
else: # Other item types or if item is None
else:
return None
# --- Handle Foreground Role (Text Color) ---
elif role == Qt.ForegroundRole:
@@ -266,7 +263,7 @@ class UnifiedViewModel(QAbstractItemModel):
effective_item_type = item.item_type_override if item.item_type_override is not None else item.item_type
if effective_item_type:
# Use cached color for text
return self._file_type_colors.get(effective_item_type) # Returns None if not found
return self._file_type_colors.get(effective_item_type)
# For SourceRule and AssetRule, return None to use default text color (usually contrasts well)
return None
@@ -274,9 +271,8 @@ class UnifiedViewModel(QAbstractItemModel):
if isinstance(item, SourceRule):
if role == Qt.DisplayRole or role == Qt.EditRole:
if column == self.COL_NAME:
# Always display name
return Path(item.input_path).name
elif column == self.COL_SUPPLIER: # Always handle supplier
elif column == self.COL_SUPPLIER:
display_value = item.supplier_override if item.supplier_override is not None else item.supplier_identifier
return display_value if display_value is not None else ""
return None # Other columns/roles are blank for SourceRule
@@ -290,7 +286,7 @@ class UnifiedViewModel(QAbstractItemModel):
return display_value if display_value else ""
elif role == Qt.EditRole:
if column == self.COL_NAME:
return item.asset_name # Return name for editing
return item.asset_name
elif column == self.COL_ASSET_TYPE:
return item.asset_type_override
return None
@@ -314,20 +310,20 @@ class UnifiedViewModel(QAbstractItemModel):
def setData(self, index: QModelIndex, value, role: int = Qt.EditRole) -> bool:
"""Sets the role data for the item at index to value."""
if not index.isValid() or role != Qt.EditRole: # Check only index and role
if not index.isValid() or role != Qt.EditRole:
return False
item = index.internalPointer()
if item is None: # Extra check for safety
if item is None:
return False
column = index.column()
changed = False
# --- Handle different item types ---
if isinstance(item, SourceRule): # If SourceRule is editable
if isinstance(item, SourceRule):
if column == self.COL_SUPPLIER:
# Get the new value, strip whitespace, treat empty as None
log.debug(f"setData COL_SUPPLIER: Index=({index.row()},{column}), Value='{value}', Type={type(value)}") # <-- ADDED LOGGING (Corrected Indentation)
log.debug(f"setData COL_SUPPLIER: Index=({index.row()},{column}), Value='{value}', Type={type(value)}")
new_value = str(value).strip() if value is not None and str(value).strip() else None
# Get the original identifier (assuming it exists on SourceRule)
@@ -335,7 +331,7 @@ class UnifiedViewModel(QAbstractItemModel):
# If the new value is the same as the original, clear the override
if new_value == original_identifier:
new_value = None # Effectively removes the override
new_value = None
# Update supplier_override only if it's different
if item.supplier_override != new_value:
@@ -343,14 +339,14 @@ class UnifiedViewModel(QAbstractItemModel):
changed = True
elif isinstance(item, AssetRule):
if column == self.COL_NAME: # Handle Asset Name change
if column == self.COL_NAME:
new_asset_name = str(value).strip() if value else None
if not new_asset_name:
log.warning("setData: Asset name cannot be empty.")
return False # Don't allow empty names
return False
if item.asset_name == new_asset_name:
return False # No change
return False
# --- Validation: Check for duplicates within the same SourceRule ---
parent_source = getattr(item, 'parent_source', None)
@@ -379,13 +375,13 @@ class UnifiedViewModel(QAbstractItemModel):
for src_idx, source_rule in enumerate(self._source_rules):
source_rule_index = self.createIndex(src_idx, 0, source_rule)
for asset_idx, asset_rule in enumerate(source_rule.assets):
asset_rule_index = self.createIndex(asset_idx, 0, asset_rule) # This index is relative to source_rule_index
asset_rule_index = self.createIndex(asset_idx, 0, asset_rule)
for file_idx, file_rule in enumerate(asset_rule.files):
if file_rule.target_asset_name_override == old_asset_name:
log.debug(f" Updating target for file: {Path(file_rule.file_path).name}")
file_rule.target_asset_name_override = new_asset_name
# Get the correct index for the file rule to emit dataChanged
file_rule_parent_index = self.parent(self.createIndex(file_idx, 0, file_rule)) # Get parent AssetRule index
file_rule_parent_index = self.parent(self.createIndex(file_idx, 0, file_rule))
file_rule_index = self.index(file_idx, self.COL_TARGET_ASSET, file_rule_parent_index)
if file_rule_index.isValid():
updated_file_indices.append(file_rule_index)
@@ -401,41 +397,41 @@ class UnifiedViewModel(QAbstractItemModel):
elif column == self.COL_ASSET_TYPE:
# Delegate provides string value (e.g., "Surface", "Model") or None
new_value = str(value) if value is not None else None
if new_value == "": new_value = None # Treat empty string as None
if new_value == "": new_value = None
# Update asset_type_override
if item.asset_type_override != new_value:
item.asset_type_override = new_value
changed = True
elif isinstance(item, FileRule):
if column == self.COL_TARGET_ASSET: # Target Asset Name Override
if column == self.COL_TARGET_ASSET:
# Ensure value is string or None
new_value = str(value).strip() if value is not None else None
if new_value == "": new_value = None # Treat empty string as None
if new_value == "": new_value = None
# Update target_asset_name_override
if item.target_asset_name_override != new_value:
old_value = item.target_asset_name_override # Store old value for potential revert/comparison
old_value = item.target_asset_name_override
item.target_asset_name_override = new_value
changed = True
# Emit signal that the override changed, let handler deal with restructuring
# Pass the FileRule item itself, the new value, and the index
self.targetAssetOverrideChanged.emit(item, new_value, index)
elif column == self.COL_ITEM_TYPE: # Item-Type Override
elif column == self.COL_ITEM_TYPE:
# Delegate provides string value (e.g., "MAP_COL") or None
new_value = str(value) if value is not None else None
if new_value == "": new_value = None # Treat empty string as None
if new_value == "": new_value = None
# Update item_type_override
if item.item_type_override != new_value:
log.debug(f"setData COL_ITEM_TYPE: File='{Path(item.file_path).name}', Original Override='{item.item_type_override}', New Value='{new_value}'") # DEBUG LOG
old_override = item.item_type_override # Store old value for logging
log.debug(f"setData COL_ITEM_TYPE: File='{Path(item.file_path).name}', Original Override='{item.item_type_override}', New Value='{new_value}'")
old_override = item.item_type_override
item.item_type_override = new_value
changed = True
# standard_map_type is no longer stored on FileRule.
# Remove the logic that updated it here.
pass # No action needed to update standard_map_type
pass
log.debug(f"setData COL_ITEM_TYPE: File='{Path(item.file_path).name}', Final Override='{item.item_type_override}'") # DEBUG LOG - Updated
log.debug(f"setData COL_ITEM_TYPE: File='{Path(item.file_path).name}', Final Override='{item.item_type_override}'")
if changed:
@@ -448,22 +444,21 @@ class UnifiedViewModel(QAbstractItemModel):
def flags(self, index: QModelIndex) -> Qt.ItemFlags:
"""Returns the item flags for the given index."""
if not index.isValid():
return Qt.NoItemFlags # No flags for invalid index
return Qt.NoItemFlags
# Start with default flags for a valid item
default_flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
item = index.internalPointer()
if not item: # Should not happen for valid index, but safety check
if not item:
return Qt.NoItemFlags
column = index.column()
# Always use detailed mode editability logic
can_edit = False
if isinstance(item, SourceRule):
if column == self.COL_SUPPLIER: can_edit = True
elif isinstance(item, AssetRule):
if column == self.COL_NAME: can_edit = True # Allow editing name
if column == self.COL_NAME: can_edit = True
if column == self.COL_ASSET_TYPE: can_edit = True
# AssetRule items can accept drops
default_flags |= Qt.ItemIsDropEnabled
@@ -494,9 +489,9 @@ class UnifiedViewModel(QAbstractItemModel):
"""Safely returns the item associated with the index."""
if index.isValid():
item = index.internalPointer()
if item: # Ensure internal pointer is not None
if item:
return item
return None # Return None for invalid index or None pointer
return None
# --- Method to update model based on prediction results, preserving overrides ---
def update_rules_for_sources(self, new_source_rules: List[SourceRule]):
"""
@@ -539,7 +534,7 @@ class UnifiedViewModel(QAbstractItemModel):
self.beginInsertRows(QModelIndex(), insert_row, insert_row)
self._source_rules.append(new_source_rule)
self.endInsertRows()
continue # Process next new_source_rule
continue
# 3. Merge Existing SourceRule
log.debug(f"Merging SourceRule for '{source_path}'")
@@ -578,7 +573,7 @@ class UnifiedViewModel(QAbstractItemModel):
if existing_asset.asset_type != new_asset.asset_type and existing_asset.asset_type_override is None:
existing_asset.asset_type = new_asset.asset_type
asset_type_col_index = self.createIndex(existing_asset_row, self.COL_ASSET_TYPE, existing_asset)
self.dataChanged.emit(asset_type_col_index, asset_type_col_index, [Qt.DisplayRole, Qt.EditRole, Qt.BackgroundRole]) # Include BackgroundRole for color
self.dataChanged.emit(asset_type_col_index, asset_type_col_index, [Qt.DisplayRole, Qt.EditRole, Qt.BackgroundRole])
# --- Merge FileRules within the AssetRule ---
self._merge_file_rules(existing_asset, new_asset, existing_asset_index)
@@ -586,7 +581,7 @@ class UnifiedViewModel(QAbstractItemModel):
else:
# --- Add New AssetRule ---
log.debug(f" Adding new AssetRule: {asset_name}")
new_asset.parent_source = existing_source_rule # Set parent
new_asset.parent_source = existing_source_rule
# Ensure file parents are set
for file_rule in new_asset.files:
file_rule.parent_asset = new_asset
@@ -601,7 +596,7 @@ class UnifiedViewModel(QAbstractItemModel):
assets_to_remove = []
for i, existing_asset in reversed(list(enumerate(existing_source_rule.assets))):
if existing_asset.asset_name not in processed_asset_names:
assets_to_remove.append((i, existing_asset.asset_name)) # Store index and name
assets_to_remove.append((i, existing_asset.asset_name))
for row_index, asset_name_to_remove in assets_to_remove:
log.debug(f" Removing old AssetRule: {asset_name_to_remove}")
@@ -625,30 +620,30 @@ class UnifiedViewModel(QAbstractItemModel):
# --- Update Existing FileRule ---
log.debug(f" Merging FileRule: {Path(file_path).name}")
existing_file_row = existing_asset.files.index(existing_file)
existing_file_index = self.createIndex(existing_file_row, 0, existing_file) # Index relative to parent_asset_index
existing_file_index = self.createIndex(existing_file_row, 0, existing_file)
# Update non-override fields (item_type, standard_map_type)
changed_roles = []
if existing_file.item_type != new_file.item_type and existing_file.item_type_override is None:
existing_file.item_type = new_file.item_type
changed_roles.extend([Qt.DisplayRole, Qt.EditRole, Qt.BackgroundRole]) # Include BackgroundRole for color
changed_roles.extend([Qt.DisplayRole, Qt.EditRole, Qt.BackgroundRole])
# standard_map_type is no longer stored on FileRule.
# Remove the logic that updated it during merge.
pass # No action needed for standard_map_type during merge
pass
# Emit dataChanged only if something actually changed
if changed_roles:
# Emit for all relevant columns potentially affected by type changes
for col in [self.COL_ITEM_TYPE]: # Add other cols if needed
for col in [self.COL_ITEM_TYPE]:
col_index = self.createIndex(existing_file_row, col, existing_file)
self.dataChanged.emit(col_index, col_index, changed_roles)
else:
# --- Add New FileRule ---
log.debug(f" Adding new FileRule: {Path(file_path).name}")
new_file.parent_asset = existing_asset # Set parent
new_file.parent_asset = existing_asset
insert_row = len(existing_asset.files)
self.beginInsertRows(parent_asset_index, insert_row, insert_row)
existing_asset.files.append(new_file)
@@ -689,7 +684,7 @@ class UnifiedViewModel(QAbstractItemModel):
if old_parent_asset == target_parent_asset:
log.debug("moveFileRule: Source and target parent are the same. No move needed.")
return True # Technically successful, no change needed
return True
# Get old parent index
source_rule = getattr(old_parent_asset, 'parent_source', None)
@@ -705,14 +700,14 @@ class UnifiedViewModel(QAbstractItemModel):
log.error("moveFileRule: Could not find old parent or source file within their respective lists.")
return False
target_row = len(target_parent_asset.files) # Append to the end of the target
target_row = len(target_parent_asset.files)
log.debug(f"Moving file '{Path(file_item.file_path).name}' from '{old_parent_asset.asset_name}' (row {source_row}) to '{target_parent_asset.asset_name}' (row {target_row})")
self.beginMoveRows(old_parent_index, source_row, source_row, target_parent_asset_index, target_row)
# Restructure internal data
old_parent_asset.files.pop(source_row)
target_parent_asset.files.append(file_item)
file_item.parent_asset = target_parent_asset # Update parent reference
file_item.parent_asset = target_parent_asset
self.endMoveRows()
return True
@@ -732,11 +727,11 @@ class UnifiedViewModel(QAbstractItemModel):
return self.createIndex(existing_row, 0, asset)
except ValueError:
log.error("createAssetRule: Found existing asset but failed to get its index.")
return QModelIndex() # Should not happen
return QModelIndex()
log.debug(f"Creating new AssetRule '{new_asset_name}' under '{Path(source_rule.input_path).name}'")
new_asset_rule = AssetRule(asset_name=new_asset_name)
new_asset_rule.parent_source = source_rule # Set parent reference
new_asset_rule.parent_source = source_rule
# Optionally copy type info from another asset
if isinstance(copy_from_asset, AssetRule):
@@ -756,7 +751,7 @@ class UnifiedViewModel(QAbstractItemModel):
# Emit signals for inserting the new parent row
self.beginInsertRows(grandparent_index, new_parent_row, new_parent_row)
source_rule.assets.insert(new_parent_row, new_asset_rule) # Insert into data structure
source_rule.assets.insert(new_parent_row, new_asset_rule)
self.endInsertRows()
# Return index for the newly created asset
@@ -771,7 +766,7 @@ class UnifiedViewModel(QAbstractItemModel):
if asset_rule_to_remove.files:
log.warning(f"removeAssetRule: Asset '{asset_rule_to_remove.asset_name}' is not empty. Removal aborted.")
return False # Do not remove non-empty assets automatically
return False
source_rule = getattr(asset_rule_to_remove, 'parent_source', None)
if not source_rule:
@@ -811,10 +806,10 @@ class UnifiedViewModel(QAbstractItemModel):
for sr_row, source_rule in enumerate(self._source_rules):
if source_rule is target_item_object:
return self.createIndex(sr_row, 0, source_rule) # Top-level item
return self.createIndex(sr_row, 0, source_rule)
parent_source_rule_index = self.createIndex(sr_row, 0, source_rule) # Potential parent for children
if not parent_source_rule_index.isValid(): # Should always be valid here
parent_source_rule_index = self.createIndex(sr_row, 0, source_rule)
if not parent_source_rule_index.isValid():
log.error(f"findIndexForItem: Could not create valid index for SourceRule: {source_rule.input_path}")
continue
@@ -826,7 +821,7 @@ class UnifiedViewModel(QAbstractItemModel):
parent_asset_rule_index = self.index(ar_row, 0, parent_source_rule_index)
if not parent_asset_rule_index.isValid():
log.error(f"findIndexForItem: Could not create valid index for AssetRule: {asset_rule.asset_name}")
continue # Skip children if parent index is invalid
continue
for fr_row, file_rule in enumerate(asset_rule.files):
if file_rule is target_item_object:
@@ -850,7 +845,7 @@ class UnifiedViewModel(QAbstractItemModel):
self.beginRemoveRows(grandparent_index, asset_row_for_removal, asset_row_for_removal)
source_rule.assets.pop(asset_row_for_removal)
self.endRemoveRows()
return True # This was the original end of removeAssetRule
return True
def update_status(self, source_path: str, status_text: str):
"""
@@ -877,7 +872,7 @@ class UnifiedViewModel(QAbstractItemModel):
# (e.g., delegates, background color based on status if implemented later)
start_index = self.createIndex(found_row, 0, found_rule)
end_index = self.createIndex(found_row, self.columnCount() - 1, found_rule)
self.dataChanged.emit(start_index, end_index, [Qt.DisplayRole]) # Emit for DisplayRole, maybe others needed later
self.dataChanged.emit(start_index, end_index, [Qt.DisplayRole])
except Exception as e:
log.exception(f"Error setting status attribute or emitting dataChanged for {source_path}: {e}")
@@ -905,7 +900,7 @@ class UnifiedViewModel(QAbstractItemModel):
dragged_file_info = []
for index in indexes:
if not index.isValid() or index.column() != 0: # Only consider valid indices from the first column
if not index.isValid() or index.column() != 0:
continue
item = index.internalPointer()
if isinstance(item, FileRule):
@@ -928,9 +923,9 @@ class UnifiedViewModel(QAbstractItemModel):
# 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]) # source_row
stream.writeInt8(info[1]) # source_parent_row
stream.writeInt8(info[2]) # source_grandparent_row
stream.writeInt8(info[0])
stream.writeInt8(info[1])
stream.writeInt8(info[2])
mime_data.setData(self.MIME_TYPE, encoded_data)
log.debug(f"mimeData: Encoded {len(dragged_file_info)} FileRule indices.")
@@ -943,11 +938,11 @@ class UnifiedViewModel(QAbstractItemModel):
# Check if the drop target is a valid AssetRule
if not parent.isValid():
return False # Cannot drop onto root or SourceRule directly in this implementation
return False
target_item = parent.internalPointer()
if not isinstance(target_item, AssetRule):
return False # Can only drop onto AssetRule items
return False
# Optional: Prevent dropping onto the original parent? (Might be confusing)
# For now, allow dropping onto the same parent (moveFileRule handles this)
@@ -961,7 +956,7 @@ class UnifiedViewModel(QAbstractItemModel):
return False
target_asset_item = parent.internalPointer()
if not isinstance(target_asset_item, AssetRule): # Should be caught by canDrop, but double-check
if not isinstance(target_asset_item, AssetRule):
log.error("dropMimeData: Target item is not an AssetRule.")
return False
@@ -984,7 +979,7 @@ class UnifiedViewModel(QAbstractItemModel):
# Keep track of original parents that might become empty
original_parents = set()
moved_files_new_indices = {} # Store new index after move for dataChanged emission
moved_files_new_indices = {}
# --- BEGIN FIX: Reconstruct all source indices BEFORE the move loop ---
source_indices_to_process = []
@@ -1018,12 +1013,12 @@ class UnifiedViewModel(QAbstractItemModel):
# Process moves using the pre-calculated valid indices
for source_file_index in source_indices_to_process: # Iterate through the valid indices
for source_file_index in source_indices_to_process:
# Get the file item (already validated during reconstruction)
file_item = source_file_index.internalPointer()
# Track original parent for cleanup (using the valid index)
old_parent_index = self.parent(source_file_index) # Get parent from 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):
@@ -1031,7 +1026,7 @@ class UnifiedViewModel(QAbstractItemModel):
grandparent_index = self.parent(old_parent_index)
if grandparent_index.isValid():
original_parents.add((grandparent_index.row(), old_parent_asset.asset_name))
else: # Handle root case or error
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.")
@@ -1040,21 +1035,21 @@ class UnifiedViewModel(QAbstractItemModel):
# Perform the move using the model's method and the valid source_file_index
if self.moveFileRule(source_file_index, parent): # 'parent' is the target_parent_asset_index
if self.moveFileRule(source_file_index, parent):
# --- Update Target Asset Override After Successful Move ---
# The file_item's parent_asset reference should now be updated by moveFileRule
new_parent_asset = getattr(file_item, 'parent_asset', None)
if new_parent_asset == target_asset_item: # Check if move was successful internally
if new_parent_asset == target_asset_item:
if file_item.target_asset_name_override != target_asset_item.asset_name:
log.debug(f" Updating target override for '{Path(file_item.file_path).name}' to '{target_asset_item.asset_name}'")
file_item.target_asset_name_override = target_asset_item.asset_name
# Need the *new* index of the moved file to emit dataChanged
try:
new_row = target_asset_item.files.index(file_item)
new_file_index_col0 = self.index(new_row, 0, parent) # Index for col 0 under new parent
new_file_index_target_col = self.index(new_row, self.COL_TARGET_ASSET, parent) # Index for target col
new_file_index_col0 = self.index(new_row, 0, parent)
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 # Use hashable file_path as key
moved_files_new_indices[file_item.file_path] = new_file_index_target_col
else:
log.warning(f" Could not get valid *new* index for target column of moved file: {Path(file_item.file_path).name}")
except ValueError:
@@ -1065,22 +1060,22 @@ class UnifiedViewModel(QAbstractItemModel):
else:
log.error(f"dropMimeData: moveFileRule failed for file '{Path(file_item.file_path).name}'.")
# If one move fails, should we stop? For now, continue processing others.
continue # Skip override update and cleanup check for this file
continue
# --- Emit dataChanged for Target Asset column AFTER all moves ---
for source_path, new_index in moved_files_new_indices.items(): # Key is now source_path (string)
for source_path, new_index in moved_files_new_indices.items():
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)}") # Log tuples
for gp_row, asset_name in list(original_parents): # Iterate over a copy of tuples
log.debug(f"dropMimeData: Checking original parents for cleanup: {list(original_parents)}")
for gp_row, asset_name in list(original_parents):
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: # Don't remove the target if it was also an original parent
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}'.")