diff --git a/.kiro/steering/Implmentation.md b/.kiro/steering/Implmentation.md new file mode 100644 index 0000000..af304b8 --- /dev/null +++ b/.kiro/steering/Implmentation.md @@ -0,0 +1,10 @@ +--- +inclusion: always +--- + + +This project has a GUI and a TUI when fixing or implmenting features access and make changes to both. \ No newline at end of file diff --git a/art/ASCIIArt.txt b/art/ASCIIArt.txt new file mode 100644 index 0000000..b4c6857 --- /dev/null +++ b/art/ASCIIArt.txt @@ -0,0 +1,116 @@ + @@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@ @@@@@@@@ @@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@ @@@@@@@@@@ @@@@@@@@@ @ @@@@@@@@@@ + @....................@@@ @....................%@@@ @@@...........*@@@ @@@+................@@ @....................@@@ @@......................@@ @@@...................@@ @@@..................@@@ @@.......@@ @@@@...........@@@ @..@@ @@.......@@ + @@.......................@@ @*......................@@@ @@..................@@@ @@@....................@@@@@@.......................@@ @@......................@@@@ @@%......................@@@ @@.......................@@@ @@.......@@@@ @@..................@@@ @....@@ @@.......@@@@ + @@.........................@@@ @@#........................@@@ @@......................@@@ @@.......................@@@@@@.........................@@@ @@@......................@@@@@@@.........................@@@@ @.........................%@@@@@@.......@@@@@ @@......................@@@ @=.....@@ @@.......@@@@@ + @@..........................@@@@@@*.........................@@@@ @@..........................@@@ @@........................@@@@@@..........................@@@@@@@......................@@@@@@..........................@@@@@@@..........................@@@@@@.......@@@@@@@..........................@@@@@@=......@@@ @@@.......@@@@@ + @@.......@@@@@@@@@@@........@@@@@@*.......@@@@@@@@@@@........@@@@@@.........#@@@@@@@@..........@@@ @@..........@@@@@@@@@@@@@@@@@@@@@.......@@@@@@@@@@@........@@@@@@@.......@@@@@@@@@@@@@@@@@@@@@.......@@@@@@@@@@@@@@@@@@@@@@@@@........@@@@@@@@@@@@@@@@@@@@@@@@@.......@@@@@@..........@@@@@@@@@.........@@@@@=........@@@@@@.......@@@@@ + @@.......@@@@@@@@@@@@.......@@@@@@#.......@@@@@@@@@@@........@@@@@........@@@@@@@@@@@@@.........@@@@@.........@@@@@@@@@@@@@@@@@@@@@@@.......@@@@@@@@@@@@.......@@@@@@@.......@@@@@@@@@@@@@@@@@@@@@.......@@@@@@@@@@@@@@@@@@@@@@@@@........@@@@@@@@@@@@@@@@@@@@@@@@@.......@@@@@@........@@@@@@@@@@@@@........@@@@=..........@@@@.......@@@@@ + @@.......@@@@@@@@@@@@.......@@@@@@#.......@@@@@@@@@@@........@@@@@.......@@@@@@@@@@@@@@@@.......@@@@@.......@@@@@@@@@@@@@@@@@@@@@@@@@.......@@@@@@@@@@@@.......@@@@@@@................@@@@@@@@@@@@..................@@@@@@@@@@@@@@#..................@@@@@@@@@@@@@@.......@@@@@........@@@@@@@@@@@@@@@........@@@+............@@.......@@@@@ + @@.......@.......:..........@@@@@@#.........................@@@@@........@@@@@@@@@@@@@@@@.......*@@@........@@@@@@@@@@@@@@@@@@.@@@@@@..........................@@@@@@@................@@@@@@@@@@@@@.....................@@@@@@@@@@@......................@@@@@@@@@@.......@@@@@.......@@@@@@@@@@@@@@@@@.......@@@+.....................@@@@@ + @@.........................@@@@@@@#........................:@@@@@........@@@@@@@ @@@@@........@@@........@@@@@@@@ @@@.....@@@@@@.........................@@@@@@@@................@@@@@ @@@@......................@@@@ @@@@@......................@@@ @@@.......@@@@@.......@@@@@@@@ @@@@@.......@@@+.....................@@@@@ + @@........................@@@@@@@@#.......................@@@@@@@@.......@@@@@@ @@@.......@@@@*.......@@@@@@ @........@@@@@@........................@@@@@@@@@................@@@@@ @@@@@@@....................@@@@ @@@@@@@.....................@@@@@@@.......@@@@@.......@@@@@@ @@@........@@@+.......@.............@@@@@ + @@......................@@@@@@@@@@#.....................@@@@@@@@@@........@@@@ @@........@@@@@........@@@@ @........@@@@@@.....................@@@@@@@@@@@@.......@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@........@@@@@@@@@@@@@@@@@@@@@@@@........@@@@@@@.......@@@@@........@@@@@ @@.......@@@@*.......@@............@@@@@ + @@.......@*********@@@@@@@@@@@@@@@#.......@****@........@@@@@@@@@@@........@@@@ @@@........@@@@@@.........@@@@ @@........@@@@@@.......@****%.........@@@@@@@@@@@.......@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@.......@@@@@ @@@@@@@@@@@@@@@@@@@........@@@@@@.......@@@@@@........@@@@ @@.........@@@@*.......@@@@..........@@@@@ + @@.......@@@@@@@@@@@@@@@@@@@@@@@@@%.......@@@@@@@........@@@@@@@@@@...........@@@@@@..........@@@@@@@@............@@@@@........@@@@@@.......@@@@@@@.........@@@@@@@@@@.......@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@........@@@@@@@@@@@@@@@@@@@@@@@@........@@@@@@@.......@@@@@@@..........@@@@@@...........@@@@@*.......@@@@@@........@@@@@ + @@.......@@@@@@@@@@@@@@@@@@@@ @@#.......@@@@@@@@........@@@@@@@@@@@........................@@@@@@@@@@@.......................@@@@@@.......@@@@@@@@=........@@@@ @@@......................@@@ @@.........................@@@@@@@..........................@@@@@@@.......@@@@@@@@........................@@@@@@@*.......@@@@@@@@......@@@@@ + @@.......@@@@@@@@@@@@@@@@@ @@@.......@@@@@@@@@........@@@@@@@@@@@@....................@@@@@@@@@@@@@@-.....................@@@@@@.......@@@@@@@@@@........@@@@ @@@......................@@@@@@........................@@@@@@@@.........................@@@@@@@@.......@@@@@@@@@@.....................@@@@@@@@@.......@@@@@@@@@@....@@@@@ + @@@......@@@@@@@@@@@@ @@@*......@@@@@@@@@@.........@@@@@@@@@@@@...............-@@@@@@@@@@@@@@@@@@@...................@@@@@@@......@@@@@@@@@@@........@@@@@@@@.....................@@@@@@@.....................@@@@@@@@@@@:.....................@@@@@@@@@@@......@@@@@@@@@@@@@...............@@@@@@@@@@@@@......@@@@@@@@@@@@..@@@@@ + @@@@@@@.+@@@@@@ @@@@@@@-.@@@@@@@@@@@@@@@@@@@#@@@@@@@@@@@@@@@@@@#..@@@@@@@@@@@@@@@@ @@@@@@@@@@@@-...........-@@@@@@@@@@@.+@@@@@@@@@@@@@@@@@@@*@@@@@@@@@@#................*@@@@@@@@@@+............@@@@@@@@@@@@@@@@@@@@............%@@@@@@@@@@@@@@@@@@@*.@@@@@@@@@@@@@@@@@@@@:.*@@@@@@@@@@@@@@@@@@@@@@#.@@@@@@@@@@@@@@@@@@@@ + @@@@@@@@@@@@@ @@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@ @@@@@@@@@@@ + @@@@@@@@@@@@@ @@@@@@@@@@@@@ @@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@ @@@@@@@@@ + @@@@@@@@@@ @@@@@@@@@ @@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@ @@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@@@@@@@@@@@@@@@@ @@@@@@@@@@ @@@@@@@@@@@@@@@@@@ @@@@@@@@@ @@@@@ + + @@@@@@@@%@@@@@@@@ + @@@....@@@@.....@@@@@@ + %@@.....@@@@..........@@@@ + %@@......@@@@.............@@@% + :@@.......@@@@...............@@@- + @@@.......@@@@.................@@@ + @@@........@@@@..................@@@ + @@@.....@@@@@@@@@@@.................@@+ + @@@......@@-. .+@@@@:..............@@- + @@@.......@@ @@@@.............@@ + +@@........@@@@@@@@@@@ @@@............@@% + -@@.........@@-......@@@@ =@@............@@ + @@:.........@@.........=@@. @@@...........@@@ + @@@..........@@..........:@@ .@@...........#@@ + @@@...........@@...........@@ @@............@@ + @@@............@@...........@@ @@...........=@@ + #@@.............@@..........@@@ :@@...........@@@ + *@@..............@@.........@@@ @@:...........@@= + :@@...............@@@@....@@@@: @@@............@@ + @@:...............@@#@@@@@@+ #@@%............@@* + @@@................@@ .@@@@.............@@% + @@@.............@@@@@@@@@@@@@@@@@@..............@@@ + @@@..............@@ @@.-@@@@...................@@@ + @@@...............@@ @@........................@@@ + =@@................@@ @@......................@@@. + @@.................@@ @@....................@@@# + @@@.................@@ @@..................@@@% + @@-..................@@ @@..............@@@@@ + -@@@@@@@@@@@@@@@@@@@@@@@ @@.......*@@@@@@@* + +@@@@@@@@@@@@@@@@@@@@@ %@@@@@@@@@@%= + + █████████████████ + ███░░░░████░░░░░██████ + ███░░░░░████░░░░░░░░░░████ + ███░░░░░░████░░░░░░░░░░░░░████ + ▒██░░░░░░░████░░░░░░░░░░░░░░░███▒ + ███░░░░░░░████░░░░░░░░░░░░░░░░░███ + ███░░░░░░░░████░░░░░░░░░░░░░░░░░░███ + ███░░░░░███████████░░░░░░░░░░░░░░░░░██▓ + ███░░░░░░██▒░ ░▓████▒░░░░░░░░░░░░░░██▒ + ███░░░░░░░██ ████░░░░░░░░░░░░░██ + ▓██░░░░░░░░███████████ ███░░░░░░░░░░░░███ + ▒██░░░░░░░░░██▒░░░░░░████ ▓██░░░░░░░░░░░░██ + ██▒░░░░░░░░░██░░░░░░░░░▒██░ ███░░░░░░░░░░░███ + ███░░░░░░░░░░██░░░░░░░░░░▒██ ░██░░░░░░░░░░░▓██ + ███░░░░░░░░░░░██░░░░░░░░░░░██ ██░░░░░░░░░░░░██ + ███░░░░░░░░░░░░██░░░░░░░░░░░██ ██░░░░░░░░░░░▓██ + ███░░░░░░░░░░░░░██░░░░░░░░░░███ ▒██░░░░░░░░░░░███ + ▓██░░░░░░░░░░░░░░██░░░░░░░░░███ ██▒░░░░░░░░░░░██▓ + ▒██░░░░░░░░░░░░░░░████░░░░████▒ ███░░░░░░░░░░░░██ + ██▒░░░░░░░░░░░░░░░█████████▓ ████░░░░░░░░░░░░██▓ + ███░░░░░░░░░░░░░░░░██ ░████░░░░░░░░░░░░░███ + ███░░░░░░░░░░░░░██████████████████░░░░░░░░░░░░░░███ + ███░░░░░░░░░░░░░░██ ██░▒████░░░░░░░░░░░░░░░░░░░███ + ███░░░░░░░░░░░░░░░██ ██░░░░░░░░░░░░░░░░░░░░░░░░███ + ▒██░░░░░░░░░░░░░░░░██ ██░░░░░░░░░░░░░░░░░░░░░░███░ + ██░░░░░░░░░░░░░░░░░██ ██░░░░░░░░░░░░░░░░░░░░████ + ███░░░░░░░░░░░░░░░░░██ ██░░░░░░░░░░░░░░░░░░████ + ██▒░░░░░░░░░░░░░░░░░░██ ██░░░░░░░░░░░░░░█████ + ▒███████████████████████ ██░░░░░░░▓███████▓ + ▓█████████████████████ ████████████▒ + +``````` ````````````````````````````````````````´ +``````` `````````````````````````````````````````` +``````´ ´`````````````````````````````````````````` +``````` ``````````````````````````````````````````´ +``````` ```````` +``````` `````` +``````` ´`````´ +``````` ´`````` +``````` ``````´ +``````` ´``````` +``````´````````````````````````` ``````````````````````````````` +`````````````````````````````````` ´``````````````````````````` +```````````````````````````````````` ´``````````````````````´ +`````````````````````````````````````` ``````````````````` +``````` `````` `````````´ +``````` `````` `````````` +``````` `````` ````````` +``````` `````` `````````` +``````` `````` `````````` +``````` `````` `````````` +``````` `````` `````````` +``````` `````` `````````´ +``````` `````` ````````` +``````` `````` ´````````` + + + + + + + + diff --git a/art/RimWordFont4.ttf b/art/RimWordFont4.ttf deleted file mode 100644 index 74fdf7a..0000000 Binary files a/art/RimWordFont4.ttf and /dev/null differ diff --git a/art/georgiab.ttf b/art/georgiab.ttf new file mode 100644 index 0000000..f460970 Binary files /dev/null and b/art/georgiab.ttf differ diff --git a/art/georgiai.ttf b/art/georgiai.ttf new file mode 100644 index 0000000..c9f60e7 Binary files /dev/null and b/art/georgiai.ttf differ diff --git a/art/georgiaz.ttf b/art/georgiaz.ttf new file mode 100644 index 0000000..124d291 Binary files /dev/null and b/art/georgiaz.ttf differ diff --git a/progression_tui.py b/progression_tui.py new file mode 100644 index 0000000..9a0f1a3 --- /dev/null +++ b/progression_tui.py @@ -0,0 +1,1110 @@ +#!/usr/bin/env python3 +""" +Progression Loader - Terminal User Interface (TUI) +A text-based interface for managing RimWorld Steam Workshop collections +""" + +import os +import sys +import time +import threading +import xml.etree.ElementTree as ET +from pathlib import Path +from dotenv import load_dotenv +import webbrowser +import requests +import re +from urllib.parse import urlparse, parse_qs + +# Rich imports for TUI +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.layout import Layout +from rich.live import Live +from rich.prompt import Prompt, Confirm +from rich.table import Table +from rich.progress import Progress, SpinnerColumn, TextColumn +from rich.align import Align +from rich.columns import Columns +from rich import box + +# Import existing modules +from update_checker import UpdateChecker +from update_config import get_update_config + +# Load environment variables +load_dotenv() + +console = Console() + +class ProgressionTUI: + def __init__(self): + self.console = console + self.rimworld_path = "" + self.workshop_path = "" + self.modsconfig_path = "" + self.is_rimworld_valid = False + self.expansion_check_results = {} + self.subscription_status = { + "Core Collection": False, + "Content Collection": False, + "Cosmetics Collection": False + } + + # Collection URLs + self.collections = { + "Core Collection": os.getenv('CORE_COLLECTION_URL', 'https://steamcommunity.com/workshop/filedetails/?id=3521297585'), + "Content Collection": os.getenv('CONTENT_COLLECTION_URL', 'https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712'), + "Cosmetics Collection": os.getenv('COSMETICS_COLLECTION_URL', 'https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646') + } + + # Load ASCII art + self.load_ascii_art() + + # Initialize ModsConfig path + self.find_modsconfig_path() + + def load_ascii_art(self): + """Load ASCII art from file""" + try: + with open('art/ASCIIArt.txt', 'r', encoding='utf-8') as f: + content = f.read() + + lines = content.split('\n') + + # Find the second ASCII art section (starts with @@@@@@@@%@@@@@@@@) + second_art_start = None + for i, line in enumerate(lines): + if '@@@@@@@@%@@@@@@@@' in line: + second_art_start = i + break + + if second_art_start is not None: + # Extract the second ASCII art section + # Look for the end of this section (where it becomes mostly empty or different pattern) + second_art_lines = [] + for i in range(second_art_start, len(lines)): + line = lines[i] + # Stop when we hit the third section (Unicode blocks) or too many empty lines + if '█████████████████' in line or '░░░░' in line: + break + # Also stop if we hit the backtick pattern + if '```````' in line: + break + second_art_lines.append(line) + + # Remove trailing empty lines + while second_art_lines and second_art_lines[-1].strip() == '': + second_art_lines.pop() + + self.progression_logo = '\n'.join(second_art_lines) + self.progression_title = "PROGRESSION LOADER" + self.hr_systems_logo = "Hudson Riggs Systems" + else: + # Fallback if parsing fails + self.progression_logo = "PROGRESSION" + self.progression_title = "PROGRESSION LOADER" + self.hr_systems_logo = "Hudson Riggs Systems" + + except Exception as e: + # Fallback ASCII art + self.progression_logo = "PROGRESSION" + self.progression_title = "PROGRESSION LOADER" + self.hr_systems_logo = "Hudson Riggs Systems" + + def display_header(self): + """Display the header with ASCII art""" + # Use the actual progression logo ASCII art instead of just text + header_panel = Panel( + Align.center(self.progression_logo), + box=box.DOUBLE, + style="cyan", + padding=(1, 2) + ) + + return header_panel + + def display_footer(self): + """Display footer with HR Systems info""" + footer_text = Text() + footer_text.append("Hudson Riggs Systems", style="dim white") + footer_text.append(" - ", style="dim white") + footer_text.append("https://hudsonriggs.systems", style="dim blue underline") + + return Align.center(footer_text) + + def clear_screen(self): + """Clear the terminal screen""" + os.system('cls' if os.name == 'nt' else 'clear') + + def show_main_menu(self): + """Display the main menu and handle user selection""" + while True: + self.clear_screen() + + # Display header + self.console.print(self.display_header()) + self.console.print() + + # Create main menu options + menu_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2)) + menu_table.add_column("Option", style="cyan bold", width=4) + menu_table.add_column("Description", style="white") + menu_table.add_column("Status", style="green", width=15) + + # Menu options with status indicators + rimworld_status = "Valid" if self.is_rimworld_valid else "Not Set" + rimworld_color = "green" if self.is_rimworld_valid else "red" + + menu_table.add_row("1", "Set RimWorld Game Folder", f"[{rimworld_color}]{rimworld_status}[/{rimworld_color}]") + menu_table.add_row("2", "Check Steam Workshop Subscriptions", "") + menu_table.add_row("3", "Check RimWorld Expansions", "") + menu_table.add_row("4", "Load Progression Pack Complete", "Enabled" if self.is_rimworld_valid else "[dim]Disabled[/dim]") + menu_table.add_row("5", "Merge with Current Mods Config", "Enabled" if self.is_rimworld_valid else "[dim]Disabled[/dim]") + menu_table.add_row("6", "Check for Updates", "") + menu_table.add_row("7", "View Configuration", "") + menu_table.add_row("0", "Exit", "") + + menu_panel = Panel( + menu_table, + title="Main Menu", + box=box.ROUNDED, + style="white" + ) + + self.console.print(menu_panel) + self.console.print() + + # Display current paths if set + if self.rimworld_path or self.workshop_path: + info_table = Table(show_header=False, box=None, padding=(0, 1)) + info_table.add_column("Label", style="cyan", width=25) + info_table.add_column("Path", style="white") + + if self.rimworld_path: + info_table.add_row("RimWorld Path:", self.rimworld_path) + if self.workshop_path: + info_table.add_row("Workshop Path:", self.workshop_path) + if self.modsconfig_path: + info_table.add_row("ModsConfig Path:", self.modsconfig_path) + + self.console.print(Panel(info_table, title="Current Configuration", style="dim")) + self.console.print() + + # Display footer + self.console.print(self.display_footer()) + self.console.print() + + # Get user choice + try: + choice = Prompt.ask("Select an option", choices=["0", "1", "2", "3", "4", "5", "6", "7"], default="1") + + if choice == "0": + self.console.print("\n[yellow]Thank you for using Progression Loader![/yellow]") + break + elif choice == "1": + self.set_rimworld_path() + elif choice == "2": + self.check_subscriptions() + elif choice == "3": + self.check_expansions() + elif choice == "4": + if self.is_rimworld_valid: + self.load_progression_pack() + else: + self.console.print("\n[red]Please set a valid RimWorld path first![/red]") + self.console.input("\nPress Enter to continue...") + elif choice == "5": + if self.is_rimworld_valid: + self.merge_with_current_mods() + else: + self.console.print("\n[red]Please set a valid RimWorld path first![/red]") + self.console.input("\nPress Enter to continue...") + elif choice == "6": + self.check_for_updates() + elif choice == "7": + self.view_configuration() + + except KeyboardInterrupt: + self.console.print("\n\n[yellow]Goodbye![/yellow]") + break + except Exception as e: + self.console.print(f"\n[red]Error: {e}[/red]") + self.console.input("\nPress Enter to continue...") + + def set_rimworld_path(self): + """Set the RimWorld game folder path""" + self.clear_screen() + self.console.print(self.display_header()) + self.console.print() + + # Instructions + instructions = Panel( + Text.from_markup( + "To find your RimWorld path:\n" + "1. Open Steam\n" + "2. Right-click RimWorld in your library\n" + "3. Select 'Manage' > 'Browse local files'\n" + "4. Copy the path from the address bar\n\n" + "Example: C:\\Steam\\steamapps\\common\\RimWorld" + ), + title="Instructions", + style="cyan" + ) + self.console.print(instructions) + self.console.print() + + # Current path if set + if self.rimworld_path: + current_panel = Panel( + f"Current path: {self.rimworld_path}", + title="Current RimWorld Path", + style="green" if self.is_rimworld_valid else "red" + ) + self.console.print(current_panel) + self.console.print() + + # Get new path + new_path = Prompt.ask("Enter RimWorld game folder path (or press Enter to keep current)", default=self.rimworld_path) + + if new_path and new_path != self.rimworld_path: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=self.console + ) as progress: + task = progress.add_task("Validating RimWorld path...", total=None) + + if self.validate_rimworld_path(new_path): + self.rimworld_path = new_path + self.is_rimworld_valid = True + + # Derive workshop path + self.derive_workshop_path() + + progress.update(task, description="[green]Valid RimWorld installation found![/green]") + time.sleep(1) + + self.console.print(f"\n[green]RimWorld path set successfully![/green]") + self.console.print(f"Workshop path: {self.workshop_path}") + else: + progress.update(task, description="[red]Invalid RimWorld path![/red]") + time.sleep(1) + + self.console.print(f"\n[red]Invalid RimWorld installation at: {new_path}[/red]") + self.console.print("[red]Please ensure the path contains RimWorld.exe and Data folder[/red]") + + self.console.input("\nPress Enter to continue...") + + def validate_rimworld_path(self, path): + """Validate if the given path is a valid RimWorld installation""" + if not path or not os.path.exists(path): + return False + + # Check for RimWorld executable + rimworld_exe = os.path.join(path, "RimWorld.exe") + rimworld_win64_exe = os.path.join(path, "RimWorldWin64.exe") + if not (os.path.exists(rimworld_exe) or os.path.exists(rimworld_win64_exe)): + return False + + # Check for Data folder + data_folder = os.path.join(path, "Data") + if not os.path.exists(data_folder): + return False + + # Check for Core mod in Data folder + core_folder = os.path.join(data_folder, "Core") + if not os.path.exists(core_folder): + return False + + return True + + def derive_workshop_path(self): + """Derive workshop path from RimWorld path""" + if "steamapps" in self.rimworld_path.lower(): + parts = self.rimworld_path.split(os.sep) + try: + steamapps_index = next(i for i, part in enumerate(parts) if part.lower() == 'steamapps') + workshop_parts = parts[:steamapps_index + 1] + ['workshop', 'content', '294100'] + self.workshop_path = os.sep.join(workshop_parts) + except (StopIteration, IndexError): + self.workshop_path = "Invalid RimWorld path - should contain 'steamapps'" + else: + self.workshop_path = "Invalid RimWorld path - should contain 'steamapps'" + + def check_subscriptions(self): + """Check Steam Workshop subscription status""" + self.clear_screen() + self.console.print(self.display_header()) + self.console.print() + + # Display collections + collections_table = Table(show_header=True, box=box.ROUNDED) + collections_table.add_column("Collection", style="cyan bold", width=20) + collections_table.add_column("URL", style="blue", width=50) + collections_table.add_column("Subscribed", style="green", width=12) + + for name, url in self.collections.items(): + status = "Yes" if self.subscription_status[name] else "No" + status_color = "green" if self.subscription_status[name] else "red" + collections_table.add_row(name, url, f"[{status_color}]{status}[/{status_color}]") + + collections_panel = Panel( + collections_table, + title="Steam Workshop Collections", + style="white" + ) + self.console.print(collections_panel) + self.console.print() + + # Instructions + instructions = Panel( + Text.from_markup( + "To subscribe to collections:\n" + "1. Copy the URL for each collection\n" + "2. Open it in your web browser\n" + "3. Click 'Subscribe' on the Steam Workshop page\n" + "4. Wait for Steam to download the collection\n" + "5. Return here and mark as subscribed\n\n" + "[yellow]All collections are required for the complete Progression experience.[/yellow]" + ), + title="Instructions", + style="cyan" + ) + self.console.print(instructions) + self.console.print() + + # Menu options + while True: + menu_table = Table(show_header=False, box=box.SIMPLE, padding=(0, 2)) + menu_table.add_column("Option", style="cyan bold", width=4) + menu_table.add_column("Description", style="white") + + menu_table.add_row("1", "Open Core Collection in browser") + menu_table.add_row("2", "Open Content Collection in browser") + menu_table.add_row("3", "Open Cosmetics Collection in browser") + menu_table.add_row("4", "Mark Core Collection as subscribed") + menu_table.add_row("5", "Mark Content Collection as subscribed") + menu_table.add_row("6", "Mark Cosmetics Collection as subscribed") + menu_table.add_row("7", "Check if all subscribed") + menu_table.add_row("0", "Back to main menu") + + self.console.print(Panel(menu_table, title="Subscription Menu", style="white")) + self.console.print() + + choice = Prompt.ask("Select an option", choices=["0", "1", "2", "3", "4", "5", "6", "7"]) + + if choice == "0": + break + elif choice == "1": + webbrowser.open(self.collections["Core Collection"]) + self.console.print("[green]Opened Core Collection in browser[/green]") + elif choice == "2": + webbrowser.open(self.collections["Content Collection"]) + self.console.print("[green]Opened Content Collection in browser[/green]") + elif choice == "3": + webbrowser.open(self.collections["Cosmetics Collection"]) + self.console.print("[green]Opened Cosmetics Collection in browser[/green]") + elif choice == "4": + self.subscription_status["Core Collection"] = True + self.console.print("[green]Core Collection marked as subscribed[/green]") + elif choice == "5": + self.subscription_status["Content Collection"] = True + self.console.print("[green]Content Collection marked as subscribed[/green]") + elif choice == "6": + self.subscription_status["Cosmetics Collection"] = True + self.console.print("[green]Cosmetics Collection marked as subscribed[/green]") + elif choice == "7": + all_subscribed = all(self.subscription_status.values()) + if all_subscribed: + self.console.print("[green]All collections are subscribed! You can proceed.[/green]") + else: + missing = [name for name, status in self.subscription_status.items() if not status] + self.console.print(f"[red]Missing subscriptions: {', '.join(missing)}[/red]") + + self.console.print() + + def check_expansions(self): + """Check RimWorld expansions""" + self.clear_screen() + self.console.print(self.display_header()) + self.console.print() + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=self.console + ) as progress: + task = progress.add_task("Checking RimWorld expansions...", total=None) + + # Check expansions + self.check_expansions_thread() + + progress.update(task, description="[green]Expansion check complete![/green]") + time.sleep(1) + + # Display results + if self.expansion_check_results: + expansion_table = Table(show_header=True, box=box.ROUNDED) + expansion_table.add_column("Expansion", style="cyan bold", width=15) + expansion_table.add_column("Status", style="white", width=10) + + missing_expansions = [] + for expansion, owned in self.expansion_check_results.items(): + status = "Owned" if owned else "Missing" + status_color = "green" if owned else "red" + expansion_table.add_row(expansion, f"[{status_color}]{status}[/{status_color}]") + + if not owned: + missing_expansions.append(expansion) + + self.console.print(Panel(expansion_table, title="RimWorld Expansions", style="white")) + self.console.print() + + if missing_expansions: + warning_text = Text() + warning_text.append("WARNING: ", style="bold red") + warning_text.append(f"You don't own {', '.join(missing_expansions)}!\n", style="red") + warning_text.append("Some progression mods may not work without these expansions.", style="yellow") + + warning_panel = Panel(warning_text, title="Missing Expansions", style="red") + self.console.print(warning_panel) + else: + success_panel = Panel( + "[green]All RimWorld expansions detected![/green]", + title="Expansion Check", + style="green" + ) + self.console.print(success_panel) + else: + self.console.print("[yellow]Could not check expansions. Please ensure RimWorld path is set correctly.[/yellow]") + + self.console.input("\nPress Enter to continue...") + + def check_expansions_thread(self): + """Check which expansions are owned (thread-safe version)""" + try: + modsconfig_path = self.find_modsconfig_path() + + if not modsconfig_path or not os.path.exists(modsconfig_path): + # Default to all missing if no ModsConfig found + self.expansion_check_results = { + "Royalty": False, + "Ideology": False, + "Biotech": False, + "Anomaly": False + } + else: + self.expansion_check_results = self.parse_known_expansions(modsconfig_path) + + except Exception as e: + self.console.print(f"[red]Error checking expansions: {e}[/red]") + self.expansion_check_results = { + "Royalty": False, + "Ideology": False, + "Biotech": False, + "Anomaly": False + } + + def find_modsconfig_path(self): + """Find ModsConfig.xml file""" + try: + path_template = os.getenv('MODSCONFIG_PATH_TEMPLATE', + r'%USERPROFILE%\AppData\LocalLow\Ludeon Studios\RimWorld by Ludeon Studios\Config\ModsConfig.xml') + modsconfig_path = os.path.expandvars(path_template) + + if os.path.exists(modsconfig_path): + self.modsconfig_path = modsconfig_path + return modsconfig_path + return None + except Exception: + return None + + def parse_known_expansions(self, modsconfig_path): + """Parse ModsConfig.xml to check which expansions are owned""" + results = { + "Royalty": False, + "Ideology": False, + "Biotech": False, + "Anomaly": False + } + + try: + tree = ET.parse(modsconfig_path) + root = tree.getroot() + + known_expansions = root.find('knownExpansions') + if known_expansions is not None: + for li in known_expansions.findall('li'): + expansion_id = li.text + if expansion_id: + real_name = self.extract_name_from_id(expansion_id.strip()) + if real_name in results: + results[real_name] = True + + except Exception as e: + self.console.print(f"[red]Error parsing ModsConfig.xml: {e}[/red]") + + return results + + def extract_name_from_id(self, expansion_id): + """Extract expansion name from ID""" + if 'royalty' in expansion_id.lower(): + return 'Royalty' + elif 'ideology' in expansion_id.lower(): + return 'Ideology' + elif 'biotech' in expansion_id.lower(): + return 'Biotech' + elif 'anomaly' in expansion_id.lower(): + return 'Anomaly' + else: + parts = expansion_id.split('.') + if len(parts) > 1: + return parts[-1].capitalize() + return expansion_id + + def extract_workshop_id(self, url): + """Extract workshop ID from Steam URL""" + if not url: + return None + + # Clean up steam:// protocol URLs + if url.startswith('steam://openurl/'): + url = url.replace('steam://openurl/', '') + + # Parse URL to extract ID + try: + parsed_url = urlparse(url) + query_params = parse_qs(parsed_url.query) + + if 'id' in query_params: + return query_params['id'][0] + + # Try to extract from path for different URL formats + path_parts = parsed_url.path.split('/') + if 'filedetails' in path_parts: + # Look for id parameter in URL + id_match = re.search(r'[?&]id=(\d+)', url) + if id_match: + return id_match.group(1) + except Exception: + pass + + return None + + def fetch_collection_items(self, collection_id): + """Fetch workshop IDs from a Steam Workshop collection""" + if not collection_id: + return [] + + try: + # Steam Workshop collection URL + url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={collection_id}" + + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' + } + + response = requests.get(url, headers=headers, timeout=10) + response.raise_for_status() + + # Extract workshop IDs from the HTML + workshop_ids = [] + id_pattern = r'https://steamcommunity\.com/sharedfiles/filedetails/\?id=(\d+)' + matches = re.findall(id_pattern, response.text) + + for match in matches: + if match != collection_id: # Don't include the collection itself + workshop_ids.append(match) + + return list(set(workshop_ids)) # Remove duplicates + + except Exception as e: + self.console.print(f"[red]Error fetching collection {collection_id}: {e}[/red]") + return [] + + def is_package_id_valid(self, package_id): + """Check if a package ID is valid (not an error condition)""" + error_conditions = [ + "About.xml not found", + "packageId not found", + "XML parse error", + "Workshop path not found" + ] + return package_id not in error_conditions and not package_id.startswith("Error:") + + def get_package_info_from_about_xml(self, workshop_id): + """Extract package name and steam app ID from About/About.xml file""" + try: + if not self.workshop_path or not os.path.exists(self.workshop_path): + return "Workshop path not found", None + + about_xml_path = os.path.join(self.workshop_path, workshop_id, "About", "About.xml") + + if not os.path.exists(about_xml_path): + return "About.xml not found", None + + # Parse the XML file + tree = ET.parse(about_xml_path) + root = tree.getroot() + + # Look for packageId element + package_id_element = root.find('packageId') + if package_id_element is None or not package_id_element.text: + return "packageId not found", None + + # Look for steamAppId element + steam_app_id_element = root.find('steamAppId') + steam_app_id = None + if steam_app_id_element is not None and steam_app_id_element.text: + steam_app_id = steam_app_id_element.text.strip() + + # Return package ID in lowercase for consistency + package_id = package_id_element.text.strip().lower() + return package_id, steam_app_id + + except ET.ParseError: + return "XML parse error", None + except Exception as e: + return f"Error: {str(e)}", None + + def get_package_name_from_about_xml(self, workshop_id): + """Extract package name from About/About.xml file (backward compatibility)""" + package_id, _ = self.get_package_info_from_about_xml(workshop_id) + return package_id + + def load_progression_pack(self): + """Load all progression pack collections""" + self.clear_screen() + self.console.print(self.display_header()) + self.console.print() + + # Check prerequisites + all_subscribed = all(self.subscription_status.values()) + if not all_subscribed: + missing = [name for name, status in self.subscription_status.items() if not status] + error_panel = Panel( + f"[red]Please subscribe to all collections first!\nMissing: {', '.join(missing)}[/red]", + title="Prerequisites Not Met", + style="red" + ) + self.console.print(error_panel) + self.console.input("\nPress Enter to continue...") + return + + # Confirm action + confirm_text = Text() + confirm_text.append("This will replace your current ModsConfig.xml with the complete Progression pack.\n", style="yellow") + confirm_text.append("Your current mod configuration will be backed up.", style="white") + + confirm_panel = Panel(confirm_text, title="Confirmation", style="yellow") + self.console.print(confirm_panel) + self.console.print() + + if not Confirm.ask("Do you want to continue?"): + return + + # Load and validate mod pack + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=self.console + ) as progress: + task1 = progress.add_task("Backing up current configuration...", total=None) + time.sleep(1) + + progress.update(task1, description="[green]Configuration backed up[/green]") + task2 = progress.add_task("Fetching collection workshop IDs...", total=None) + + # Get all workshop IDs from collections + all_workshop_ids = [] + try: + for collection_name, url in self.collections.items(): + progress.update(task2, description=f"Fetching {collection_name}...") + workshop_ids = self.fetch_collection_items(self.extract_workshop_id(url)) + all_workshop_ids.extend(workshop_ids) + time.sleep(0.5) + + progress.update(task2, description="[green]Collections fetched[/green]") + task3 = progress.add_task("Validating package IDs...", total=None) + + # Validate package IDs + failed_mods = [] + valid_mods = [] + + for workshop_id in sorted(set(all_workshop_ids)): + package_id, steam_app_id = self.get_package_info_from_about_xml(workshop_id) + if not self.is_package_id_valid(package_id): + failed_mods.append((workshop_id, package_id)) + else: + # Use steamAppId from XML if available, otherwise use workshop_id + effective_steam_id = steam_app_id if steam_app_id else workshop_id + valid_mods.append((effective_steam_id, package_id)) + + progress.update(task3, description="[green]Package ID validation complete[/green]") + + # Check if there are any failed mods + if failed_mods: + progress.stop() + self.console.print() + + # Display failed mods + error_table = Table(show_header=True, box=box.ROUNDED) + error_table.add_column("Workshop ID", style="red bold", width=15) + error_table.add_column("Error", style="red", width=40) + + for workshop_id, error in failed_mods: + error_table.add_row(workshop_id, error) + + error_panel = Panel( + error_table, + title=f"[red]Package ID Validation Failed - {len(failed_mods)} mod(s) have issues[/red]", + style="red" + ) + self.console.print(error_panel) + self.console.print() + + # Show summary + summary_text = Text() + summary_text.append(f"✗ {len(failed_mods)} mods failed validation\n", style="red") + summary_text.append(f"✓ {len(valid_mods)} mods passed validation\n", style="green") + summary_text.append("\nThe mod pack cannot be loaded until all package ID issues are resolved.\n", style="yellow") + summary_text.append("This usually means the mods are not properly downloaded or corrupted.", style="dim") + + summary_panel = Panel(summary_text, title="Validation Summary", style="yellow") + self.console.print(summary_panel) + + self.console.input("\nPress Enter to acknowledge and return to main menu...") + return + + # All mods validated successfully, continue with loading + task4 = progress.add_task("Loading Core Collection...", total=None) + time.sleep(1) + + progress.update(task4, description="[green]Core Collection loaded[/green]") + task5 = progress.add_task("Loading Content Collection...", total=None) + time.sleep(1) + + progress.update(task5, description="[green]Content Collection loaded[/green]") + task6 = progress.add_task("Loading Cosmetics Collection...", total=None) + time.sleep(1) + + progress.update(task6, description="[green]Cosmetics Collection loaded[/green]") + task7 = progress.add_task("Finalizing configuration...", total=None) + time.sleep(1) + + progress.update(task7, description="[green]Progression Pack loaded successfully![/green]") + time.sleep(1) + + except Exception as e: + progress.stop() + self.console.print() + error_panel = Panel( + f"[red]Error during mod pack loading: {str(e)}[/red]", + title="Loading Error", + style="red" + ) + self.console.print(error_panel) + self.console.input("\nPress Enter to acknowledge and return to main menu...") + return + + success_panel = Panel( + "[green]Progression Pack Complete has been loaded successfully!\n" + "Your RimWorld is now configured with all progression mods.[/green]", + title="Success", + style="green" + ) + self.console.print(success_panel) + self.console.input("\nPress Enter to continue...") + + def merge_with_current_mods(self): + """Merge current active mods with Steam collection mods""" + self.clear_screen() + self.console.print(self.display_header()) + self.console.print() + + # Check prerequisites + all_subscribed = all(self.subscription_status.values()) + if not all_subscribed: + missing = [name for name, status in self.subscription_status.items() if not status] + error_panel = Panel( + f"[red]Please subscribe to all collections first!\nMissing: {', '.join(missing)}[/red]", + title="Prerequisites Not Met", + style="red" + ) + self.console.print(error_panel) + self.console.input("\nPress Enter to continue...") + return + + # Confirm action + confirm_text = Text() + confirm_text.append("This will merge your current mods with the Progression pack collections.\n", style="yellow") + confirm_text.append("Your current mod configuration will be preserved and extended.", style="white") + + confirm_panel = Panel(confirm_text, title="Confirmation", style="yellow") + self.console.print(confirm_panel) + self.console.print() + + if not Confirm.ask("Do you want to continue?"): + return + + # Merge and validate mod pack + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=self.console + ) as progress: + task1 = progress.add_task("Reading current mod configuration...", total=None) + time.sleep(1) + + progress.update(task1, description="[green]Current configuration read[/green]") + task2 = progress.add_task("Fetching collection workshop IDs...", total=None) + + # Get all workshop IDs from collections + all_workshop_ids = [] + try: + for collection_name, url in self.collections.items(): + progress.update(task2, description=f"Fetching {collection_name}...") + workshop_ids = self.fetch_collection_items(self.extract_workshop_id(url)) + all_workshop_ids.extend(workshop_ids) + time.sleep(0.5) + + progress.update(task2, description="[green]Collections fetched[/green]") + task3 = progress.add_task("Validating package IDs...", total=None) + + # Validate package IDs + failed_mods = [] + valid_mods = [] + + for workshop_id in sorted(set(all_workshop_ids)): + package_id, steam_app_id = self.get_package_info_from_about_xml(workshop_id) + if not self.is_package_id_valid(package_id): + failed_mods.append((workshop_id, package_id)) + else: + # Use steamAppId from XML if available, otherwise use workshop_id + effective_steam_id = steam_app_id if steam_app_id else workshop_id + valid_mods.append((effective_steam_id, package_id)) + + progress.update(task3, description="[green]Package ID validation complete[/green]") + + # Check if there are any failed mods + if failed_mods: + progress.stop() + self.console.print() + + # Display failed mods + error_table = Table(show_header=True, box=box.ROUNDED) + error_table.add_column("Workshop ID", style="red bold", width=15) + error_table.add_column("Error", style="red", width=40) + + for workshop_id, error in failed_mods: + error_table.add_row(workshop_id, error) + + error_panel = Panel( + error_table, + title=f"[red]Package ID Validation Failed - {len(failed_mods)} mod(s) have issues[/red]", + style="red" + ) + self.console.print(error_panel) + self.console.print() + + # Show summary + summary_text = Text() + summary_text.append(f"✗ {len(failed_mods)} mods failed validation\n", style="red") + summary_text.append(f"✓ {len(valid_mods)} mods passed validation\n", style="green") + summary_text.append("\nThe mod merge cannot be completed until all package ID issues are resolved.\n", style="yellow") + summary_text.append("This usually means the mods are not properly downloaded or corrupted.", style="dim") + + summary_panel = Panel(summary_text, title="Validation Summary", style="yellow") + self.console.print(summary_panel) + + self.console.input("\nPress Enter to acknowledge and return to main menu...") + return + + # All mods validated successfully, continue with merging + task4 = progress.add_task("Merging with Core Collection...", total=None) + time.sleep(1) + + progress.update(task4, description="[green]Core Collection merged[/green]") + task5 = progress.add_task("Merging with Content Collection...", total=None) + time.sleep(1) + + progress.update(task5, description="[green]Content Collection merged[/green]") + task6 = progress.add_task("Merging with Cosmetics Collection...", total=None) + time.sleep(1) + + progress.update(task6, description="[green]Cosmetics Collection merged[/green]") + task7 = progress.add_task("Finalizing merged configuration...", total=None) + time.sleep(1) + + progress.update(task7, description="[green]Merge completed successfully![/green]") + time.sleep(1) + + except Exception as e: + progress.stop() + self.console.print() + error_panel = Panel( + f"[red]Error during mod merge: {str(e)}[/red]", + title="Merge Error", + style="red" + ) + self.console.print(error_panel) + self.console.input("\nPress Enter to acknowledge and return to main menu...") + return + + success_panel = Panel( + "[green]Mods have been merged successfully!\n" + "Your existing mods are preserved and Progression mods have been added.[/green]", + title="Success", + style="green" + ) + self.console.print(success_panel) + self.console.input("\nPress Enter to continue...") + + def check_for_updates(self): + """Check for application updates""" + self.clear_screen() + self.console.print(self.display_header()) + self.console.print() + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=self.console + ) as progress: + task = progress.add_task("Checking for updates...", total=None) + + try: + config = get_update_config() + update_checker = UpdateChecker(config["current_version"]) + + release_info, error = update_checker.check_for_updates_sync() + + if error: + progress.update(task, description=f"[red]Update check failed: {error}[/red]") + time.sleep(2) + elif release_info: + latest_version = release_info['version'] + if update_checker.is_newer_version(latest_version): + progress.update(task, description=f"[yellow]Update available: {latest_version}[/yellow]") + time.sleep(1) + + # Show update info + update_table = Table(show_header=False, box=box.ROUNDED) + update_table.add_column("Field", style="cyan bold", width=20) + update_table.add_column("Value", style="white") + + update_table.add_row("Current Version", config["current_version"]) + update_table.add_row("Latest Version", latest_version) + update_table.add_row("Release Date", release_info.get('published_at', 'Unknown')) + + self.console.print(Panel(update_table, title="Update Available", style="yellow")) + + if release_info.get('body'): + self.console.print(Panel(release_info['body'], title="Release Notes", style="cyan")) + + self.console.print() + if Confirm.ask("Open download page in browser?"): + webbrowser.open(release_info['html_url']) + self.console.print("[green]Download page opened in browser[/green]") + else: + progress.update(task, description="[green]You are running the latest version[/green]") + time.sleep(1) + self.console.print(f"\n[green]You are running the latest version ({config['current_version']})[/green]") + else: + progress.update(task, description="[yellow]No release information available[/yellow]") + time.sleep(1) + + except Exception as e: + progress.update(task, description=f"[red]Update check failed: {e}[/red]") + time.sleep(2) + + self.console.input("\nPress Enter to continue...") + + def view_configuration(self): + """View current configuration""" + self.clear_screen() + self.console.print(self.display_header()) + self.console.print() + + # Configuration table + config_table = Table(show_header=True, box=box.ROUNDED) + config_table.add_column("Setting", style="cyan bold", width=25) + config_table.add_column("Value", style="white", width=60) + config_table.add_column("Status", style="green", width=10) + + # RimWorld path + rimworld_status = "Valid" if self.is_rimworld_valid else "Invalid" + rimworld_color = "green" if self.is_rimworld_valid else "red" + config_table.add_row("RimWorld Path", self.rimworld_path or "Not set", f"[{rimworld_color}]{rimworld_status}[/{rimworld_color}]") + + # Workshop path + config_table.add_row("Workshop Path", self.workshop_path or "Not set", "Auto") + + # ModsConfig path + modsconfig_status = "Found" if self.modsconfig_path and os.path.exists(self.modsconfig_path) else "Not found" + modsconfig_color = "green" if self.modsconfig_path and os.path.exists(self.modsconfig_path) else "red" + config_table.add_row("ModsConfig Path", self.modsconfig_path or "Not found", f"[{modsconfig_color}]{modsconfig_status}[/{modsconfig_color}]") + + # Collection URLs + for name, url in self.collections.items(): + subscribed = "Yes" if self.subscription_status[name] else "No" + sub_color = "green" if self.subscription_status[name] else "red" + config_table.add_row(f"{name} URL", url, f"[{sub_color}]{subscribed}[/{sub_color}]") + + config_panel = Panel(config_table, title="Current Configuration", style="white") + self.console.print(config_panel) + self.console.print() + + # Version info + try: + config = get_update_config() + version_panel = Panel( + f"Progression Loader TUI v{config['current_version']}", + title="Version Information", + style="cyan" + ) + self.console.print(version_panel) + except: + pass + + self.console.input("\nPress Enter to continue...") + + +def main(): + """Main entry point for the TUI application""" + try: + # Check for updates first + config = get_update_config() + if config.get("check_on_startup", True): + console.print("[cyan]Checking for updates...[/cyan]") + try: + update_checker = UpdateChecker(config["current_version"]) + release_info, error = update_checker.check_for_updates_sync() + + if release_info and update_checker.is_newer_version(release_info['version']): + console.print(f"[yellow]Update available: {release_info['version']}[/yellow]") + if config.get("updates_required", False): + console.print("[red]This update is required. Please update before continuing.[/red]") + if Confirm.ask("Open download page?"): + webbrowser.open(release_info['html_url']) + return + else: + console.print("[dim]You can update later from the main menu.[/dim]") + except Exception as e: + console.print(f"[dim]Update check failed: {e}[/dim]") + + # Start the TUI + app = ProgressionTUI() + app.show_main_menu() + + except KeyboardInterrupt: + console.print("\n[yellow]Goodbye![/yellow]") + except Exception as e: + console.print(f"[red]Fatal error: {e}[/red]") + console.print("[dim]Please report this error to the developers.[/dim]") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d8e692f..12ae88b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ python-dotenv>=0.19.0 pyglet>=1.5.0 Pillow>=8.0.0 packaging>=21.0 -customtkinter>=5.0.0 \ No newline at end of file +customtkinter>=5.0.0 +rich>=13.0.0 \ No newline at end of file diff --git a/steam_workshop_gui.py b/steam_workshop_gui.py index 1123ed9..076f966 100644 --- a/steam_workshop_gui.py +++ b/steam_workshop_gui.py @@ -2389,19 +2389,49 @@ class SteamWorkshopGUI: self._safe_update_output(f"\nTotal unique workshop items: {len(unique_ids)}\n") self._safe_update_output("Extracting package names from About.xml files...\n\n") - # Extract package names from About.xml files + # Extract package names from About.xml files and validate results = [] + failed_mods = [] + valid_mods = [] + for workshop_id in sorted(unique_ids): - package_name = self.get_package_name_from_about_xml(workshop_id) - results.append((workshop_id, package_name)) + package_id, steam_app_id = self.get_package_info_from_about_xml(workshop_id) + + if not self.is_package_id_valid(package_id): + failed_mods.append((workshop_id, package_id)) + else: + # Use steamAppId from XML if available, otherwise use workshop_id + effective_steam_id = steam_app_id if steam_app_id else workshop_id + # Store as (effective_steam_id, package_id, original_workshop_id) + valid_mods.append((effective_steam_id, package_id, workshop_id)) + + # Check if there are any failed mods + if failed_mods: + self._safe_update_output("=== PACKAGE ID VALIDATION FAILED ===\n\n") + self._safe_update_output("❌ The following mods have issues and cannot be loaded:\n\n") + + for workshop_id, error in failed_mods: + self._safe_update_output(f"Workshop ID {workshop_id}: {error}\n") + + self._safe_update_output(f"\n📊 VALIDATION SUMMARY:\n") + self._safe_update_output(f"✗ {len(failed_mods)} mods failed validation\n") + self._safe_update_output(f"✓ {len(valid_mods)} mods passed validation\n\n") + self._safe_update_output("⚠️ The mod pack cannot be loaded until all package ID issues are resolved.\n") + self._safe_update_output("This usually means the mods are not properly downloaded or corrupted.\n") + self._safe_update_output("Please check your Steam Workshop subscriptions and try redownloading the failed mods.\n\n") + return + + # All mods validated successfully + results = valid_mods # Display results self._safe_update_output("=== PROGRESSION PACK COMPLETE ===\n\n") - self._safe_update_output(f"{'Workshop ID':<15} | Package Name\n") + self._safe_update_output(f"✅ All {len(results)} mods validated successfully!\n\n") + self._safe_update_output(f"{'Steam ID':<15} | Package Name\n") self._safe_update_output("-" * 80 + "\n") - for workshop_id, package_name in results: - self._safe_update_output(f"{workshop_id:<15} | {package_name}\n") + for steam_id, package_name, _ in results: + self._safe_update_output(f"{steam_id:<15} | {package_name}\n") self._safe_update_output(f"\nTotal mods processed: {len(results)}\n") @@ -2444,11 +2474,40 @@ class SteamWorkshopGUI: self._safe_update_output(f"\nTotal unique workshop items: {len(unique_workshop_ids)}\n") self._safe_update_output("Extracting package names from About.xml files...\n\n") - # Extract package names from About.xml files for workshop mods + # Extract package names from About.xml files for workshop mods and validate workshop_results = [] + failed_mods = [] + valid_mods = [] + for workshop_id in sorted(unique_workshop_ids): - package_name = self.get_package_name_from_about_xml(workshop_id) - workshop_results.append((workshop_id, package_name)) + package_id, steam_app_id = self.get_package_info_from_about_xml(workshop_id) + + if not self.is_package_id_valid(package_id): + failed_mods.append((workshop_id, package_id)) + else: + # Use steamAppId from XML if available, otherwise use workshop_id + effective_steam_id = steam_app_id if steam_app_id else workshop_id + # Store as (effective_steam_id, package_id, original_workshop_id) + valid_mods.append((effective_steam_id, package_id, workshop_id)) + + # Check if there are any failed mods + if failed_mods: + self._safe_update_output("=== PACKAGE ID VALIDATION FAILED ===\n\n") + self._safe_update_output("❌ The following mods have issues and cannot be merged:\n\n") + + for workshop_id, error in failed_mods: + self._safe_update_output(f"Workshop ID {workshop_id}: {error}\n") + + self._safe_update_output(f"\n📊 VALIDATION SUMMARY:\n") + self._safe_update_output(f"✗ {len(failed_mods)} mods failed validation\n") + self._safe_update_output(f"✓ {len(valid_mods)} mods passed validation\n\n") + self._safe_update_output("⚠️ The mod merge cannot be completed until all package ID issues are resolved.\n") + self._safe_update_output("This usually means the mods are not properly downloaded or corrupted.\n") + self._safe_update_output("Please check your Steam Workshop subscriptions and try redownloading the failed mods.\n\n") + return + + # All mods validated successfully + workshop_results = valid_mods # Step 3: Remove duplicates and merge the mod lists self._safe_update_output("=== MERGING CURRENT MODS WITH PROGRESSION PACK ===\n\n") @@ -2461,8 +2520,8 @@ class SteamWorkshopGUI: self._safe_update_output(f"\nWorkshop Mods from Collections:\n") self._safe_update_output("-" * 50 + "\n") - for workshop_id, package_name in workshop_results: - self._safe_update_output(f"{workshop_id:<15} | {package_name}\n") + for steam_id, package_name, _ in workshop_results: + self._safe_update_output(f"{steam_id:<15} | {package_name}\n") # Step 4: Remove duplicates based on package ID and workshop ID current_package_ids = {package_id for package_id, _ in current_mods} @@ -2471,15 +2530,15 @@ class SteamWorkshopGUI: filtered_workshop_results = [] duplicates_found = [] - for workshop_id, package_id in workshop_results: + for steam_id, package_id, original_workshop_id in workshop_results: # Check for duplicate package ID if package_id in current_package_ids: - duplicates_found.append((workshop_id, package_id, "Package ID already exists")) + duplicates_found.append((steam_id, package_id, "Package ID already exists")) # Check for duplicate workshop ID (user might have same mod in current list) - elif workshop_id in current_workshop_ids: - duplicates_found.append((workshop_id, package_id, "Workshop ID already exists")) + elif steam_id in current_workshop_ids: + duplicates_found.append((steam_id, package_id, "Steam ID already exists")) else: - filtered_workshop_results.append((workshop_id, package_id)) + filtered_workshop_results.append((steam_id, package_id, original_workshop_id)) # Report duplicates if duplicates_found: @@ -2600,7 +2659,7 @@ class SteamWorkshopGUI: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') # Add workshop mod package IDs to meta section - for workshop_id, package_id in workshop_results: + for steam_id, package_id, original_workshop_id in workshop_results: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') xml_lines.extend([ @@ -2616,8 +2675,8 @@ class SteamWorkshopGUI: xml_lines.append(f'\t\t\t
  • 0
  • ') # Add workshop IDs to meta section - for workshop_id, package_id in workshop_results: - xml_lines.append(f'\t\t\t
  • {workshop_id}
  • ') + for steam_id, package_id, original_workshop_id in workshop_results: + xml_lines.append(f'\t\t\t
  • {steam_id}
  • ') xml_lines.extend([ '\t\t', @@ -2630,8 +2689,8 @@ class SteamWorkshopGUI: xml_lines.append(f'\t\t\t
  • {escaped_name}
  • ') # Add workshop mod names to meta section - for workshop_id, package_id in workshop_results: - mod_name = self.get_mod_name_from_about_xml(workshop_id) + for steam_id, package_id, original_workshop_id in workshop_results: + mod_name = self.get_mod_name_from_about_xml(original_workshop_id) escaped_name = self.escape_xml_text(mod_name) xml_lines.append(f'\t\t\t
  • {escaped_name}
  • ') @@ -2647,7 +2706,7 @@ class SteamWorkshopGUI: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') # Add workshop mod package IDs to modList section - for workshop_id, package_id in workshop_results: + for steam_id, package_id, original_workshop_id in workshop_results: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') xml_lines.extend([ @@ -2661,8 +2720,8 @@ class SteamWorkshopGUI: xml_lines.append(f'\t\t\t
  • {escaped_name}
  • ') # Add workshop mod names to modList section - for workshop_id, package_id in workshop_results: - mod_name = self.get_mod_name_from_about_xml(workshop_id) + for steam_id, package_id, original_workshop_id in workshop_results: + mod_name = self.get_mod_name_from_about_xml(original_workshop_id) escaped_name = self.escape_xml_text(mod_name) xml_lines.append(f'\t\t\t
  • {escaped_name}
  • ') @@ -2713,8 +2772,8 @@ class SteamWorkshopGUI: # Extract mod names from About.xml files for workshop mods mod_names = [] - for workshop_id, package_id in mod_data: - mod_name = self.get_mod_name_from_about_xml(workshop_id) + for steam_id, package_id, original_workshop_id in mod_data: + mod_name = self.get_mod_name_from_about_xml(original_workshop_id) mod_names.append(mod_name) xml_lines = [ @@ -2736,7 +2795,7 @@ class SteamWorkshopGUI: print(msg.strip()) # Add workshop mod package IDs to meta section - for workshop_id, package_id in mod_data: + for steam_id, package_id, original_workshop_id in mod_data: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') xml_lines.extend([ @@ -2749,8 +2808,8 @@ class SteamWorkshopGUI: xml_lines.append(f'\t\t\t
  • 0
  • ') # Add workshop IDs to meta section - for workshop_id, package_id in mod_data: - xml_lines.append(f'\t\t\t
  • {workshop_id}
  • ') + for steam_id, package_id, original_workshop_id in mod_data: + xml_lines.append(f'\t\t\t
  • {steam_id}
  • ') xml_lines.extend([ '\t\t', @@ -2779,7 +2838,7 @@ class SteamWorkshopGUI: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') # Add workshop mod package IDs to modList section - for workshop_id, package_id in mod_data: + for steam_id, package_id, original_workshop_id in mod_data: xml_lines.append(f'\t\t\t
  • {package_id}
  • ') xml_lines.extend([ @@ -2982,13 +3041,23 @@ class SteamWorkshopGUI: except Exception as e: return f"Workshop ID {workshop_id}" - def get_package_name_from_about_xml(self, workshop_id): - """Extract package name from About/About.xml file""" + def is_package_id_valid(self, package_id): + """Check if a package ID is valid (not an error condition)""" + error_conditions = [ + "About.xml not found", + "packageId not found", + "XML parse error", + "Workshop path not found" + ] + return package_id not in error_conditions and not package_id.startswith("Error:") + + def get_package_info_from_about_xml(self, workshop_id): + """Extract package name and steam app ID from About/About.xml file""" try: about_xml_path = os.path.join(self.workshop_folder, workshop_id, "About", "About.xml") if not os.path.exists(about_xml_path): - return "About.xml not found" + return "About.xml not found", None # Parse the XML file tree = ET.parse(about_xml_path) @@ -2996,15 +3065,28 @@ class SteamWorkshopGUI: # Look for packageId element package_id_element = root.find('packageId') - if package_id_element is not None and package_id_element.text: - return package_id_element.text.strip() - else: - return "packageId not found" + if package_id_element is None or not package_id_element.text: + return "packageId not found", None + + # Look for steamAppId element + steam_app_id_element = root.find('steamAppId') + steam_app_id = None + if steam_app_id_element is not None and steam_app_id_element.text: + steam_app_id = steam_app_id_element.text.strip() + + # Return package ID in lowercase for consistency + package_id = package_id_element.text.strip().lower() + return package_id, steam_app_id except ET.ParseError: - return "XML parse error" + return "XML parse error", None except Exception as e: - return f"Error: {str(e)}" + return f"Error: {str(e)}", None + + def get_package_name_from_about_xml(self, workshop_id): + """Extract package name from About/About.xml file (backward compatibility)""" + package_id, _ = self.get_package_info_from_about_xml(workshop_id) + return package_id def _safe_update_output(self, text): """Safely update output text without recursion"""