mirror of
https://github.com/Omni-guides/Jackify.git
synced 2026-06-08 01:47:45 +02:00
452 lines
26 KiB
Python
452 lines
26 KiB
Python
"""Discovery phase methods for ModlistInstallCLI (Mixin)."""
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Optional, Dict
|
|
|
|
from .config_handler import ConfigHandler
|
|
from .ui_colors import (
|
|
COLOR_PROMPT,
|
|
COLOR_RESET,
|
|
COLOR_INFO,
|
|
COLOR_ERROR,
|
|
COLOR_SUCCESS,
|
|
COLOR_WARNING,
|
|
COLOR_SELECTION,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ModlistInstallCLIDiscoveryMixin:
|
|
"""Mixin providing discovery phase methods."""
|
|
|
|
def run_discovery_phase(self, context_override=None) -> Optional[Dict]:
|
|
"""
|
|
Run the discovery phase: prompt for all required info, and validate inputs.
|
|
Returns a context dict with all collected info, or None if cancelled.
|
|
Accepts context_override for pre-filled values (e.g., for Tuxborn/machineid flow).
|
|
"""
|
|
self.logger.info("Starting modlist discovery phase (restored logic).")
|
|
from .modlist_install_cli import get_jackify_engine_path
|
|
|
|
print(f"\n{COLOR_PROMPT}--- Wabbajack Modlist Install: Discovery Phase ---{COLOR_RESET}")
|
|
|
|
if context_override:
|
|
self.context.update(context_override)
|
|
if 'resolution' in context_override:
|
|
self.context['resolution'] = context_override['resolution']
|
|
else:
|
|
self.context = {}
|
|
|
|
is_gui_mode = os.environ.get('JACKIFY_GUI_MODE') == '1'
|
|
# Only require game_type for non-Tuxborn workflows
|
|
if self.context.get('machineid'):
|
|
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key']
|
|
else:
|
|
required_keys = ['modlist_name', 'install_dir', 'download_dir', 'nexus_api_key', 'game_type']
|
|
has_modlist = self.context.get('modlist_value') or self.context.get('machineid')
|
|
missing = [k for k in required_keys if not self.context.get(k)]
|
|
if is_gui_mode:
|
|
if missing or not has_modlist:
|
|
self.logger.error(f"Missing required arguments for GUI workflow: {', '.join(missing)}")
|
|
if not has_modlist:
|
|
self.logger.error("Missing modlist_value or machineid for GUI workflow.")
|
|
self.logger.error("This workflow must be fully non-interactive. Please report this as a bug if you see this message.")
|
|
return None
|
|
self.logger.info("All required context present in GUI mode, skipping prompts.")
|
|
return self.context
|
|
|
|
# Get engine path using the helper
|
|
engine_executable = get_jackify_engine_path()
|
|
self.logger.debug(f"Engine executable path: {engine_executable}")
|
|
|
|
if not os.path.exists(engine_executable):
|
|
print(f"{COLOR_ERROR}Error: jackify-install-engine not found at expected location.{COLOR_RESET}")
|
|
print(f"{COLOR_INFO}Expected: {engine_executable}{COLOR_RESET}")
|
|
return None
|
|
|
|
engine_dir = os.path.dirname(engine_executable)
|
|
|
|
# 1. Prompt for modlist source (unless using machineid from context_override)
|
|
if 'machineid' not in self.context:
|
|
print("\n" + "-" * 28) # Separator
|
|
print(f"{COLOR_PROMPT}How would you like to select your modlist?{COLOR_RESET}")
|
|
print(f"{COLOR_SELECTION}1.{COLOR_RESET} Select from a list of available modlists")
|
|
print(f"{COLOR_SELECTION}2.{COLOR_RESET} Provide the path to a .wabbajack file on disk")
|
|
print(f"{COLOR_SELECTION}0.{COLOR_RESET} Cancel and return to previous menu")
|
|
source_choice = input(f"{COLOR_PROMPT}Enter your selection (0-2): {COLOR_RESET}").strip()
|
|
self.logger.debug(f"User selected modlist source option: {source_choice}")
|
|
|
|
if source_choice == '1':
|
|
self.context['modlist_source_type'] = 'online_list'
|
|
print(f"\n{COLOR_INFO}Fetching available modlists... This may take a moment.{COLOR_RESET}")
|
|
try:
|
|
env = os.environ.copy()
|
|
env["DOTNET_SYSTEM_GLOBALIZATION_INVARIANT"] = "1"
|
|
self.logger.info("Setting DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1 for jackify-engine process.")
|
|
|
|
# Use the engine path from the helper function, but the command structure from restored.
|
|
engine_executable_path_for_subprocess = get_jackify_engine_path()
|
|
command = [engine_executable_path_for_subprocess, 'list-modlists', '--show-all-sizes', '--show-machine-url']
|
|
self.logger.info(f"Executing command: {' '.join(command)} in CWD: {engine_dir}")
|
|
|
|
# check=True as in restored logic
|
|
result = subprocess.run(
|
|
command,
|
|
capture_output=True, text=True, check=True,
|
|
env=env, cwd=engine_dir
|
|
)
|
|
|
|
# self.logger.debug(f"Engine stdout (raw):\n{result.stdout}") # COMMENTED OUT - too verbose
|
|
|
|
lines = result.stdout.splitlines()
|
|
|
|
# Parse new format: [STATUS] Modlist Name - Game - Download|Install|Total - MachineURL
|
|
# STATUS indicators: [DOWN], [NSFW], or both [DOWN] [NSFW]
|
|
raw_modlists_from_engine = []
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line or line.startswith('Loading') or line.startswith('Loaded'):
|
|
continue
|
|
|
|
# Extract status indicators
|
|
status_down = '[DOWN]' in line
|
|
status_nsfw = '[NSFW]' in line
|
|
|
|
# Remove status indicators to get clean line
|
|
clean_line = line.replace('[DOWN]', '').replace('[NSFW]', '').strip()
|
|
|
|
# Split on ' - ' to get: [Modlist Name, Game, Sizes, MachineURL]
|
|
parts = clean_line.split(' - ')
|
|
if len(parts) != 4:
|
|
continue # Skip malformed lines
|
|
|
|
modlist_name = parts[0].strip()
|
|
game_name = parts[1].strip()
|
|
sizes_str = parts[2].strip()
|
|
machine_url = parts[3].strip()
|
|
|
|
# Parse sizes: "Download|Install|Total" (e.g., "203GB|130GB|333GB")
|
|
size_parts = sizes_str.split('|')
|
|
if len(size_parts) != 3:
|
|
continue # Skip if sizes don't match expected format
|
|
|
|
download_size = size_parts[0].strip()
|
|
install_size = size_parts[1].strip()
|
|
total_size = size_parts[2].strip()
|
|
|
|
# Skip if any required data is missing
|
|
if not modlist_name or not game_name or not machine_url:
|
|
continue
|
|
|
|
raw_modlists_from_engine.append({
|
|
'id': modlist_name, # Use modlist name as ID for compatibility
|
|
'name': modlist_name,
|
|
'game': game_name,
|
|
'download_size': download_size,
|
|
'install_size': install_size,
|
|
'total_size': total_size,
|
|
'machine_url': machine_url, # Store machine URL for installation
|
|
'status_down': status_down,
|
|
'status_nsfw': status_nsfw
|
|
})
|
|
|
|
self.logger.info(f"Scraped {len(raw_modlists_from_engine)} modlists after revised regex and filtering logic.")
|
|
|
|
if not raw_modlists_from_engine:
|
|
print(f"{COLOR_WARNING}No modlists found after applying revised regex and filtering logic.{COLOR_RESET}")
|
|
return None
|
|
|
|
# EXACT game_type_map and grouping logic from restored file
|
|
game_type_map = {
|
|
'1': ('Skyrim', ['Skyrim', 'Skyrim Special Edition']),
|
|
'2': ('Fallout 4', ['Fallout 4']),
|
|
'3': ('Fallout New Vegas', ['Fallout New Vegas']),
|
|
'4': ('Oblivion', ['Oblivion']),
|
|
'5': ('Other Games', None) # Using None as in restored for keyword list
|
|
}
|
|
|
|
grouped_modlists = {k: [] for k in game_type_map}
|
|
|
|
for m_info in raw_modlists_from_engine: # m_info is like {'id': ..., 'game': ...}
|
|
found_category = False
|
|
for cat_key, (cat_label, cat_keywords) in game_type_map.items():
|
|
if cat_key == '5': # Skip 'Other Games' for direct matching initially
|
|
continue
|
|
if cat_keywords: # Ensure there are keywords to check (handles 'Other Games' with None)
|
|
for keyword in cat_keywords:
|
|
if keyword.lower() in m_info['game'].lower():
|
|
grouped_modlists[cat_key].append(m_info)
|
|
found_category = True
|
|
break # Found category for this modlist
|
|
if found_category:
|
|
break # Move to next modlist
|
|
if not found_category:
|
|
grouped_modlists['5'].append(m_info) # Add to 'Other Games'
|
|
|
|
selected_modlist_info = None # Will store {'id': ..., 'game': ...}
|
|
while not selected_modlist_info:
|
|
print(f"\n{COLOR_PROMPT}Select a game category:{COLOR_RESET}")
|
|
|
|
category_display_map = {} # Maps displayed number to actual game_type_map key
|
|
display_idx = 1
|
|
# Iterate in a defined order for consistent menu
|
|
for cat_key_ordered in ['1','2','3','4','5']:
|
|
if cat_key_ordered in grouped_modlists and grouped_modlists[cat_key_ordered]: # Only show if non-empty
|
|
cat_label = game_type_map[cat_key_ordered][0]
|
|
print(f" {COLOR_SELECTION}{display_idx}.{COLOR_RESET} {cat_label} ({len(grouped_modlists[cat_key_ordered])} modlists)")
|
|
category_display_map[str(display_idx)] = cat_key_ordered
|
|
display_idx += 1
|
|
|
|
if display_idx == 1: # No categories had any modlists
|
|
print(f"{COLOR_WARNING}No modlists found to display after grouping. Engine output might be empty or filtered entirely.{COLOR_RESET}")
|
|
return None
|
|
|
|
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Cancel")
|
|
|
|
game_cat_choice = input(f"{COLOR_PROMPT}Enter selection: {COLOR_RESET}").strip()
|
|
if game_cat_choice == '0':
|
|
self.logger.info("User cancelled game category selection.")
|
|
return None
|
|
|
|
actual_cat_key = category_display_map.get(game_cat_choice)
|
|
if not actual_cat_key:
|
|
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
|
|
continue
|
|
|
|
# modlist_group_for_game is a list of dicts like {'id': ..., 'game': ...}
|
|
modlist_group_for_game = sorted(grouped_modlists[actual_cat_key], key=lambda x: x['id'].lower())
|
|
|
|
print(f"\n{COLOR_SUCCESS}Available Modlists for {game_type_map[actual_cat_key][0]}:{COLOR_RESET}")
|
|
for idx, m_detail in enumerate(modlist_group_for_game, 1):
|
|
if actual_cat_key == '5': # 'Other Games' category
|
|
print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']} ({m_detail['game']})")
|
|
else:
|
|
print(f" {COLOR_SELECTION}{idx}.{COLOR_RESET} {m_detail['id']}")
|
|
print(f" {COLOR_SELECTION}0.{COLOR_RESET} Back to game categories")
|
|
|
|
while True:
|
|
mod_choice_idx_str = input(f"{COLOR_PROMPT}Select modlist (or 0): {COLOR_RESET}").strip()
|
|
if mod_choice_idx_str == '0':
|
|
break
|
|
if mod_choice_idx_str.isdigit():
|
|
mod_idx = int(mod_choice_idx_str) - 1
|
|
if 0 <= mod_idx < len(modlist_group_for_game):
|
|
selected_modlist_info = modlist_group_for_game[mod_idx]
|
|
self.context['modlist_source'] = 'identifier'
|
|
# Use machine_url for installation, display name for suggestions
|
|
self.context['modlist_value'] = selected_modlist_info.get('machine_url', selected_modlist_info['id'])
|
|
self.context['modlist_game'] = selected_modlist_info['game']
|
|
self.context['modlist_name_suggestion'] = selected_modlist_info['id'].split('/')[-1]
|
|
self.logger.info(f"User selected online modlist: {selected_modlist_info}")
|
|
break
|
|
else:
|
|
print(f"{COLOR_ERROR}Invalid modlist number.{COLOR_RESET}")
|
|
else:
|
|
print(f"{COLOR_ERROR}Invalid input. Please enter a number.{COLOR_RESET}")
|
|
if selected_modlist_info:
|
|
break
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
self.logger.error(f"list-modlists failed. Code: {e.returncode}")
|
|
if e.stdout: self.logger.error(f"Engine stdout:\n{e.stdout}")
|
|
if e.stderr: self.logger.error(f"Engine stderr:\n{e.stderr}")
|
|
print(f"{COLOR_ERROR}Failed to fetch modlist list. Engine error (Code: {e.returncode}).{COLOR_RESET}")
|
|
return None
|
|
except FileNotFoundError:
|
|
self.logger.error(f"Engine not found: {engine_executable_path_for_subprocess}")
|
|
print(f"{COLOR_ERROR}Critical error: jackify-install-engine not found.{COLOR_RESET}")
|
|
return None
|
|
except Exception as e:
|
|
self.logger.error(f"Unexpected error fetching modlists: {e}", exc_info=True)
|
|
print(f"{COLOR_ERROR}Unexpected error fetching modlists: {e}{COLOR_RESET}")
|
|
return None
|
|
|
|
elif source_choice == '2':
|
|
self.context['modlist_source_type'] = 'local_file'
|
|
print(f"\n{COLOR_PROMPT}Please provide the path to your .wabbajack file (tab-completion supported).{COLOR_RESET}")
|
|
modlist_path = self.menu_handler.get_existing_file_path(
|
|
prompt_message="Enter the path to your .wabbajack file (or 'q' to cancel):",
|
|
extension_filter=".wabbajack", # Ensure this is the exact filter used by the method
|
|
no_header=True # To avoid re-printing a header if get_existing_file_path has one
|
|
)
|
|
if modlist_path is None: # Assumes get_existing_file_path returns None on cancel/'q'
|
|
self.logger.info("User cancelled .wabbajack file selection.")
|
|
print(f"{COLOR_INFO}Cancelled by user.{COLOR_RESET}")
|
|
return None
|
|
|
|
self.context['modlist_source'] = 'path' # For install command
|
|
self.context['modlist_value'] = str(modlist_path)
|
|
# Suggest a name based on the file
|
|
self.context['modlist_name_suggestion'] = Path(modlist_path).stem
|
|
self.logger.info(f"User selected local .wabbajack file: {modlist_path}")
|
|
|
|
elif source_choice == '0':
|
|
self.logger.info("User cancelled modlist source selection.")
|
|
print(f"{COLOR_INFO}Returning to previous menu.{COLOR_RESET}")
|
|
return None
|
|
else:
|
|
self.logger.warning(f"Invalid modlist source choice: {source_choice}")
|
|
print(f"{COLOR_ERROR}Invalid selection. Please try again.{COLOR_RESET}")
|
|
return self.run_discovery_phase() # Re-prompt
|
|
|
|
# --- Prompts for install_dir, download_dir, modlist_name, api_key ---
|
|
# It will use self.context['modlist_name_suggestion'] if available.
|
|
|
|
# 2. Prompt for modlist name (skip if 'modlist_name' already in context from override)
|
|
if 'modlist_name' not in self.context or not self.context['modlist_name']:
|
|
default_name = self.context.get('modlist_name_suggestion', 'MyModlist')
|
|
print("\n" + "-" * 28)
|
|
print(f"{COLOR_PROMPT}Enter a name for this modlist installation in Steam.{COLOR_RESET}")
|
|
print(f"{COLOR_INFO}(This will be the shortcut name. Default: {default_name}){COLOR_RESET}")
|
|
modlist_name_input = input(f"{COLOR_PROMPT}Modlist Name (or 'q' to cancel): {COLOR_RESET}").strip()
|
|
if not modlist_name_input: # User hit enter for default
|
|
modlist_name = default_name
|
|
elif modlist_name_input.lower() == 'q':
|
|
self.logger.info("User cancelled at modlist name prompt.")
|
|
return None
|
|
else:
|
|
modlist_name = modlist_name_input
|
|
self.context['modlist_name'] = modlist_name
|
|
self.logger.debug(f"Modlist name set to: {self.context['modlist_name']}")
|
|
|
|
# 3. Prompt for install directory
|
|
if 'install_dir' not in self.context:
|
|
# Use configurable base directory
|
|
config_handler = ConfigHandler()
|
|
base_install_dir = Path(config_handler.get_modlist_install_base_dir())
|
|
default_install_dir = base_install_dir / self.context['modlist_name']
|
|
print("\n" + "-" * 28)
|
|
print(f"{COLOR_PROMPT}Enter the main installation directory for '{self.context['modlist_name']}'.{COLOR_RESET}")
|
|
print(f"{COLOR_INFO}(Default: {default_install_dir}){COLOR_RESET}")
|
|
install_dir_path = self.menu_handler.get_directory_path(
|
|
prompt_message=f"{COLOR_PROMPT}Install directory (or 'q' to cancel, Enter for default): {COLOR_RESET}",
|
|
default_path=default_install_dir,
|
|
create_if_missing=True,
|
|
no_header=True
|
|
)
|
|
if install_dir_path is None:
|
|
self.logger.info("User cancelled at install directory prompt.")
|
|
return None
|
|
self.context['install_dir'] = install_dir_path
|
|
self.logger.debug(f"Install directory context set to: {self.context['install_dir']}")
|
|
|
|
# 4. Prompt for download directory
|
|
if 'download_dir' not in self.context:
|
|
# Use configurable base directory for downloads
|
|
config_handler = ConfigHandler()
|
|
base_download_dir = Path(config_handler.get_modlist_downloads_base_dir())
|
|
default_download_dir = base_download_dir / self.context['modlist_name']
|
|
|
|
print("\n" + "-" * 28)
|
|
print(f"{COLOR_PROMPT}Enter the downloads directory for modlist archives.{COLOR_RESET}")
|
|
print(f"{COLOR_INFO}(Default: {default_download_dir}){COLOR_RESET}")
|
|
download_dir_path = self.menu_handler.get_directory_path(
|
|
prompt_message=f"{COLOR_PROMPT}Download directory (or 'q' to cancel, Enter for default): {COLOR_RESET}",
|
|
default_path=default_download_dir,
|
|
create_if_missing=True,
|
|
no_header=True
|
|
)
|
|
if download_dir_path is None:
|
|
self.logger.info("User cancelled at download directory prompt.")
|
|
return None
|
|
self.context['download_dir'] = download_dir_path
|
|
self.logger.debug(f"Download directory context set to: {self.context['download_dir']}")
|
|
|
|
# 5. Get Nexus authentication (OAuth or API key)
|
|
if 'nexus_api_key' not in self.context:
|
|
from jackify.backend.services.nexus_auth_service import NexusAuthService
|
|
auth_service = NexusAuthService()
|
|
|
|
# Get current auth status
|
|
authenticated, method, username = auth_service.get_auth_status()
|
|
|
|
if authenticated:
|
|
# Already authenticated - use existing auth
|
|
if method == 'oauth':
|
|
print("\n" + "-" * 28)
|
|
print(f"{COLOR_SUCCESS}Nexus Authentication: Authorized via OAuth{COLOR_RESET}")
|
|
if username:
|
|
print(f"{COLOR_INFO}Logged in as: {username}{COLOR_RESET}")
|
|
elif method == 'api_key':
|
|
print("\n" + "-" * 28)
|
|
print(f"{COLOR_INFO}Nexus Authentication: Using API Key (Legacy){COLOR_RESET}")
|
|
|
|
# Get valid token/key and OAuth state for engine auto-refresh
|
|
api_key, oauth_info = auth_service.get_auth_for_engine()
|
|
if api_key:
|
|
self.context['nexus_api_key'] = api_key
|
|
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
|
|
else:
|
|
# Auth expired or invalid - prompt to set up
|
|
print(f"\n{COLOR_WARNING}Your authentication has expired or is invalid.{COLOR_RESET}")
|
|
authenticated = False
|
|
|
|
if not authenticated:
|
|
# Not authenticated - offer to set up OAuth
|
|
print("\n" + "-" * 28)
|
|
print(f"{COLOR_WARNING}Nexus Mods authentication is required for downloading mods.{COLOR_RESET}")
|
|
print(f"\n{COLOR_PROMPT}Would you like to authorize with Nexus now?{COLOR_RESET}")
|
|
print(f"{COLOR_INFO}This will open your browser for secure OAuth authorization.{COLOR_RESET}")
|
|
|
|
authorize = input(f"{COLOR_PROMPT}Authorize now? [Y/n]: {COLOR_RESET}").strip().lower()
|
|
|
|
if authorize in ('', 'y', 'yes'):
|
|
# Launch OAuth authorization
|
|
print(f"\n{COLOR_INFO}Starting OAuth authorization...{COLOR_RESET}")
|
|
print(f"{COLOR_WARNING}Your browser will open shortly.{COLOR_RESET}")
|
|
print(f"{COLOR_INFO}Note: Your browser may ask permission to open 'xdg-open' or{COLOR_RESET}")
|
|
print(f"{COLOR_INFO}Jackify's protocol handler - please click 'Open' or 'Allow'.{COLOR_RESET}")
|
|
|
|
def show_message(msg):
|
|
print(f"\n{COLOR_INFO}{msg}{COLOR_RESET}")
|
|
|
|
success = auth_service.authorize_oauth(show_browser_message_callback=show_message)
|
|
|
|
if success:
|
|
print(f"\n{COLOR_SUCCESS}OAuth authorization successful!{COLOR_RESET}")
|
|
_, _, username = auth_service.get_auth_status()
|
|
if username:
|
|
print(f"{COLOR_INFO}Authorized as: {username}{COLOR_RESET}")
|
|
|
|
api_key, oauth_info = auth_service.get_auth_for_engine()
|
|
if api_key:
|
|
self.context['nexus_api_key'] = api_key
|
|
self.context['nexus_oauth_info'] = oauth_info # For engine auto-refresh
|
|
else:
|
|
print(f"{COLOR_ERROR}Failed to retrieve auth token after authorization.{COLOR_RESET}")
|
|
return None
|
|
else:
|
|
print(f"\n{COLOR_ERROR}OAuth authorization failed.{COLOR_RESET}")
|
|
return None
|
|
else:
|
|
# User declined OAuth - cancelled
|
|
print(f"\n{COLOR_INFO}Authorization required to proceed. Installation cancelled.{COLOR_RESET}")
|
|
self.logger.info("User declined Nexus authorization.")
|
|
return None
|
|
self.logger.debug(f"Nexus authentication configured for engine.")
|
|
|
|
# Display summary and confirm
|
|
self._display_summary() # Ensure this method exists or implement it
|
|
if self.context.get('skip_confirmation'):
|
|
confirm = 'y'
|
|
else:
|
|
confirm = input(f"{COLOR_PROMPT}Proceed with installation using these settings? (y/N): {COLOR_RESET}").strip().lower()
|
|
if confirm != 'y':
|
|
self.logger.info("User cancelled at final confirmation.")
|
|
print(f"{COLOR_INFO}Installation cancelled by user.{COLOR_RESET}")
|
|
return None
|
|
|
|
self.logger.info("Discovery phase complete.") # Log completion first
|
|
|
|
# Create a copy of the context for logging, so we don't alter the original
|
|
context_for_logging = self.context.copy()
|
|
if 'nexus_api_key' in context_for_logging and context_for_logging['nexus_api_key'] is not None:
|
|
context_for_logging['nexus_api_key'] = "[REDACTED]" # Redact the API key for logging
|
|
|
|
self.logger.info(f"Context: {context_for_logging}") # Log the redacted context
|
|
return self.context
|
|
|