First Time Setup Implementation
This commit is contained in:
388
gui/first_time_setup_dialog.py
Normal file
388
gui/first_time_setup_dialog.py
Normal file
@@ -0,0 +1,388 @@
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
|
||||
QFileDialog, QMessageBox, QGroupBox, QFormLayout, QSpinBox, QDialogButtonBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, Slot
|
||||
|
||||
# Constants for bundled resource locations relative to app base
|
||||
BUNDLED_CONFIG_SUBDIR_NAME = "config"
|
||||
BUNDLED_PRESETS_SUBDIR_NAME = "Presets"
|
||||
DEFAULT_USER_DATA_SUBDIR_NAME = "user_data" # For portable path attempt
|
||||
|
||||
# Files to copy from bundled config to user config
|
||||
DEFAULT_CONFIG_FILES = [
|
||||
"asset_type_definitions.json",
|
||||
"file_type_definitions.json",
|
||||
"llm_settings.json",
|
||||
"suppliers.json"
|
||||
]
|
||||
# app_settings.json is NOT copied. user_settings.json is handled separately.
|
||||
|
||||
USER_SETTINGS_FILENAME = "user_settings.json"
|
||||
PERSISTENT_PATH_MARKER_FILENAME = ".first_run_complete"
|
||||
PERSISTENT_CONFIG_ROOT_STORAGE_FILENAME = "asset_processor_user_root.txt" # Stores USER_CHOSEN_PATH
|
||||
|
||||
APP_NAME = "AssetProcessor" # Used for AppData paths
|
||||
|
||||
def get_app_base_dir() -> Path:
|
||||
"""Determines the base directory for the application (executable or script)."""
|
||||
if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
|
||||
# Running in a PyInstaller bundle
|
||||
return Path(sys._MEIPASS)
|
||||
else:
|
||||
# Running as a script
|
||||
return Path(__file__).resolve().parent.parent # Assuming this file is in gui/ subdir
|
||||
|
||||
def get_os_specific_app_data_dir() -> Path:
|
||||
"""Gets the OS-specific application data directory."""
|
||||
if sys.platform == "win32":
|
||||
path_str = os.getenv('APPDATA')
|
||||
if path_str:
|
||||
return Path(path_str) / APP_NAME
|
||||
# Fallback if APPDATA is not set, though unlikely
|
||||
return Path.home() / "AppData" / "Roaming" / APP_NAME
|
||||
elif sys.platform == "darwin": # macOS
|
||||
return Path.home() / "Library" / "Application Support" / APP_NAME
|
||||
else: # Linux and other Unix-like
|
||||
return Path.home() / ".config" / APP_NAME
|
||||
|
||||
class FirstTimeSetupDialog(QDialog):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Asset Processor - First-Time Setup")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(600)
|
||||
|
||||
self.app_base_dir = get_app_base_dir()
|
||||
self.user_chosen_path: Optional[Path] = None
|
||||
|
||||
self._init_ui()
|
||||
self._propose_default_config_path()
|
||||
|
||||
def _init_ui(self):
|
||||
main_layout = QVBoxLayout(self)
|
||||
|
||||
# Configuration Path Group
|
||||
config_path_group = QGroupBox("Configuration Location")
|
||||
config_path_layout = QVBoxLayout()
|
||||
|
||||
self.proposed_path_label = QLabel("Proposed default configuration path:")
|
||||
config_path_layout.addWidget(self.proposed_path_label)
|
||||
|
||||
path_selection_layout = QHBoxLayout()
|
||||
self.config_path_edit = QLineEdit()
|
||||
self.config_path_edit.setReadOnly(False) # Allow editing, then validate
|
||||
path_selection_layout.addWidget(self.config_path_edit)
|
||||
|
||||
browse_button = QPushButton("Browse...")
|
||||
browse_button.clicked.connect(self._browse_config_path)
|
||||
path_selection_layout.addWidget(browse_button)
|
||||
config_path_layout.addLayout(path_selection_layout)
|
||||
config_path_group.setLayout(config_path_layout)
|
||||
main_layout.addWidget(config_path_group)
|
||||
|
||||
# User Settings Group
|
||||
user_settings_group = QGroupBox("Initial User Settings")
|
||||
user_settings_form_layout = QFormLayout()
|
||||
|
||||
self.output_base_dir_edit = QLineEdit()
|
||||
output_base_dir_browse_button = QPushButton("Browse...")
|
||||
output_base_dir_browse_button.clicked.connect(self._browse_output_base_dir)
|
||||
output_base_dir_layout = QHBoxLayout()
|
||||
output_base_dir_layout.addWidget(self.output_base_dir_edit)
|
||||
output_base_dir_layout.addWidget(output_base_dir_browse_button)
|
||||
user_settings_form_layout.addRow("Default Library Output Path:", output_base_dir_layout)
|
||||
|
||||
self.output_dir_pattern_edit = QLineEdit("[supplier]/[asset_category]/[asset_name]")
|
||||
user_settings_form_layout.addRow("Asset Structure Pattern:", self.output_dir_pattern_edit)
|
||||
|
||||
self.output_format_16bit_primary_edit = QLineEdit("png")
|
||||
user_settings_form_layout.addRow("Default 16-bit Output Format (Primary):", self.output_format_16bit_primary_edit)
|
||||
|
||||
self.output_format_8bit_edit = QLineEdit("png")
|
||||
user_settings_form_layout.addRow("Default 8-bit Output Format:", self.output_format_8bit_edit)
|
||||
|
||||
self.resolution_threshold_jpg_spinbox = QSpinBox()
|
||||
self.resolution_threshold_jpg_spinbox.setRange(256, 16384)
|
||||
self.resolution_threshold_jpg_spinbox.setValue(4096)
|
||||
self.resolution_threshold_jpg_spinbox.setSuffix(" px")
|
||||
user_settings_form_layout.addRow("JPG Resolution Threshold (for 8-bit):", self.resolution_threshold_jpg_spinbox)
|
||||
|
||||
user_settings_group.setLayout(user_settings_form_layout)
|
||||
main_layout.addWidget(user_settings_group)
|
||||
|
||||
# Dialog Buttons
|
||||
self.button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||
self.button_box.button(QDialogButtonBox.StandardButton.Ok).setText("Finish Setup")
|
||||
self.button_box.accepted.connect(self._on_finish_setup)
|
||||
self.button_box.rejected.connect(self.reject)
|
||||
main_layout.addWidget(self.button_box)
|
||||
|
||||
def _propose_default_config_path(self):
|
||||
proposed_path = None
|
||||
|
||||
# 1. Try portable path: user_data/ next to the application base dir
|
||||
# If running from script, app_base_dir is .../Asset_processor_tool/gui, so parent is .../Asset_processor_tool
|
||||
# If bundled, app_base_dir is the directory of the executable.
|
||||
|
||||
# Let's refine app_base_dir for portable path logic
|
||||
# If script: Path(__file__).parent.parent = Asset_processor_tool
|
||||
# If frozen: sys._MEIPASS (which is the temp extraction dir, not ideal for persistent user_data)
|
||||
# A better approach for portable if frozen: Path(sys.executable).parent
|
||||
|
||||
current_app_dir = Path(sys.executable).parent if getattr(sys, 'frozen', False) else self.app_base_dir
|
||||
|
||||
portable_path_candidate = current_app_dir / DEFAULT_USER_DATA_SUBDIR_NAME
|
||||
try:
|
||||
portable_path_candidate.mkdir(parents=True, exist_ok=True)
|
||||
if os.access(str(portable_path_candidate), os.W_OK):
|
||||
proposed_path = portable_path_candidate
|
||||
self.proposed_path_label.setText(f"Proposed portable path (writable):")
|
||||
else:
|
||||
self.proposed_path_label.setText(f"Portable path '{portable_path_candidate}' not writable.")
|
||||
except Exception as e:
|
||||
self.proposed_path_label.setText(f"Could not use portable path '{portable_path_candidate}': {e}")
|
||||
print(f"Error checking/creating portable path: {e}") # For debugging
|
||||
|
||||
# 2. Fallback to OS-specific app data directory
|
||||
if not proposed_path:
|
||||
os_specific_path = get_os_specific_app_data_dir()
|
||||
try:
|
||||
os_specific_path.mkdir(parents=True, exist_ok=True)
|
||||
if os.access(str(os_specific_path), os.W_OK):
|
||||
proposed_path = os_specific_path
|
||||
self.proposed_path_label.setText(f"Proposed standard path (writable):")
|
||||
else:
|
||||
self.proposed_path_label.setText(f"Standard path '{os_specific_path}' not writable. Please choose a location.")
|
||||
except Exception as e:
|
||||
self.proposed_path_label.setText(f"Could not use standard path '{os_specific_path}': {e}. Please choose a location.")
|
||||
print(f"Error checking/creating standard path: {e}") # For debugging
|
||||
|
||||
if proposed_path:
|
||||
self.config_path_edit.setText(str(proposed_path.resolve()))
|
||||
else:
|
||||
# Should not happen if OS specific path creation works, but as a last resort:
|
||||
self.config_path_edit.setText(str(Path.home())) # Default to home if all else fails
|
||||
QMessageBox.warning(self, "Path Issue", "Could not determine a default writable configuration path. Please select one manually.")
|
||||
|
||||
@Slot()
|
||||
def _browse_config_path(self):
|
||||
directory = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"Select Configuration Directory",
|
||||
self.config_path_edit.text() or str(Path.home())
|
||||
)
|
||||
if directory:
|
||||
self.config_path_edit.setText(directory)
|
||||
|
||||
@Slot()
|
||||
def _browse_output_base_dir(self):
|
||||
directory = QFileDialog.getExistingDirectory(
|
||||
self,
|
||||
"Select Default Library Output Directory",
|
||||
self.output_base_dir_edit.text() or str(Path.home())
|
||||
)
|
||||
if directory:
|
||||
self.output_base_dir_edit.setText(directory)
|
||||
|
||||
def _validate_inputs(self) -> bool:
|
||||
# Validate chosen config path
|
||||
path_str = self.config_path_edit.text().strip()
|
||||
if not path_str:
|
||||
QMessageBox.warning(self, "Input Error", "Configuration path cannot be empty.")
|
||||
return False
|
||||
|
||||
self.user_chosen_path = Path(path_str)
|
||||
try:
|
||||
self.user_chosen_path.mkdir(parents=True, exist_ok=True)
|
||||
if not os.access(str(self.user_chosen_path), os.W_OK):
|
||||
QMessageBox.warning(self, "Path Error", f"The chosen configuration path '{self.user_chosen_path}' is not writable.")
|
||||
return False
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Path Error", f"Error with chosen configuration path '{self.user_chosen_path}': {e}")
|
||||
return False
|
||||
|
||||
# Validate output base dir
|
||||
output_base_dir_str = self.output_base_dir_edit.text().strip()
|
||||
if not output_base_dir_str:
|
||||
QMessageBox.warning(self, "Input Error", "Default Library Output Path cannot be empty.")
|
||||
return False
|
||||
try:
|
||||
Path(output_base_dir_str).mkdir(parents=True, exist_ok=True) # Check if creatable
|
||||
if not os.access(output_base_dir_str, os.W_OK):
|
||||
QMessageBox.warning(self, "Path Error", f"The chosen output base path '{output_base_dir_str}' is not writable.")
|
||||
return False
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Path Error", f"Error with output base path '{output_base_dir_str}': {e}")
|
||||
return False
|
||||
|
||||
if not self.output_dir_pattern_edit.text().strip():
|
||||
QMessageBox.warning(self, "Input Error", "Asset Structure Pattern cannot be empty.")
|
||||
return False
|
||||
if not self.output_format_16bit_primary_edit.text().strip():
|
||||
QMessageBox.warning(self, "Input Error", "Default 16-bit Output Format cannot be empty.")
|
||||
return False
|
||||
if not self.output_format_8bit_edit.text().strip():
|
||||
QMessageBox.warning(self, "Input Error", "Default 8-bit Output Format cannot be empty.")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _copy_default_files(self):
|
||||
if not self.user_chosen_path:
|
||||
return
|
||||
|
||||
bundled_config_dir = self.app_base_dir / BUNDLED_CONFIG_SUBDIR_NAME
|
||||
user_target_config_dir = self.user_chosen_path / BUNDLED_CONFIG_SUBDIR_NAME # User files also go into a 'config' subdir
|
||||
|
||||
try:
|
||||
user_target_config_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Could not create user config subdirectory '{user_target_config_dir}': {e}")
|
||||
return
|
||||
|
||||
for filename in DEFAULT_CONFIG_FILES:
|
||||
source_file = bundled_config_dir / filename
|
||||
target_file = user_target_config_dir / filename
|
||||
if not target_file.exists():
|
||||
if source_file.is_file():
|
||||
try:
|
||||
shutil.copy2(str(source_file), str(target_file))
|
||||
print(f"Copied '{source_file}' to '{target_file}'")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "File Copy Error", f"Could not copy '{filename}' to '{target_file}': {e}")
|
||||
else:
|
||||
print(f"Default config file '{source_file}' not found in bundle.")
|
||||
else:
|
||||
print(f"User config file '{target_file}' already exists. Skipping copy.")
|
||||
|
||||
# Copy Presets
|
||||
bundled_presets_dir = self.app_base_dir / BUNDLED_PRESETS_SUBDIR_NAME
|
||||
user_target_presets_dir = self.user_chosen_path / BUNDLED_PRESETS_SUBDIR_NAME
|
||||
|
||||
if bundled_presets_dir.is_dir():
|
||||
try:
|
||||
user_target_presets_dir.mkdir(parents=True, exist_ok=True)
|
||||
for item in bundled_presets_dir.iterdir():
|
||||
target_item = user_target_presets_dir / item.name
|
||||
if not target_item.exists():
|
||||
if item.is_file():
|
||||
shutil.copy2(str(item), str(target_item))
|
||||
print(f"Copied preset '{item.name}' to '{target_item}'")
|
||||
# Add elif item.is_dir() for recursive copy if presets can have subdirs
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Preset Copy Error", f"Could not copy presets to '{user_target_presets_dir}': {e}")
|
||||
else:
|
||||
print(f"Bundled presets directory '{bundled_presets_dir}' not found.")
|
||||
|
||||
|
||||
def _save_initial_user_settings(self):
|
||||
if not self.user_chosen_path:
|
||||
return
|
||||
|
||||
user_settings_path = self.user_chosen_path / USER_SETTINGS_FILENAME
|
||||
settings_data = {}
|
||||
|
||||
# Load existing if it exists (though unlikely for first-time setup, but good practice)
|
||||
if user_settings_path.exists():
|
||||
try:
|
||||
with open(user_settings_path, 'r', encoding='utf-8') as f:
|
||||
settings_data = json.load(f)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Error Loading Settings", f"Could not load existing user settings from '{user_settings_path}': {e}. Will create a new one.")
|
||||
settings_data = {}
|
||||
|
||||
# Update with new values from dialog
|
||||
settings_data['OUTPUT_BASE_DIR'] = self.output_base_dir_edit.text().strip()
|
||||
settings_data['OUTPUT_DIRECTORY_PATTERN'] = self.output_dir_pattern_edit.text().strip()
|
||||
settings_data['OUTPUT_FORMAT_16BIT_PRIMARY'] = self.output_format_16bit_primary_edit.text().strip().lower()
|
||||
settings_data['OUTPUT_FORMAT_8BIT'] = self.output_format_8bit_edit.text().strip().lower()
|
||||
settings_data['RESOLUTION_THRESHOLD_FOR_JPG'] = self.resolution_threshold_jpg_spinbox.value()
|
||||
|
||||
# Ensure general_settings exists for app_version if needed, or other core settings
|
||||
if 'general_settings' not in settings_data:
|
||||
settings_data['general_settings'] = {}
|
||||
# Example: settings_data['general_settings']['some_new_user_setting'] = True
|
||||
|
||||
try:
|
||||
with open(user_settings_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(settings_data, f, indent=4)
|
||||
print(f"Saved user settings to '{user_settings_path}'")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error Saving Settings", f"Could not save user settings to '{user_settings_path}': {e}")
|
||||
|
||||
|
||||
def _save_persistent_info(self):
|
||||
if not self.user_chosen_path:
|
||||
return
|
||||
|
||||
# 1. Save USER_CHOSEN_PATH to a persistent location (e.g., AppData)
|
||||
persistent_storage_dir = get_os_specific_app_data_dir()
|
||||
try:
|
||||
persistent_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
persistent_path_file = persistent_storage_dir / PERSISTENT_CONFIG_ROOT_STORAGE_FILENAME
|
||||
with open(persistent_path_file, 'w', encoding='utf-8') as f:
|
||||
f.write(str(self.user_chosen_path.resolve()))
|
||||
print(f"Saved chosen config path to '{persistent_path_file}'")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Error Saving Path", f"Could not persistently save the chosen configuration path: {e}")
|
||||
# This is not critical enough to stop the setup, but user might need to re-select on next launch.
|
||||
|
||||
# 2. Create marker file in USER_CHOSEN_PATH
|
||||
marker_file = self.user_chosen_path / PERSISTENT_PATH_MARKER_FILENAME
|
||||
try:
|
||||
with open(marker_file, 'w', encoding='utf-8') as f:
|
||||
f.write("Asset Processor first-time setup complete.")
|
||||
print(f"Created marker file at '{marker_file}'")
|
||||
except Exception as e:
|
||||
QMessageBox.warning(self, "Error Creating Marker", f"Could not create first-run marker file at '{marker_file}': {e}")
|
||||
|
||||
@Slot()
|
||||
def _on_finish_setup(self):
|
||||
if not self._validate_inputs():
|
||||
return
|
||||
|
||||
# Confirmation before proceeding
|
||||
reply = QMessageBox.question(self, "Confirm Setup",
|
||||
f"The following path will be used for configuration and user data:\n"
|
||||
f"{self.user_chosen_path}\n\n"
|
||||
f"Default configuration files and presets will be copied if they don't exist.\n"
|
||||
f"Initial user settings will be saved.\n\nProceed with setup?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No)
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
return
|
||||
|
||||
try:
|
||||
self._copy_default_files()
|
||||
self._save_initial_user_settings()
|
||||
self._save_persistent_info()
|
||||
QMessageBox.information(self, "Setup Complete", "First-time setup completed successfully!")
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Setup Error", f"An unexpected error occurred during setup: {e}")
|
||||
# Optionally, attempt cleanup or guide user
|
||||
|
||||
def get_chosen_config_path(self) -> Optional[Path]:
|
||||
"""Returns the path chosen by the user after successful completion."""
|
||||
if self.result() == QDialog.DialogCode.Accepted:
|
||||
return self.user_chosen_path
|
||||
return None
|
||||
|
||||
if __name__ == '__main__':
|
||||
from PySide6.QtWidgets import QApplication
|
||||
app = QApplication(sys.argv)
|
||||
dialog = FirstTimeSetupDialog()
|
||||
if dialog.exec():
|
||||
chosen_path = dialog.get_chosen_config_path()
|
||||
print(f"Dialog accepted. Chosen config path: {chosen_path}")
|
||||
else:
|
||||
print("Dialog cancelled.")
|
||||
sys.exit()
|
||||
Reference in New Issue
Block a user