From 1c1620d91a02c9ffbdaf9c4526a9950aea3afc59 Mon Sep 17 00:00:00 2001 From: Rusfort Date: Fri, 16 May 2025 00:25:46 +0200 Subject: [PATCH] First Time Setup Implementation --- configuration.py | 780 ++++++++---------- .../length.bin | Bin 40000 -> 40000 bytes .../conport_vector_data/chroma.sqlite3 | Bin 163840 -> 163840 bytes context_portal/context.db | Bin 344064 -> 344064 bytes gui/first_time_setup_dialog.py | 388 +++++++++ main.py | 206 ++--- utils/app_setup_utils.py | 66 ++ 7 files changed, 883 insertions(+), 557 deletions(-) create mode 100644 gui/first_time_setup_dialog.py create mode 100644 utils/app_setup_utils.py diff --git a/configuration.py b/configuration.py index 6e64044..659bc7b 100644 --- a/configuration.py +++ b/configuration.py @@ -1,26 +1,42 @@ import json import os +import sys +import shutil from pathlib import Path import logging import re import collections.abc -from typing import Optional +from typing import Optional, Union log = logging.getLogger(__name__) -BASE_DIR = Path(__file__).parent -APP_SETTINGS_PATH = BASE_DIR / "config" / "app_settings.json" -LLM_SETTINGS_PATH = BASE_DIR / "config" / "llm_settings.json" -ASSET_TYPE_DEFINITIONS_PATH = BASE_DIR / "config" / "asset_type_definitions.json" -FILE_TYPE_DEFINITIONS_PATH = BASE_DIR / "config" / "file_type_definitions.json" -USER_SETTINGS_PATH = BASE_DIR / "config" / "user_settings.json" -SUPPLIERS_CONFIG_PATH = BASE_DIR / "config" / "suppliers.json" -PRESETS_DIR = BASE_DIR / "Presets" +# This BASE_DIR is primarily for fallback when not bundled or for locating bundled resources relative to the script. +_SCRIPT_DIR = Path(__file__).resolve().parent class ConfigurationError(Exception): """Custom exception for configuration loading errors.""" pass +def _get_user_config_path_placeholder() -> Optional[Path]: + """ + Placeholder function. In a real scenario, this would retrieve the + saved user configuration path (e.g., from a settings file). + Returns None if not set, triggering first-time setup behavior. + """ + # For this subtask, we assume this path is determined externally and passed to Configuration. + # If we were to implement the settings.ini check here, it would look like: + # try: + # app_data_dir = Path(os.getenv('APPDATA')) / "AssetProcessor" + # settings_ini = app_data_dir / "settings.ini" + # if settings_ini.exists(): + # with open(settings_ini, 'r') as f: + # path_str = f.read().strip() + # return Path(path_str) + # except Exception: + # return None + return None + + def _get_base_map_type(target_map_string: str) -> str: """Extracts the base map type (e.g., 'COL') from a potentially numbered string ('COL-1').""" # Use regex to find the leading alphabetical part @@ -92,53 +108,252 @@ def _deep_merge_dicts(base_dict: dict, override_dict: dict) -> dict: class Configuration: """ - Loads and provides access to core settings combined with a specific preset. + Loads and provides access to core settings combined with a specific preset, + managing bundled and user-specific configuration paths. """ - def __init__(self, preset_name: str): + BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME = "config" + PRESETS_DIR_APP_BUNDLED_NAME = "Presets" + USER_SETTINGS_FILENAME = "user_settings.json" + APP_SETTINGS_FILENAME = "app_settings.json" + ASSET_TYPE_DEFINITIONS_FILENAME = "asset_type_definitions.json" + FILE_TYPE_DEFINITIONS_FILENAME = "file_type_definitions.json" + LLM_SETTINGS_FILENAME = "llm_settings.json" + SUPPLIERS_CONFIG_FILENAME = "suppliers.json" + USER_CONFIG_SUBDIR_NAME = "config" # Subdirectory within user's chosen config root for most jsons + USER_PRESETS_SUBDIR_NAME = "Presets" # Subdirectory within user's chosen config root for presets + + def __init__(self, preset_name: str, base_dir_user_config: Optional[Path] = None, is_first_run_setup: bool = False): """ Loads core config, user overrides, and the specified preset file. Args: preset_name: The name of the preset (without .json extension). - + base_dir_user_config: The root path for user-specific configurations. + If None, loading of user-specific files will be skipped or may fail. + is_first_run_setup: Flag indicating if this is part of the initial setup + process where user config dir might be empty and fallbacks + should not aggressively try to copy from bundle until UI confirms. Raises: - ConfigurationError: If core config or preset cannot be loaded/validated. + ConfigurationError: If critical configurations cannot be loaded/validated. """ - log.debug(f"Initializing Configuration with preset filename stem: '{preset_name}'") - self._preset_filename_stem = preset_name # Store the stem used for loading + log.debug(f"Initializing Configuration with preset: '{preset_name}', user_config_dir: '{base_dir_user_config}', first_run_flag: {is_first_run_setup}") + self._preset_filename_stem = preset_name + self.base_dir_user_config: Optional[Path] = base_dir_user_config + self.is_first_run_setup = is_first_run_setup + self.base_dir_app_bundled: Path = self._determine_base_dir_app_bundled() - # 1. Load core settings - self._core_settings: dict = self._load_core_config() + log.info(f"Determined BASE_DIR_APP_BUNDLED: {self.base_dir_app_bundled}") + log.info(f"Using BASE_DIR_USER_CONFIG: {self.base_dir_user_config}") - # 2. Load asset type definitions - self._asset_type_definitions: dict = self._load_asset_type_definitions() + # 1. Load core application settings (always from bundled) + app_settings_path = self.base_dir_app_bundled / self.BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME / self.APP_SETTINGS_FILENAME + self._core_settings: dict = self._load_json_file( + app_settings_path, + is_critical=True, + description="Core application settings" + ) - # 3. Load file type definitions - self._file_type_definitions: dict = self._load_file_type_definitions() + # 2. Load user settings (from user config dir, if provided) + user_settings_overrides: dict = {} + if self.base_dir_user_config: + user_settings_file_path = self.base_dir_user_config / self.USER_SETTINGS_FILENAME + user_settings_overrides = self._load_json_file( + user_settings_file_path, + is_critical=False, # Not critical if missing, especially on first run + description=f"User settings from {user_settings_file_path}" + ) or {} # Ensure it's a dict + else: + log.info(f"{self.USER_SETTINGS_FILENAME} not loaded: User config directory not set.") - # 4. Load user settings - user_settings_overrides: dict = self._load_user_settings() - - # 5. Deep merge user settings onto core settings + # 3. Deep merge user settings onto core settings if user_settings_overrides: - log.info("Applying user setting overrides to core settings.") - # _deep_merge_dicts modifies self._core_settings in place + log.info(f"Applying user setting overrides to core settings.") _deep_merge_dicts(self._core_settings, user_settings_overrides) - # 6. Load LLM settings - self._llm_settings: dict = self._load_llm_config() + # 4. Load other definition files (from user config dir, with fallback from bundled) + self._asset_type_definitions: dict = self._load_definition_file_with_fallback( + self.ASSET_TYPE_DEFINITIONS_FILENAME, "ASSET_TYPE_DEFINITIONS" + ) + self._file_type_definitions: dict = self._load_definition_file_with_fallback( + self.FILE_TYPE_DEFINITIONS_FILENAME, "FILE_TYPE_DEFINITIONS" + ) + self._llm_settings: dict = self._load_definition_file_with_fallback( + self.LLM_SETTINGS_FILENAME, None # LLM settings might be flat (no root key) + ) + self._suppliers_config: dict = self._load_definition_file_with_fallback( + self.SUPPLIERS_CONFIG_FILENAME, None # Suppliers config is flat + ) - # 7. Load preset settings (conceptually overrides combined base + user for shared keys) - self._preset_settings: dict = self._load_preset(self._preset_filename_stem) # Use the stored stem + # 5. Load preset settings (from user config dir, with fallback from bundled) + self._preset_settings: dict = self._load_preset_with_fallback(self._preset_filename_stem) - # Store the actual preset name read from the file content self.actual_internal_preset_name = self._preset_settings.get("preset_name", self._preset_filename_stem) log.info(f"Configuration instance: Loaded preset file '{self._preset_filename_stem}.json', internal preset_name is '{self.actual_internal_preset_name}'") - # 8. Validate and compile (after all base/user/preset settings are established) + # 6. Validate and compile (after all base/user/preset settings are established) self._validate_configs() self._compile_regex_patterns() - log.info(f"Configuration loaded successfully using preset: '{self.actual_internal_preset_name}'") # Changed self.preset_name to self.actual_internal_preset_name + log.info(f"Configuration loaded successfully using preset: '{self.actual_internal_preset_name}'") + + def _determine_base_dir_app_bundled(self) -> Path: + """Determines the base directory for bundled application resources.""" + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + # Running in a PyInstaller bundle + log.debug(f"Running as bundled app, _MEIPASS: {sys._MEIPASS}") + return Path(sys._MEIPASS) + else: + # Running as a script + log.debug(f"Running as script, using _SCRIPT_DIR: {_SCRIPT_DIR}") + return _SCRIPT_DIR + + def _ensure_dir_exists(self, dir_path: Path): + """Ensures a directory exists, creating it if necessary.""" + try: + if not dir_path.exists(): + log.info(f"Directory not found, creating: {dir_path}") + dir_path.mkdir(parents=True, exist_ok=True) + elif not dir_path.is_dir(): + raise ConfigurationError(f"Expected directory but found file: {dir_path}") + except OSError as e: + raise ConfigurationError(f"Failed to create or access directory {dir_path}: {e}") + + def _copy_default_if_missing(self, user_target_path: Path, bundled_source_subdir: str, filename: str) -> bool: + """ + Copies a default file from the bundled location to the user config directory + if it's missing in the user directory. This is for post-first-time-setup fallback. + """ + if not self.base_dir_user_config: + log.error(f"Cannot copy default for '{filename}': base_dir_user_config is not set.") + return False + + if user_target_path.exists(): + log.debug(f"User file '{user_target_path}' already exists. No copy needed from bundle.") + return False + + # This fallback copy should NOT happen during the initial UI-driven setup phase + # where the UI is responsible for the first population of the user directory. + # It's for subsequent runs where a user might have deleted a file. + if self.is_first_run_setup: + log.debug(f"'{filename}' missing in user dir during first_run_setup phase. UI should handle initial copy. Skipping fallback copy.") + return False # File is missing, but UI should handle it. + + bundled_file_path = self.base_dir_app_bundled / bundled_source_subdir / filename + if not bundled_file_path.is_file(): + log.warning(f"Default bundled file '{bundled_file_path}' not found. Cannot copy to user location '{user_target_path}'.") + return False + + log.warning(f"User file '{user_target_path}' is missing. Attempting to restore from bundled default: '{bundled_file_path}'.") + try: + self._ensure_dir_exists(user_target_path.parent) + shutil.copy2(bundled_file_path, user_target_path) + log.info(f"Successfully copied '{bundled_file_path}' to '{user_target_path}'.") + return True # File was copied + except Exception as e: + log.error(f"Failed to copy '{bundled_file_path}' to '{user_target_path}': {e}") + return False # Copy failed + + def _load_json_file(self, file_path: Optional[Path], is_critical: bool = False, description: str = "configuration") -> dict: + """Loads a JSON file, handling errors. Returns empty dict if not found and not critical.""" + if not file_path: + if is_critical: + raise ConfigurationError(f"Critical {description} file path is not defined.") + log.debug(f"{description} file path is not defined. Returning empty dict.") + return {} + + log.debug(f"Attempting to load {description} from: {file_path}") + if not file_path.is_file(): + if is_critical: + raise ConfigurationError(f"Critical {description} file not found: {file_path}") + log.info(f"{description} file not found: {file_path}. Returning empty dict.") + return {} + try: + with open(file_path, 'r', encoding='utf-8') as f: + settings = json.load(f) + log.debug(f"{description} loaded successfully from {file_path}.") + return settings + except json.JSONDecodeError as e: + msg = f"Failed to parse {description} file {file_path}: Invalid JSON - {e}" + if is_critical: raise ConfigurationError(msg) + log.warning(msg + ". Returning empty dict.") + return {} + except Exception as e: + msg = f"Failed to read {description} file {file_path}: {e}" + if is_critical: raise ConfigurationError(msg) + log.warning(msg + ". Returning empty dict.") + return {} + + def _load_definition_file_with_fallback(self, filename: str, root_key: Optional[str] = None) -> dict: + """ + Loads a definition JSON file from the user config subdir. + If not found and not first_run_setup, attempts to copy from bundled config subdir and then loads it. + If base_dir_user_config is not set, loads directly from bundled (read-only). + """ + data = {} + user_file_path = None + + if self.base_dir_user_config: + user_file_path = self.base_dir_user_config / self.USER_CONFIG_SUBDIR_NAME / filename + data = self._load_json_file(user_file_path, is_critical=False, description=f"User {filename}") + + if not data: # If not found or failed to load from user path + # Attempt fallback copy only if not in the initial setup phase by UI + # and if the file was genuinely missing (not a parse error for an existing file) + if not user_file_path.exists() and not self.is_first_run_setup: + if self._copy_default_if_missing(user_file_path, self.BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME, filename): + data = self._load_json_file(user_file_path, is_critical=False, description=f"User {filename} after copy") + else: + # No user_config_dir, load directly from bundled (read-only) + log.warning(f"User config directory not set. Loading '{filename}' from bundled defaults (read-only).") + bundled_path = self.base_dir_app_bundled / self.BASE_DIR_APP_BUNDLED_CONFIG_SUBDIR_NAME / filename + data = self._load_json_file(bundled_path, is_critical=False, description=f"Bundled {filename}") + + if not data: + # If still no data, it's an issue, especially for critical definitions + is_critical_def = filename in [self.ASSET_TYPE_DEFINITIONS_FILENAME, self.FILE_TYPE_DEFINITIONS_FILENAME] + err_msg = f"Failed to load '{filename}' from user dir '{user_file_path if user_file_path else 'N/A'}' or bundled defaults. Critical functionality may be affected." + if is_critical_def: raise ConfigurationError(err_msg) + log.error(err_msg) + return {} + + if root_key: + if root_key not in data: + raise ConfigurationError(f"Key '{root_key}' not found in loaded {filename} data: {data.keys()}") + content = data[root_key] + # Ensure content is a dictionary if a root_key is expected to yield one + if not isinstance(content, dict): + raise ConfigurationError(f"Content under root key '{root_key}' in {filename} must be a dictionary, got {type(content)}.") + return content + return data # For flat files + + def _load_preset_with_fallback(self, preset_name_stem: str) -> dict: + """ + Loads a preset JSON file from the user's Presets subdir. + If not found and not first_run_setup, attempts to copy from bundled Presets and then loads it. + If base_dir_user_config is not set, loads directly from bundled (read-only). + """ + preset_filename = f"{preset_name_stem}.json" + preset_data = {} + user_preset_file_path = None + + if self.base_dir_user_config: + user_presets_dir = self.base_dir_user_config / self.USER_PRESETS_SUBDIR_NAME + user_preset_file_path = user_presets_dir / preset_filename + preset_data = self._load_json_file(user_preset_file_path, is_critical=False, description=f"User preset '{preset_filename}'") + + if not preset_data: # If not found or failed to load + if not user_preset_file_path.exists() and not self.is_first_run_setup: + if self._copy_default_if_missing(user_preset_file_path, self.PRESETS_DIR_APP_BUNDLED_NAME, preset_filename): + preset_data = self._load_json_file(user_preset_file_path, is_critical=False, description=f"User preset '{preset_filename}' after copy") + else: + log.warning(f"User config directory not set. Loading preset '{preset_filename}' from bundled defaults (read-only).") + bundled_presets_dir = self.base_dir_app_bundled / self.PRESETS_DIR_APP_BUNDLED_NAME + bundled_preset_file_path = bundled_presets_dir / preset_filename + # Presets are generally critical for operation if one is specified + preset_data = self._load_json_file(bundled_preset_file_path, is_critical=True, description=f"Bundled preset '{preset_filename}'") + + if not preset_data: + raise ConfigurationError(f"Preset file '{preset_filename}' could not be loaded from user dir '{user_preset_file_path if user_preset_file_path else 'N/A'}' or bundled defaults.") + return preset_data def _compile_regex_patterns(self): @@ -237,118 +452,6 @@ class Configuration: log.debug("Finished compiling regex patterns.") - def _load_core_config(self) -> dict: - """Loads settings from the core app_settings.json file.""" - log.debug(f"Loading core config from: {APP_SETTINGS_PATH}") - if not APP_SETTINGS_PATH.is_file(): - raise ConfigurationError(f"Core configuration file not found: {APP_SETTINGS_PATH}") - try: - with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f: - settings = json.load(f) - log.debug(f"Core config loaded successfully.") - return settings - except json.JSONDecodeError as e: - raise ConfigurationError(f"Failed to parse core configuration file {APP_SETTINGS_PATH}: Invalid JSON - {e}") - except Exception as e: - raise ConfigurationError(f"Failed to read core configuration file {APP_SETTINGS_PATH}: {e}") - - def _load_llm_config(self) -> dict: - """Loads settings from the llm_settings.json file.""" - log.debug(f"Loading LLM config from: {LLM_SETTINGS_PATH}") - if not LLM_SETTINGS_PATH.is_file(): - # Log a warning but don't raise an error, allow fallback if possible - log.warning(f"LLM configuration file not found: {LLM_SETTINGS_PATH}. LLM features might be disabled or use defaults.") - return {} - try: - with open(LLM_SETTINGS_PATH, 'r', encoding='utf-8') as f: - settings = json.load(f) - log.debug(f"LLM config loaded successfully.") - return settings - except json.JSONDecodeError as e: - log.error(f"Failed to parse LLM configuration file {LLM_SETTINGS_PATH}: Invalid JSON - {e}") - return {} - except Exception as e: - log.error(f"Failed to read LLM configuration file {LLM_SETTINGS_PATH}: {e}") - return {} - - - def _load_preset(self, preset_name: str) -> dict: - """Loads the specified preset JSON file.""" - log.debug(f"Loading preset: '{preset_name}' from {PRESETS_DIR}") - if not PRESETS_DIR.is_dir(): - raise ConfigurationError(f"Presets directory not found: {PRESETS_DIR}") - - preset_file = PRESETS_DIR / f"{preset_name}.json" - if not preset_file.is_file(): - raise ConfigurationError(f"Preset file not found: {preset_file}") - - try: - with open(preset_file, 'r', encoding='utf-8') as f: - preset_data = json.load(f) - log.debug(f"Preset '{preset_name}' loaded successfully.") - return preset_data - except json.JSONDecodeError as e: - raise ConfigurationError(f"Failed to parse preset file {preset_file}: Invalid JSON - {e}") - except Exception as e: - raise ConfigurationError(f"Failed to read preset file {preset_file}: {e}") - - def _load_asset_type_definitions(self) -> dict: - """Loads asset type definitions from the asset_type_definitions.json file.""" - log.debug(f"Loading asset type definitions from: {ASSET_TYPE_DEFINITIONS_PATH}") - if not ASSET_TYPE_DEFINITIONS_PATH.is_file(): - raise ConfigurationError(f"Asset type definitions file not found: {ASSET_TYPE_DEFINITIONS_PATH}") - try: - with open(ASSET_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f: - data = json.load(f) - if "ASSET_TYPE_DEFINITIONS" not in data: - raise ConfigurationError(f"Key 'ASSET_TYPE_DEFINITIONS' not found in {ASSET_TYPE_DEFINITIONS_PATH}") - settings = data["ASSET_TYPE_DEFINITIONS"] - if not isinstance(settings, dict): - raise ConfigurationError(f"'ASSET_TYPE_DEFINITIONS' in {ASSET_TYPE_DEFINITIONS_PATH} must be a dictionary.") - log.debug(f"Asset type definitions loaded successfully.") - return settings - except json.JSONDecodeError as e: - raise ConfigurationError(f"Failed to parse asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}") - except Exception as e: - raise ConfigurationError(f"Failed to read asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}") - - def _load_file_type_definitions(self) -> dict: - """Loads file type definitions from the file_type_definitions.json file.""" - log.debug(f"Loading file type definitions from: {FILE_TYPE_DEFINITIONS_PATH}") - if not FILE_TYPE_DEFINITIONS_PATH.is_file(): - raise ConfigurationError(f"File type definitions file not found: {FILE_TYPE_DEFINITIONS_PATH}") - try: - with open(FILE_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f: - data = json.load(f) - if "FILE_TYPE_DEFINITIONS" not in data: - raise ConfigurationError(f"Key 'FILE_TYPE_DEFINITIONS' not found in {FILE_TYPE_DEFINITIONS_PATH}") - settings = data["FILE_TYPE_DEFINITIONS"] - if not isinstance(settings, dict): - raise ConfigurationError(f"'FILE_TYPE_DEFINITIONS' in {FILE_TYPE_DEFINITIONS_PATH} must be a dictionary.") - log.debug(f"File type definitions loaded successfully.") - return settings - except json.JSONDecodeError as e: - raise ConfigurationError(f"Failed to parse file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}") - except Exception as e: - raise ConfigurationError(f"Failed to read file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}") - - def _load_user_settings(self) -> dict: - """Loads user override settings from config/user_settings.json.""" - log.debug(f"Attempting to load user settings from: {USER_SETTINGS_PATH}") - if not USER_SETTINGS_PATH.is_file(): - log.info(f"User settings file not found: {USER_SETTINGS_PATH}. Proceeding without user overrides.") - return {} - try: - with open(USER_SETTINGS_PATH, 'r', encoding='utf-8') as f: - settings = json.load(f) - log.info(f"User settings loaded successfully from {USER_SETTINGS_PATH}.") - return settings - except json.JSONDecodeError as e: - log.warning(f"Failed to parse user settings file {USER_SETTINGS_PATH}: Invalid JSON - {e}. Using empty user settings.") - return {} - except Exception as e: - log.warning(f"Failed to read user settings file {USER_SETTINGS_PATH}: {e}. Using empty user settings.") - return {} def _validate_configs(self): """Performs basic validation checks on loaded settings.""" @@ -445,9 +548,14 @@ class Configuration: @property - def supplier_name(self) -> str: + def supplier_name(self) -> str: # From preset return self._preset_settings.get('supplier_name', 'DefaultSupplier') + @property + def suppliers_config(self) -> dict: # From suppliers.json + """Returns the loaded suppliers configuration.""" + return self._suppliers_config + @property def internal_display_preset_name(self) -> str: """Returns the 'preset_name' field from within the loaded preset JSON, @@ -716,9 +824,64 @@ class Configuration: return self._core_settings.get('LOW_RESOLUTION_THRESHOLD', 512) @property - def FILE_TYPE_DEFINITIONS(self) -> dict: + def FILE_TYPE_DEFINITIONS(self) -> dict: # Kept for compatibility if used directly return self._file_type_definitions + # --- Save Methods --- + def _save_json_to_user_config(self, data_to_save: dict, filename: str, subdir: Optional[str] = None, is_root_key_data: Optional[str] = None): + """Helper to save a dictionary to a JSON file in the user config directory.""" + if not self.base_dir_user_config: + raise ConfigurationError(f"Cannot save {filename}: User config directory (base_dir_user_config) is not set.") + + target_dir = self.base_dir_user_config + if subdir: + target_dir = target_dir / subdir + + self._ensure_dir_exists(target_dir) + path = target_dir / filename + + data_for_json = {is_root_key_data: data_to_save} if is_root_key_data else data_to_save + + log.debug(f"Saving data to: {path}") + try: + with open(path, 'w', encoding='utf-8') as f: + json.dump(data_for_json, f, indent=4) + log.info(f"Data saved successfully to {path}") + except Exception as e: + log.error(f"Failed to save file {path}: {e}") + raise ConfigurationError(f"Failed to save {filename}: {e}") + + def save_user_settings(self, settings_dict: dict): + """Saves the provided settings dictionary to user_settings.json in the user config directory.""" + self._save_json_to_user_config(settings_dict, self.USER_SETTINGS_FILENAME) + + def save_llm_settings(self, settings_dict: dict): + """Saves LLM settings to the user config directory's 'config' subdir.""" + self._save_json_to_user_config(settings_dict, self.LLM_SETTINGS_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME) + + def save_asset_type_definitions(self, data: dict): + """Saves asset type definitions to the user config directory's 'config' subdir.""" + self._save_json_to_user_config(data, self.ASSET_TYPE_DEFINITIONS_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME, is_root_key_data="ASSET_TYPE_DEFINITIONS") + + def save_file_type_definitions(self, data: dict): + """Saves file type definitions to the user config directory's 'config' subdir.""" + self._save_json_to_user_config(data, self.FILE_TYPE_DEFINITIONS_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME, is_root_key_data="FILE_TYPE_DEFINITIONS") + + def save_supplier_settings(self, data: dict): + """Saves supplier settings to the user config directory's 'config' subdir.""" + self._save_json_to_user_config(data, self.SUPPLIERS_CONFIG_FILENAME, subdir=self.USER_CONFIG_SUBDIR_NAME) + + def save_preset(self, preset_data: dict, preset_name_stem: str): + """Saves a preset to the user config directory's 'Presets' subdir.""" + if not preset_name_stem: + raise ConfigurationError("Preset name stem cannot be empty for saving.") + preset_filename = f"{preset_name_stem}.json" + # Ensure the preset_data itself contains the correct 'preset_name' field + # or update it before saving if necessary. + # For example: preset_data['preset_name'] = preset_name_stem + self._save_json_to_user_config(preset_data, preset_filename, subdir=self.USER_PRESETS_SUBDIR_NAME) + + @property def keybind_config(self) -> dict[str, list[str]]: """ @@ -742,275 +905,60 @@ class Configuration: # For now, we rely on the order they appear in the config. return keybinds -def load_base_config() -> dict: - """ - Loads base configuration by merging app_settings.json, user_settings.json (if exists), - asset_type_definitions.json, and file_type_definitions.json. - Does not load presets or perform full validation beyond basic file loading. - Returns a dictionary containing the merged settings. If app_settings.json - fails to load, an empty dictionary is returned. If other files - fail, errors are logged, and the function proceeds with what has been loaded. - """ - base_settings = {} +# The global load_base_config() is effectively replaced by Configuration.__init__ +# Global save/load functions for individual files are refactored to be methods +# of the Configuration class or called by them, using instance paths. - # 1. Load app_settings.json (critical) - if not APP_SETTINGS_PATH.is_file(): - log.error(f"Critical: Base application settings file not found: {APP_SETTINGS_PATH}. Returning empty configuration.") - return {} - try: - with open(APP_SETTINGS_PATH, 'r', encoding='utf-8') as f: - base_settings = json.load(f) - log.info(f"Successfully loaded base application settings from: {APP_SETTINGS_PATH}") - except json.JSONDecodeError as e: - log.error(f"Critical: Failed to parse base application settings file {APP_SETTINGS_PATH}: Invalid JSON - {e}. Returning empty configuration.") - return {} - except Exception as e: - log.error(f"Critical: Failed to read base application settings file {APP_SETTINGS_PATH}: {e}. Returning empty configuration.") - return {} - - # 2. Attempt to load user_settings.json - user_settings_overrides = {} - if USER_SETTINGS_PATH.is_file(): - try: - with open(USER_SETTINGS_PATH, 'r', encoding='utf-8') as f: - user_settings_overrides = json.load(f) - log.info(f"User settings loaded successfully for base_config from {USER_SETTINGS_PATH}.") - except json.JSONDecodeError as e: - log.warning(f"Failed to parse user settings file {USER_SETTINGS_PATH} for base_config: Invalid JSON - {e}. Proceeding without these user overrides.") - except Exception as e: - log.warning(f"Failed to read user settings file {USER_SETTINGS_PATH} for base_config: {e}. Proceeding without these user overrides.") +# For example, to get a list of preset names, one might need a static method +# or a function that knows about both bundled and user preset directories. +def get_available_preset_names(base_dir_user_config: Optional[Path], base_dir_app_bundled: Path) -> list[str]: + """ + Gets a list of available preset names (stems) by looking in user presets + and then bundled presets. User presets take precedence. + """ + preset_names = set() - # 3. Deep merge user settings onto base_settings - if user_settings_overrides: - log.info("Applying user setting overrides to base_settings in load_base_config.") - # _deep_merge_dicts modifies base_settings in place - _deep_merge_dicts(base_settings, user_settings_overrides) - - # 4. Load asset_type_definitions.json (non-critical, merge if successful) - if not ASSET_TYPE_DEFINITIONS_PATH.is_file(): - log.error(f"Asset type definitions file not found: {ASSET_TYPE_DEFINITIONS_PATH}. Proceeding without it.") - else: - try: - with open(ASSET_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f: - asset_defs_data = json.load(f) - if "ASSET_TYPE_DEFINITIONS" in asset_defs_data: - if isinstance(asset_defs_data["ASSET_TYPE_DEFINITIONS"], dict): - # Merge into base_settings, which might already contain user overrides - base_settings['ASSET_TYPE_DEFINITIONS'] = asset_defs_data["ASSET_TYPE_DEFINITIONS"] - log.info(f"Successfully loaded and merged ASSET_TYPE_DEFINITIONS from: {ASSET_TYPE_DEFINITIONS_PATH}") - else: - log.error(f"Value under 'ASSET_TYPE_DEFINITIONS' in {ASSET_TYPE_DEFINITIONS_PATH} is not a dictionary. Skipping merge.") - else: - log.error(f"Key 'ASSET_TYPE_DEFINITIONS' not found in {ASSET_TYPE_DEFINITIONS_PATH}. Skipping merge.") - except json.JSONDecodeError as e: - log.error(f"Failed to parse asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}. Skipping merge.") - except Exception as e: - log.error(f"Failed to read asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}. Skipping merge.") - - # 5. Load file_type_definitions.json (non-critical, merge if successful) - if not FILE_TYPE_DEFINITIONS_PATH.is_file(): - log.error(f"File type definitions file not found: {FILE_TYPE_DEFINITIONS_PATH}. Proceeding without it.") - else: - try: - with open(FILE_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f: - file_defs_data = json.load(f) - if "FILE_TYPE_DEFINITIONS" in file_defs_data: - if isinstance(file_defs_data["FILE_TYPE_DEFINITIONS"], dict): - # Merge into base_settings - base_settings['FILE_TYPE_DEFINITIONS'] = file_defs_data["FILE_TYPE_DEFINITIONS"] - log.info(f"Successfully loaded and merged FILE_TYPE_DEFINITIONS from: {FILE_TYPE_DEFINITIONS_PATH}") - else: - log.error(f"Value under 'FILE_TYPE_DEFINITIONS' in {FILE_TYPE_DEFINITIONS_PATH} is not a dictionary. Skipping merge.") - else: - log.error(f"Key 'FILE_TYPE_DEFINITIONS' not found in {FILE_TYPE_DEFINITIONS_PATH}. Skipping merge.") - except json.JSONDecodeError as e: - log.error(f"Failed to parse file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}. Skipping merge.") - except Exception as e: - log.error(f"Failed to read file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}. Skipping merge.") - - return base_settings - -def save_llm_config(settings_dict: dict): - """ - Saves the provided LLM settings dictionary to llm_settings.json. - """ - log.debug(f"Saving LLM config to: {LLM_SETTINGS_PATH}") - try: - with open(LLM_SETTINGS_PATH, 'w', encoding='utf-8') as f: - json.dump(settings_dict, f, indent=4) - # Use info level for successful save - log.info(f"LLM config saved successfully to {LLM_SETTINGS_PATH}") - except Exception as e: - log.error(f"Failed to save LLM configuration file {LLM_SETTINGS_PATH}: {e}") - # Re-raise as ConfigurationError to signal failure upstream - raise ConfigurationError(f"Failed to save LLM configuration: {e}") -def save_user_config(settings_dict: dict): - """Saves the provided settings dictionary to user_settings.json.""" - log.debug(f"Saving user config to: {USER_SETTINGS_PATH}") - try: - # Ensure parent directory exists (though 'config/' should always exist) - USER_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) - with open(USER_SETTINGS_PATH, 'w', encoding='utf-8') as f: - json.dump(settings_dict, f, indent=4) - log.info(f"User config saved successfully to {USER_SETTINGS_PATH}") - except Exception as e: - log.error(f"Failed to save user configuration file {USER_SETTINGS_PATH}: {e}") - raise ConfigurationError(f"Failed to save user configuration: {e}") -def save_base_config(settings_dict: dict): - """ - Saves the provided settings dictionary to app_settings.json. - """ - log.debug(f"Saving base config to: {APP_SETTINGS_PATH}") - try: - with open(APP_SETTINGS_PATH, 'w', encoding='utf-8') as f: - json.dump(settings_dict, f, indent=4) - log.debug(f"Base config saved successfully.") - except Exception as e: - log.error(f"Failed to save base configuration file {APP_SETTINGS_PATH}: {e}") - raise ConfigurationError(f"Failed to save configuration: {e}") - -def load_asset_definitions() -> dict: - """ - Reads config/asset_type_definitions.json. - Returns the dictionary under the "ASSET_TYPE_DEFINITIONS" key. - Handles file not found or JSON errors gracefully (e.g., return empty dict, log error). - """ - log.debug(f"Loading asset type definitions from: {ASSET_TYPE_DEFINITIONS_PATH}") - if not ASSET_TYPE_DEFINITIONS_PATH.is_file(): - log.error(f"Asset type definitions file not found: {ASSET_TYPE_DEFINITIONS_PATH}") - return {} - try: - with open(ASSET_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f: - data = json.load(f) - if "ASSET_TYPE_DEFINITIONS" not in data: - log.error(f"Key 'ASSET_TYPE_DEFINITIONS' not found in {ASSET_TYPE_DEFINITIONS_PATH}") - return {} - settings = data["ASSET_TYPE_DEFINITIONS"] - if not isinstance(settings, dict): - log.error(f"'ASSET_TYPE_DEFINITIONS' in {ASSET_TYPE_DEFINITIONS_PATH} must be a dictionary.") - return {} - log.debug(f"Asset type definitions loaded successfully.") - return settings - except json.JSONDecodeError as e: - log.error(f"Failed to parse asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}") - return {} - except Exception as e: - log.error(f"Failed to read asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}") - return {} - -def save_asset_definitions(data: dict): - """ - Takes a dictionary (representing the content for the "ASSET_TYPE_DEFINITIONS" key). - Writes it to config/asset_type_definitions.json under the root key "ASSET_TYPE_DEFINITIONS". - Handles potential I/O errors. - """ - log.debug(f"Saving asset type definitions to: {ASSET_TYPE_DEFINITIONS_PATH}") - try: - with open(ASSET_TYPE_DEFINITIONS_PATH, 'w', encoding='utf-8') as f: - json.dump({"ASSET_TYPE_DEFINITIONS": data}, f, indent=4) - log.info(f"Asset type definitions saved successfully to {ASSET_TYPE_DEFINITIONS_PATH}") - except Exception as e: - log.error(f"Failed to save asset type definitions file {ASSET_TYPE_DEFINITIONS_PATH}: {e}") - raise ConfigurationError(f"Failed to save asset type definitions: {e}") - -def load_file_type_definitions() -> dict: - """ - Reads config/file_type_definitions.json. - Returns the dictionary under the "FILE_TYPE_DEFINITIONS" key. - Handles errors gracefully. - """ - log.debug(f"Loading file type definitions from: {FILE_TYPE_DEFINITIONS_PATH}") - if not FILE_TYPE_DEFINITIONS_PATH.is_file(): - log.error(f"File type definitions file not found: {FILE_TYPE_DEFINITIONS_PATH}") - return {} - try: - with open(FILE_TYPE_DEFINITIONS_PATH, 'r', encoding='utf-8') as f: - data = json.load(f) - if "FILE_TYPE_DEFINITIONS" not in data: - log.error(f"Key 'FILE_TYPE_DEFINITIONS' not found in {FILE_TYPE_DEFINITIONS_PATH}") - return {} - settings = data["FILE_TYPE_DEFINITIONS"] - if not isinstance(settings, dict): - log.error(f"'FILE_TYPE_DEFINITIONS' in {FILE_TYPE_DEFINITIONS_PATH} must be a dictionary.") - return {} - log.debug(f"File type definitions loaded successfully.") - return settings - except json.JSONDecodeError as e: - log.error(f"Failed to parse file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: Invalid JSON - {e}") - return {} - except Exception as e: - log.error(f"Failed to read file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}") - return {} - -def save_file_type_definitions(data: dict): - """ - Takes a dictionary (representing content for "FILE_TYPE_DEFINITIONS" key). - Writes it to config/file_type_definitions.json under the root key "FILE_TYPE_DEFINITIONS". - Handles errors. - """ - log.debug(f"Saving file type definitions to: {FILE_TYPE_DEFINITIONS_PATH}") - try: - with open(FILE_TYPE_DEFINITIONS_PATH, 'w', encoding='utf-8') as f: - json.dump({"FILE_TYPE_DEFINITIONS": data}, f, indent=4) - log.info(f"File type definitions saved successfully to {FILE_TYPE_DEFINITIONS_PATH}") - except Exception as e: - log.error(f"Failed to save file type definitions file {FILE_TYPE_DEFINITIONS_PATH}: {e}") - raise ConfigurationError(f"Failed to save file type definitions: {e}") - -def load_supplier_settings() -> dict: - """ - Reads config/suppliers.json. - Returns the entire dictionary. - Handles file not found (return empty dict) or JSON errors. - If the loaded data is a list (old format), convert it in memory to the new - dictionary format, defaulting normal_map_type to "OpenGL" for each supplier. - """ - log.debug(f"Loading supplier settings from: {SUPPLIERS_CONFIG_PATH}") - if not SUPPLIERS_CONFIG_PATH.is_file(): - log.warning(f"Supplier settings file not found: {SUPPLIERS_CONFIG_PATH}. Returning empty dict.") - return {} - try: - with open(SUPPLIERS_CONFIG_PATH, 'r', encoding='utf-8') as f: - data = json.load(f) - - if isinstance(data, list): - log.warning(f"Supplier settings in {SUPPLIERS_CONFIG_PATH} is in the old list format. Converting to new dictionary format.") - new_data = {} - for supplier_name in data: - if isinstance(supplier_name, str): - new_data[supplier_name] = {"normal_map_type": "OpenGL"} - else: - log.warning(f"Skipping non-string item '{supplier_name}' during old format conversion of supplier settings.") - log.info(f"Supplier settings converted to new format: {new_data}") - return new_data - - if not isinstance(data, dict): - log.error(f"Supplier settings in {SUPPLIERS_CONFIG_PATH} must be a dictionary. Found {type(data)}. Returning empty dict.") - return {} + # Check user presets first + if base_dir_user_config: + user_presets_dir = base_dir_user_config / Configuration.USER_PRESETS_SUBDIR_NAME + if user_presets_dir.is_dir(): + for f in user_presets_dir.glob("*.json"): + preset_names.add(f.stem) + + # Check bundled presets + bundled_presets_dir = base_dir_app_bundled / Configuration.PRESETS_DIR_APP_BUNDLED_NAME + if bundled_presets_dir.is_dir(): + for f in bundled_presets_dir.glob("*.json"): + preset_names.add(f.stem) # Adds if not already present from user dir - log.debug(f"Supplier settings loaded successfully.") - return data - except json.JSONDecodeError as e: - log.error(f"Failed to parse supplier settings file {SUPPLIERS_CONFIG_PATH}: Invalid JSON - {e}. Returning empty dict.") - return {} - except Exception as e: - log.error(f"Failed to read supplier settings file {SUPPLIERS_CONFIG_PATH}: {e}. Returning empty dict.") - return {} + if not preset_names: + log.warning("No preset files found in user or bundled preset directories.") + # Consider adding a default/template preset if none are found, or ensure one always exists in bundle. + # For now, return empty list. + + return sorted(list(preset_names)) -def save_supplier_settings(data: dict): - """ - Takes a dictionary (in the new format). - Writes it directly to config/suppliers.json. - Handles errors. - """ - log.debug(f"Saving supplier settings to: {SUPPLIERS_CONFIG_PATH}") - if not isinstance(data, dict): - log.error(f"Data for save_supplier_settings must be a dictionary. Got {type(data)}.") - raise ConfigurationError(f"Invalid data type for saving supplier settings: {type(data)}") - try: - with open(SUPPLIERS_CONFIG_PATH, 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2) # Using indent=2 as per the example for suppliers.json - log.info(f"Supplier settings saved successfully to {SUPPLIERS_CONFIG_PATH}") - except Exception as e: - log.error(f"Failed to save supplier settings file {SUPPLIERS_CONFIG_PATH}: {e}") - raise ConfigurationError(f"Failed to save supplier settings: {e}") +# Global functions like load_asset_definitions, save_asset_definitions etc. +# are now instance methods of the Configuration class (e.g., self.save_asset_type_definitions). +# If any external code was calling these global functions, it will need to be updated +# to instantiate a Configuration object and call its methods, or these global +# functions need to be carefully adapted to instantiate Configuration internally +# or accept a Configuration instance. + +# For now, let's assume the primary interaction is via Configuration instance. +# The old global functions below this point are effectively deprecated by the class methods. +# I will remove them to avoid confusion and ensure all save/load operations +# are managed through the Configuration instance with correct path context. + +# Removing old global load/save functions as their logic is now +# part of the Configuration class or replaced by its new loading/saving mechanisms. +# load_base_config() - Replaced by Configuration.__init__() +# save_llm_config(settings_dict: dict) - Replaced by Configuration.save_llm_settings() +# save_user_config(settings_dict: dict) - Replaced by Configuration.save_user_settings() +# save_base_config(settings_dict: dict) - Bundled app_settings.json should be read-only. +# load_asset_definitions() -> dict - Replaced by Configuration._load_definition_file_with_fallback() logic +# save_asset_definitions(data: dict) - Replaced by Configuration.save_asset_type_definitions() +# load_file_type_definitions() -> dict - Replaced by Configuration._load_definition_file_with_fallback() logic +# save_file_type_definitions(data: dict) - Replaced by Configuration.save_file_type_definitions() +# load_supplier_settings() -> dict - Replaced by Configuration._load_definition_file_with_fallback() logic +# save_supplier_settings(data: dict) - Replaced by Configuration.save_supplier_settings() diff --git a/context_portal/conport_vector_data/3712b223-f80b-4c07-b57a-5cf7c8175c86/length.bin b/context_portal/conport_vector_data/3712b223-f80b-4c07-b57a-5cf7c8175c86/length.bin index f37abd3ef857bb5c561d6cc82f566b8fbed6075d..ba79c51955ff81ca64cdad9e834f7249a106afcd 100644 GIT binary patch literal 40000 zcmeH_yG!0+9EFdIwQZ6nUz)^TwDxB0&F1zcw*6wi)*?>gBu?U@IEj-uiIX@LCvg%d z=_F3YNu0z?3yd|U0^Dg? zq=XkV{RQGie}FdiM=z3eKwBdWa9912>Z8X#Yg`7nr~c?gk`CBu><757{z&!FW3M#U z16b;hUL@&&>1r;7t^P>$(PKBO;Si4cqZdg!puOr2@j(5N>Z8ZLs%}HL>W^L|>41ak zIE1JENcGWUZ&X_$YU+<(B zgi!tQi{xBzlAcG1)E}oke(LRXH$uDm<2T8COH=z6vsXi^~Y(CpL(O%@{y`Pev_OF=F24? znfl|j$4@;{j$6ppAHPY?1wCb9p|1Wo?HNE_Iu;7`XMjn{2`A;bg;M=d$}<3ZyWF+V zr~V93NjYJ;T($67{ZYy@0D2;yvGGOy8K9DKLSH^;^5s66Xk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 GeipcwBm_wS diff --git a/context_portal/conport_vector_data/chroma.sqlite3 b/context_portal/conport_vector_data/chroma.sqlite3 index f1e50b865975f91be2b1d2c24b0fe08e49560bbe..0c71a3168364850c77a4e8d48d83d203be880792 100644 GIT binary patch delta 2636 zcmcguX;_n27S5MM69UPHC>n}OKnf^PmK2rb9@L_RA_I;BR$L&3R8m5M5Enoj;tEzO z3MC>aqaszQ3hJPedt3oGrdm*1X|-CdgF2{GT&pGe=+tLDkea5`7&WqVO{!X}*XrlU#%E+`jIzn;hD1$9h9O;+ zXiL%R)IBU&nog}(%0e=Y$p*bP>+hIcHaYWseTGr3(`nLWY3jrj^&HzZa@j}obUGO^ zYBG#Yb^^x5xu*hKnsHu6bku|>sZhZ9usv9!L7!$wH^ys?n$&o0Qh=90!1xa9Ik8lC zGeZIuy>0RMp4g~OwcVypO^Z)V*Qkw}q}P%~|5LJ&fun-_75)bv>lra46|r&-Tf(-m zyKM^IW~Z`RR?fc1nr&J-vL+WkqhS7%Dk29ykC@qGg|W~ONb zukhcM+Pfd$&WHI;ua+tkGlSpCVOwqGG_hQ7#!?;6c-Iy%`~;~;BId9HNhw<;sga1; zL2RkyQ}(>1)#hra*fOY_aWVa`z3Z_~#PVI7nb)@2#PDHXwc+q@o(U~g-Hbn`c zp7~wx|J|nE7C|@jLdfNGafDf%u0nrVyZag**MXy8%v<@VeH6f4~REnLu%dIc< zKBP3%60}w+qoz1N^0!B(VyM@q!Uf+GwA`-^;^wS}_yvPtBX<_Y6qJ&?n~&hMt4mc5 zm-w`>rWu#UCPBUbHE@rOAP4R2q2NUWjXruqsbIDst4pEfBg1HHhaAf`QTlF~C-Qlp zs8&0;(T`%i>6)T8@)bRzV!MNA<&Xp#;$@-pP1k3G?Kgq)?+W0(tF`c;(-j{VnxP~% z2p738!n9jT@3iyppqtJm(tu>ZVXhH-C?!(ZzT{G8?}*B*P`yDWDs8 zzAktv7sB?8Azyu_q&q*Tf&%hX_3`s8T$%`wh3BmvXC{$NOsCQ@e1^(2>6|y5t8ybp z#Qm}F3NO5MPzUQzUcrjMk*HKIg8@}6-f;GVt|4E*OQ&@p{j&t7(^1&CON=+qKBKKq zPtiX_-lvD2H)83^NO*g_8N}(H`1Q1daPg)Tj14KEnOzJ+J3>I8F2zMTb}07^!II&d zXvLQT6Eabo1o@GfD&b25@Iu2t@qs<)!~4Ku?*f#>y$vx^305cY@Wt*1+UWiz^;~j* zrf-SDb+gVvcC`S;H+_#DGgs5p*l%G<##l02IT5yb))CqCF1lo$2E$u&X!XlT*lBMD zm&02jabhMNpqNG?zm}52&8C-lc?bjbzV^6Fd6>4}-$VV6jY8f$$>9YLwfNrU@o?(W z1R8j20Xm;}ACiPMXuejDVcWLTz>BS_?>-O5xi8A#TuL)6@3aTAY_BS{?gthcU$v$@%@xp^Y|@*#t+rgt{rJ=H_H)3_h=R_DQ`@6KYd z(_oO5ZKVUkhQMik4SrpAn(BssL=^02;Ca~sH|?`Xk^eOMR?`x^eS93ogOU#K2uFL5 zUa)w|aNPOLQ+f?O;oy%G>5a~n&oII;ZjiojNiCM7y@&+LSA#^44@P)m{G- z2MH`IgZYzT;p8Hfrprv@+IiqSHlN;cD8_@{+vqnN&Gn8eOU?W`j{v7H|9`_*kr(#&% za(}NYuLKg#dZBKl0e#jkvnJHUL-n7e(CYRMZQIdEqW0U7+^krF4P&8vnGgP`I*mnc zHOh(0x2bA#OQ8d@sl0X@9BYZD!rGH)OxKFJNlt#xuZR@qD(*ZE>jUO3E;J_VUS-nO2fy%9GK zJ3#hl^rFstuaJ#~&)}Z6h~~}V98S*egGaQH$O)K-g&~z7ZMP@USBq4;kB@@wp>yfx z=aS%OoJLx*;S^nbc`lfycUmhqCplO@{QfA4ib`zX>SarZgeyz>e+)#+T$U?y3Vd_l zaJTGdxn85v)ae?%ZIwxKSL$u+kKFw?1HT7$4{(>eznZ7Epl1_uUwj}-$X&7ES72>A AIsgCw delta 191 zcmZo@;A&{#njp={HBrWyk!xeZ5`7LP{%;KYzxlsy7Ib*UzxkUzUjQ2;|3(J>jhh7x zmh(^EI6s(!k^eXY5OD!TcJpsOzTS2NA1mK?2L3brOZX@93-Ep4Y^d;*Z~ON=MqWlf zR{og`{4@D4^RMGS&i@LirJaBJ%zDOLmg@}s*MZ!#{4DB>%%PJ{uUFkRfsrKvsDYh< ppPhd-zbyZ3{tG~(zVc0H|IhfBjgg~@fujm2P{6Uhii4@G9009PJ_-N; diff --git a/context_portal/context.db b/context_portal/context.db index 40e10c8ebeff9d975ce927d41830659da05240a3..207cf8e737f098c37b6ea225571fbf5f055568f5 100644 GIT binary patch delta 20674 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 diff --git a/gui/first_time_setup_dialog.py b/gui/first_time_setup_dialog.py new file mode 100644 index 0000000..a5a03c1 --- /dev/null +++ b/gui/first_time_setup_dialog.py @@ -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() \ No newline at end of file diff --git a/main.py b/main.py index 7b859d8..f95843e 100644 --- a/main.py +++ b/main.py @@ -15,11 +15,12 @@ from typing import List, Dict, Tuple, Optional # --- Utility Imports --- from utils.hash_utils import calculate_sha256 from utils.path_utils import get_next_incrementing_value +from utils import app_setup_utils # Import the new utility module # --- Qt Imports for Application Structure --- from PySide6.QtCore import QObject, Slot, QThreadPool, QRunnable, Signal from PySide6.QtCore import Qt -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QDialog # Import QDialog for the setup dialog # --- Backend Imports --- # Add current directory to sys.path for direct execution @@ -45,6 +46,10 @@ try: from gui.main_window import MainWindow print("DEBUG: Successfully imported MainWindow.") + print("DEBUG: Attempting to import FirstTimeSetupDialog...") + from gui.first_time_setup_dialog import FirstTimeSetupDialog # Import the setup dialog + print("DEBUG: Successfully imported FirstTimeSetupDialog.") + print("DEBUG: Attempting to import prepare_processing_workspace...") from utils.workspace_utils import prepare_processing_workspace print("DEBUG: Successfully imported prepare_processing_workspace.") @@ -300,8 +305,9 @@ class App(QObject): # Signal emitted when all queued processing tasks are complete all_tasks_finished = Signal(int, int, int) # processed_count, skipped_count, failed_count (Placeholder counts for now) - def __init__(self): + def __init__(self, user_config_path: str): super().__init__() + self.user_config_path = user_config_path # Store the determined user config path self.config_obj = None self.processing_engine = None self.main_window = None @@ -310,34 +316,19 @@ class App(QObject): self._task_results = {"processed": 0, "skipped": 0, "failed": 0} log.info(f"Maximum threads for pool: {self.thread_pool.maxThreadCount()}") - self._load_config() + self._load_config(self.user_config_path) # Pass the user config path self._init_engine() self._init_gui() - def _load_config(self): - """Loads the base configuration using a default preset.""" - # The actual preset name comes from the GUI request later, but the engine - # needs an initial valid configuration object. + def _load_config(self, user_config_path: str): + """Loads the base configuration using the determined user config path.""" try: - # Find the first available preset to use as a default - preset_dir = Path(__file__).parent / "Presets" - default_preset_name = None - if preset_dir.is_dir(): - presets = sorted([f.stem for f in preset_dir.glob("*.json") if f.is_file() and not f.name.startswith('_')]) - if presets: - default_preset_name = presets[0] - log.info(f"Using first available preset as default for initial config: '{default_preset_name}'") - - if not default_preset_name: - # Fallback or raise error if no presets found - log.error("No presets found in the 'Presets' directory. Cannot initialize default configuration.") - # Option 1: Raise an error - raise ConfigurationError("No presets found to load default configuration.") - - self.config_obj = Configuration(preset_name=default_preset_name) - log.info(f"Base configuration loaded using default preset '{default_preset_name}'.") + # Initialize Configuration with the determined user config path + # The Configuration class is responsible for finding presets and other configs + self.config_obj = Configuration(base_dir_user_config=user_config_path) + log.info(f"Base configuration loaded using user config path: '{user_config_path}'.") except ConfigurationError as e: - log.error(f"Fatal: Failed to load base configuration using default preset: {e}") + log.error(f"Fatal: Failed to load base configuration using user config path '{user_config_path}': {e}") # In a real app, show this error to the user before exiting sys.exit(1) except Exception as e: @@ -401,120 +392,6 @@ class App(QObject): log.debug(f"Initialized active task count to: {self._active_tasks_count}") # Update GUI progress bar/status via MainPanelWidget - self.main_window.main_panel_widget.progress_bar.setMaximum(len(source_rules)) - self.main_window.main_panel_widget.progress_bar.setValue(0) - self.main_window.main_panel_widget.progress_bar.setFormat(f"0/{len(source_rules)} tasks") - - # --- Get paths needed for ProcessingTask --- - try: - # Get output_dir from processing_settings passed from autotest.py - output_base_path_str = processing_settings.get("output_dir") - log.info(f"APP_DEBUG: Received output_dir in processing_settings: {output_base_path_str}") - - if not output_base_path_str: - log.error("Cannot queue tasks: Output directory path is empty in processing_settings.") - # self.main_window.statusBar().showMessage("Error: Output directory cannot be empty.", 5000) # GUI specific - return - output_base_path = Path(output_base_path_str) - # Basic validation - check if it's likely a valid path structure (doesn't guarantee existence/writability here) - if not output_base_path.is_absolute(): - # Or attempt to resolve relative to workspace? For now, require absolute from GUI. - log.warning(f"Output path '{output_base_path}' is not absolute. Processing might fail if relative path is not handled correctly by engine.") - # Consider resolving: output_base_path = Path.cwd() / output_base_path # If relative paths are allowed - - # Define workspace path (assuming main.py is in the project root) - workspace_path = Path(__file__).parent.resolve() - log.debug(f"Using Workspace Path: {workspace_path}") - log.debug(f"Using Output Base Path: {output_base_path}") - - except Exception as e: - log.exception(f"Error getting/validating paths for processing task: {e}") - self.main_window.statusBar().showMessage(f"Error preparing paths: {e}", 5000) - return - # --- End Get paths --- - - - # Set max threads based on GUI setting - worker_count = processing_settings.get('workers', 1) - self.thread_pool.setMaxThreadCount(worker_count) - log.info(f"Set thread pool max workers to: {worker_count}") - - # Queue tasks in the thread pool - log.debug("DEBUG: Entering task queuing loop.") - for i, rule in enumerate(source_rules): - if isinstance(rule, SourceRule): - log.info(f"DEBUG Task {i+1}: Rule Input='{rule.input_path}', Supplier ID='{getattr(rule, 'supplier_identifier', 'Not Set')}', Preset='{getattr(rule, 'preset_name', 'Not Set')}'") - log.debug(f"DEBUG: Preparing to queue task {i+1}/{len(source_rules)} for rule: {rule.input_path}") - - # --- Create a new Configuration and Engine instance for this specific task --- - task_engine = None - try: - # Get preset name from the rule, fallback to app's default if missing - preset_name_for_task = getattr(rule, 'preset_name', None) - if not preset_name_for_task: - log.warning(f"Task {i+1} (Rule: {rule.input_path}): SourceRule missing preset_name. Falling back to default preset '{self.config_obj.preset_name}'.") - preset_name_for_task = self.config_obj.preset_name - - task_config = Configuration(preset_name=preset_name_for_task) - task_engine = ProcessingEngine(task_config) - log.debug(f"Task {i+1}: Created new ProcessingEngine instance with preset '{preset_name_for_task}'.") - - except ConfigurationError as config_err: - log.error(f"Task {i+1} (Rule: {rule.input_path}): Failed to load configuration for preset '{preset_name_for_task}': {config_err}. Skipping task.") - self._active_tasks_count -= 1 # Decrement count as this task won't run - self._task_results["failed"] += 1 - # Optionally update GUI status for this specific rule - self.main_window.update_file_status(str(rule.input_path), "failed", f"Config Error: {config_err}") - continue # Skip to the next rule - except Exception as engine_err: - log.exception(f"Task {i+1} (Rule: {rule.input_path}): Failed to initialize ProcessingEngine for preset '{preset_name_for_task}': {engine_err}. Skipping task.") - self._active_tasks_count -= 1 # Decrement count - self._task_results["failed"] += 1 - self.main_window.update_file_status(str(rule.input_path), "failed", f"Engine Init Error: {engine_err}") - continue # Skip to the next rule - - if task_engine is None: # Should not happen if exceptions are caught, but safety check - log.error(f"Task {i+1} (Rule: {rule.input_path}): Engine is None after initialization attempt. Skipping task.") - self._active_tasks_count -= 1 # Decrement count - self._task_results["failed"] += 1 - self.main_window.update_file_status(str(rule.input_path), "failed", "Engine initialization failed (unknown reason).") - continue # Skip to the next rule - # --- End Engine Instantiation --- - - task = ProcessingTask( - engine=task_engine, - rule=rule, - workspace_path=workspace_path, - output_base_path=output_base_path # This is Path(output_base_path_str) - ) - log.info(f"APP_DEBUG: Passing to ProcessingTask: output_base_path = {output_base_path}") - task.signals.finished.connect(self._on_task_finished) - log.debug(f"DEBUG: Calling thread_pool.start() for task {i+1}") - self.thread_pool.start(task) - log.debug(f"DEBUG: Returned from thread_pool.start() for task {i+1}") - else: - log.warning(f"Skipping invalid item (index {i}) in rule list: {type(rule)}") - - log.info(f"Queued {len(source_rules)} processing tasks (finished loop).") - # GUI status already updated in MainWindow when signal was emitted - - # --- Slot to handle completion of individual tasks --- - @Slot(str, str, object) - def _on_task_finished(self, rule_input_path, status, result_or_error): - """Handles the 'finished' signal from a ProcessingTask.""" - log.info(f"Task finished signal received for {rule_input_path}. Status: {status}") - self._active_tasks_count -= 1 - log.debug(f"Active tasks remaining: {self._active_tasks_count}") - - # Update overall results (basic counts for now) - if status == "processed": - self._task_results["processed"] += 1 - elif status == "skipped": # Assuming engine might return 'skipped' status eventually - self._task_results["skipped"] += 1 - else: # Count all other statuses (failed_preparation, failed_processing) as failed - self._task_results["failed"] += 1 - - # Update progress bar via MainPanelWidget total_tasks = self.main_window.main_panel_widget.progress_bar.maximum() completed_tasks = total_tasks - self._active_tasks_count self.main_window.main_panel_widget.update_progress_bar(completed_tasks, total_tasks) # Use MainPanelWidget's method @@ -558,9 +435,56 @@ if __name__ == "__main__": log.info("No required CLI arguments detected, starting GUI mode.") # --- Run the GUI Application --- try: - qt_app = QApplication(sys.argv) + user_config_path = app_setup_utils.read_saved_user_config_path() + log.debug(f"Read saved user config path: {user_config_path}") - app_instance = App() + first_run_needed = False + if user_config_path is None or not user_config_path.strip(): + log.info("No saved user config path found. First run setup needed.") + first_run_needed = True + else: + user_config_dir = Path(user_config_path) + marker_file = app_setup_utils.get_first_run_marker_file(user_config_path) + if not user_config_dir.is_dir(): + log.warning(f"Saved user config directory does not exist: {user_config_path}. First run setup needed.") + first_run_needed = True + elif not Path(marker_file).is_file(): + log.warning(f"First run marker file not found in {user_config_path}. First run setup needed.") + first_run_needed = True + else: + log.info(f"Saved user config path found and valid: {user_config_path}. Marker file exists.") + + qt_app = None + if first_run_needed: + log.info("Initiating first-time setup dialog.") + # Need a QApplication instance to show the dialog + qt_app = QApplication.instance() + if qt_app is None: + qt_app = QApplication(sys.argv) + + dialog = FirstTimeSetupDialog() + if dialog.exec() == QDialog.Accepted: + user_config_path = dialog.get_chosen_path() + log.info(f"First-time setup completed. Chosen path: {user_config_path}") + # The dialog should have already saved the path and created the marker file + else: + log.info("First-time setup cancelled by user. Exiting application.") + sys.exit(0) # Exit gracefully + + # If qt_app was created for the dialog, reuse it. Otherwise, create it now. + if qt_app is None: + qt_app = QApplication.instance() + if qt_app is None: + qt_app = QApplication(sys.argv) + + + # Ensure user_config_path is set before initializing App + if not user_config_path or not Path(user_config_path).is_dir(): + log.error(f"Fatal: User config path is invalid or not set after setup: {user_config_path}. Cannot proceed.") + sys.exit(1) + + + app_instance = App(user_config_path) # Pass the determined path app_instance.run() sys.exit(qt_app.exec()) diff --git a/utils/app_setup_utils.py b/utils/app_setup_utils.py new file mode 100644 index 0000000..723b40c --- /dev/null +++ b/utils/app_setup_utils.py @@ -0,0 +1,66 @@ +import os +import sys +import platform + +def get_app_data_dir(): + """ + Gets the OS-specific application data directory for Asset Processor. + Uses standard library methods as appdirs is not available. + """ + app_name = "AssetProcessor" + if platform.system() == "Windows": + # On Windows, use APPDATA environment variable + app_data_dir = os.path.join(os.environ.get("APPDATA", "~"), app_name) + elif platform.system() == "Darwin": + # On macOS, use ~/Library/Application Support + app_data_dir = os.path.join("~", "Library", "Application Support", app_name) + else: + # On Linux and other Unix-like systems, use ~/.config + app_data_dir = os.path.join("~", ".config", app_name) + + # Expand the user home directory symbol if present + return os.path.expanduser(app_data_dir) + +def get_persistent_config_path_file(): + """ + Gets the full path to the file storing the user's chosen config directory. + """ + app_data_dir = get_app_data_dir() + # Ensure the app data directory exists + os.makedirs(app_data_dir, exist_ok=True) + return os.path.join(app_data_dir, "asset_processor_user_root.txt") + +def read_saved_user_config_path(): + """ + Reads the saved user config path from the persistent file. + Returns the path string or None if the file doesn't exist or is empty. + """ + path_file = get_persistent_config_path_file() + if os.path.exists(path_file): + try: + with open(path_file, "r", encoding="utf-8") as f: + saved_path = f.read().strip() + if saved_path: + return saved_path + except IOError: + # Handle potential file reading errors + pass + return None + +def save_user_config_path(user_config_path): + """ + Saves the user's chosen config path to the persistent file. + """ + path_file = get_persistent_config_path_file() + try: + with open(path_file, "w", encoding="utf-8") as f: + f.write(user_config_path) + except IOError: + # Handle potential file writing errors + print(f"Error saving user config path to {path_file}", file=sys.stderr) + +def get_first_run_marker_file(user_config_path): + """ + Gets the full path to the first-run marker file within the user config directory. + """ + return os.path.join(user_config_path, ".first_run_complete") \ No newline at end of file