import tkinter as tk from tkinter import ttk, scrolledtext, filedialog, messagebox import requests import re from urllib.parse import urlparse, parse_qs import threading import os import winreg from pathlib import Path class SteamWorkshopGUI: def __init__(self, root): self.root = root self.root.title("Steam Workshop Collection Manager") self.root.geometry("1200x800") self.root.configure(bg='#2b2b2b') # Configure dark theme self.setup_dark_theme() # Initialize workshop folder self.workshop_folder = None # Create main frame main_frame = tk.Frame(root, bg='#2b2b2b') main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10) # Left panel for inputs left_frame = tk.Frame(main_frame, bg='#2b2b2b', width=400) left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10)) left_frame.pack_propagate(False) # Right panel for output right_frame = tk.Frame(main_frame, bg='#2b2b2b') right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True) self.create_input_section(left_frame) self.create_output_section(right_frame) def setup_dark_theme(self): """Configure dark theme colors""" style = ttk.Style() style.theme_use('clam') # Configure colors for dark theme style.configure('TLabel', background='#2b2b2b', foreground='#ffffff') style.configure('TButton', background='#404040', foreground='#ffffff') style.map('TButton', background=[('active', '#505050')]) style.configure('TEntry', background='#404040', foreground='#ffffff', fieldbackground='#404040') def create_input_section(self, parent): """Create the left input section""" title_label = tk.Label(parent, text="Steam Workshop Collections", font=('Arial', 14, 'bold'), bg='#2b2b2b', fg='#ffffff') title_label.pack(pady=(0, 10)) # RimWorld game folder input rimworld_frame = tk.Frame(parent, bg='#2b2b2b') rimworld_frame.pack(fill='x', pady=(0, 10)) rimworld_label = tk.Label(rimworld_frame, text="RimWorld Game Folder:", font=('Arial', 9, 'bold'), bg='#2b2b2b', fg='#ffffff') rimworld_label.pack(anchor='w') rimworld_help = tk.Label(rimworld_frame, text="Right-click RimWorld in Steam > Manage > Browse local files, copy that path", font=('Arial', 8), bg='#2b2b2b', fg='#888888', wraplength=380) rimworld_help.pack(anchor='w', pady=(2, 5)) rimworld_input_frame = tk.Frame(rimworld_frame, bg='#2b2b2b') rimworld_input_frame.pack(fill='x') self.rimworld_var = tk.StringVar() self.rimworld_entry = tk.Entry(rimworld_input_frame, textvariable=self.rimworld_var, font=('Arial', 9), bg='#404040', fg='#ffffff', insertbackground='#ffffff', relief='flat', bd=5) self.rimworld_entry.pack(side='left', fill='x', expand=True) self.rimworld_entry.bind('', self.on_rimworld_path_change) browse_game_btn = tk.Button(rimworld_input_frame, text="Browse", command=self.browse_rimworld_folder, bg='#505050', fg='white', font=('Arial', 8), relief='flat', padx=10) browse_game_btn.pack(side='right', padx=(5, 0)) # Workshop folder display (derived from RimWorld path) workshop_frame = tk.Frame(parent, bg='#2b2b2b') workshop_frame.pack(fill='x', pady=(0, 15)) workshop_label = tk.Label(workshop_frame, text="Workshop Folder (Auto-derived):", font=('Arial', 9, 'bold'), bg='#2b2b2b', fg='#ffffff') workshop_label.pack(anchor='w') self.workshop_var = tk.StringVar(value="Enter RimWorld path above") self.workshop_display = tk.Entry(workshop_frame, textvariable=self.workshop_var, font=('Arial', 8), bg='#404040', fg='#ffffff', insertbackground='#ffffff', relief='flat', bd=5, state='readonly') self.workshop_display.pack(fill='x', pady=(5, 0)) # Core collection self.create_url_input(parent, "Core Collection:", "https://steamcommunity.com/workshop/filedetails/?id=3521297585") # Content collection self.create_url_input(parent, "Content Collection:", "steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3521319712") # Cosmetics collection self.create_url_input(parent, "Cosmetics Collection:", "steam://openurl/https://steamcommunity.com/sharedfiles/filedetails/?id=3637541646") # Process button process_btn = tk.Button(parent, text="Extract Workshop IDs", command=self.process_collections, bg='#0078d4', fg='white', font=('Arial', 10, 'bold'), relief='flat', padx=20, pady=8) process_btn.pack(pady=10) # Check local mods button local_btn = tk.Button(parent, text="List Local Mod Folders", command=self.list_local_mod_folders, bg='#28a745', fg='white', font=('Arial', 10, 'bold'), relief='flat', padx=20, pady=8) local_btn.pack(pady=5) def create_url_input(self, parent, label_text, default_url): """Create a labeled URL input field""" label = tk.Label(parent, text=label_text, font=('Arial', 10, 'bold'), bg='#2b2b2b', fg='#ffffff') label.pack(anchor='w', pady=(10, 5)) entry = tk.Entry(parent, font=('Arial', 9), bg='#404040', fg='#ffffff', insertbackground='#ffffff', relief='flat', bd=5) entry.pack(fill='x', pady=(0, 10)) entry.insert(0, default_url) # Store reference to entry widgets if not hasattr(self, 'url_entries'): self.url_entries = {} collection_name = label_text.replace(" Collection:", "").lower() self.url_entries[collection_name] = entry def create_output_section(self, parent): """Create the right output section""" # Main output area main_output_frame = tk.Frame(parent, bg='#2b2b2b') main_output_frame.pack(fill=tk.BOTH, expand=True) title_label = tk.Label(main_output_frame, text="Workshop IDs Output", font=('Arial', 14, 'bold'), bg='#2b2b2b', fg='#ffffff') title_label.pack(pady=(0, 10)) # Output text area with scrollbar self.output_text = scrolledtext.ScrolledText(main_output_frame, font=('Consolas', 10), bg='#1e1e1e', fg='#ffffff', insertbackground='#ffffff', selectbackground='#404040', relief='flat', bd=5, height=20) self.output_text.pack(fill=tk.BOTH, expand=True) # Local mod folders section local_mods_frame = tk.Frame(parent, bg='#2b2b2b') local_mods_frame.pack(fill=tk.X, pady=(10, 0)) local_title_label = tk.Label(local_mods_frame, text="Local Mod Folders", font=('Arial', 12, 'bold'), bg='#2b2b2b', fg='#ffffff') local_title_label.pack(pady=(0, 5)) # Local mods text area self.local_mods_text = scrolledtext.ScrolledText(local_mods_frame, font=('Consolas', 9), bg='#1e1e1e', fg='#ffffff', insertbackground='#ffffff', selectbackground='#404040', relief='flat', bd=5, height=8) self.local_mods_text.pack(fill=tk.X) def extract_workshop_id(self, url): """Extract workshop ID from Steam URL""" # Clean up steam:// protocol URLs if url.startswith('steam://openurl/'): url = url.replace('steam://openurl/', '') # Extract ID from URL parameters try: parsed_url = urlparse(url) if 'id=' in parsed_url.query: query_params = parse_qs(parsed_url.query) return query_params.get('id', [None])[0] elif '/filedetails/?id=' in url: # Direct extraction for simple cases match = re.search(r'id=(\d+)', url) if match: return match.group(1) except Exception as e: print(f"Error extracting ID from {url}: {e}") return None def process_collections(self): """Process all collection URLs and extract workshop IDs""" self.output_text.delete(1.0, tk.END) self.output_text.insert(tk.END, "Processing collections...\n\n") self.root.update() # Process each collection in a separate thread to avoid blocking UI threading.Thread(target=self._process_collections_thread, daemon=True).start() def _process_collections_thread(self): """Thread function to process collections""" try: for collection_name, entry in self.url_entries.items(): url = entry.get().strip() if url: self._safe_update_output(f"=== {collection_name.upper()} COLLECTION ===\n") workshop_id = self.extract_workshop_id(url) if workshop_id: self._safe_update_output(f"Collection ID: {workshop_id}\n") # Fetch collection contents workshop_ids = self.fetch_collection_items(workshop_id) if workshop_ids: self._safe_update_output(f"Found {len(workshop_ids)} items:\n") # Check which mods are installed locally if workshop folder is available local_mods = set() if self.workshop_folder and os.path.exists(self.workshop_folder): try: local_mods = {item for item in os.listdir(self.workshop_folder) if os.path.isdir(os.path.join(self.workshop_folder, item)) and item.isdigit()} except: pass missing_count = 0 for item_id in workshop_ids: status = " [INSTALLED]" if item_id in local_mods else " [MISSING]" if item_id not in local_mods: missing_count += 1 self._safe_update_output(f"{item_id}{status}\n") if local_mods: self._safe_update_output(f"\nSummary: {len(workshop_ids) - missing_count} installed, {missing_count} missing\n") else: self._safe_update_output("Could not fetch collection items\n") else: self._safe_update_output("Could not extract workshop ID from URL\n") self._safe_update_output("\n") self._safe_update_output("Processing complete!") except Exception as e: self._safe_update_output(f"Error in processing: {str(e)}\n") def find_steam_workshop_folder(self): """Automatically find the Steam Workshop folder for RimWorld""" try: # Method 1: Try to find Steam installation path from registry steam_paths = [] try: # Check HKEY_LOCAL_MACHINE first with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Valve\Steam") as key: install_path = winreg.QueryValueEx(key, "InstallPath")[0] steam_paths.append(install_path) except (FileNotFoundError, OSError): pass try: # Check HKEY_CURRENT_USER with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Valve\Steam") as key: install_path = winreg.QueryValueEx(key, "SteamPath")[0] steam_paths.append(install_path) except (FileNotFoundError, OSError): pass # Method 2: Check common Steam installation locations common_paths = [ r"C:\Program Files (x86)\Steam", r"C:\Program Files\Steam", r"D:\Steam", r"E:\Steam", os.path.expanduser(r"~\Steam") ] steam_paths.extend(common_paths) # Method 3: Check if Steam is in PATH try: import shutil steam_exe = shutil.which("steam.exe") if steam_exe: steam_paths.append(os.path.dirname(steam_exe)) except: pass # Look for the workshop folder in each potential Steam path for steam_path in steam_paths: if not steam_path: continue workshop_path = os.path.join(steam_path, "steamapps", "workshop", "content", "294100") if os.path.exists(workshop_path): return workshop_path return None except Exception as e: print(f"Error finding Steam workshop folder: {e}") return None def browse_workshop_folder(self): """Allow user to manually select the workshop folder""" folder = filedialog.askdirectory( title="Select RimWorld Workshop Folder (steamapps/workshop/content/294100)", initialdir=self.workshop_folder or "C:\\" ) if folder: # Validate that this looks like a workshop folder if "294100" in folder or messagebox.askyesno( "Confirm Folder", f"Are you sure this is the RimWorld workshop folder?\n\n{folder}" ): self._safe_update_output(f"Workshop folder updated: {folder}\n") def on_rimworld_path_change(self, event=None): """Called when RimWorld path is changed""" rimworld_path = self.rimworld_var.get().strip() if rimworld_path: # Derive workshop path from RimWorld path # From: D:\SteamLibrary\steamapps\common\RimWorld # To: D:\SteamLibrary\steamapps\workshop\content\294100 if "steamapps" in rimworld_path.lower(): # Find the steamapps part and replace common\RimWorld with workshop\content\294100 parts = rimworld_path.split(os.sep) try: steamapps_index = next(i for i, part in enumerate(parts) if part.lower() == 'steamapps') # Build new path: everything up to steamapps + steamapps + workshop + content + 294100 workshop_parts = parts[:steamapps_index + 1] + ['workshop', 'content', '294100'] workshop_path = os.sep.join(workshop_parts) self.workshop_folder = workshop_path self.workshop_var.set(workshop_path) except (StopIteration, IndexError): self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'") else: self.workshop_var.set("Invalid RimWorld path - should contain 'steamapps'") else: self.workshop_var.set("Enter RimWorld path above") def browse_rimworld_folder(self): """Allow user to browse for RimWorld game folder""" folder = filedialog.askdirectory( title="Select RimWorld Game Folder (Right-click RimWorld in Steam > Manage > Browse local files)", initialdir="C:\\" ) if folder: self.rimworld_var.set(folder) self.on_rimworld_path_change() # Update workshop path def list_local_mod_folders(self): """List all local mod folder names in the separate text box""" if not self.workshop_folder or not os.path.exists(self.workshop_folder): self.local_mods_text.delete(1.0, tk.END) self.local_mods_text.insert(tk.END, "Workshop folder not found or invalid.\nPlease enter correct RimWorld path above.") return threading.Thread(target=self._list_local_mod_folders_thread, daemon=True).start() def _list_local_mod_folders_thread(self): """Thread function to list local mod folders""" try: # Clear the local mods text box self.local_mods_text.delete(1.0, tk.END) if not os.path.exists(self.workshop_folder): self.local_mods_text.insert(tk.END, f"Workshop folder does not exist:\n{self.workshop_folder}") return # Get all subdirectories (each represents a workshop mod) local_mods = [] for item in os.listdir(self.workshop_folder): item_path = os.path.join(self.workshop_folder, item) if os.path.isdir(item_path) and item.isdigit(): local_mods.append(item) local_mods.sort() # Display in the local mods text box self.local_mods_text.insert(tk.END, f"Found {len(local_mods)} local workshop mod folders:\n\n") for mod_id in local_mods: self.local_mods_text.insert(tk.END, f"{mod_id}\n") except Exception as e: self.local_mods_text.delete(1.0, tk.END) self.local_mods_text.insert(tk.END, f"Error listing local mods: {str(e)}") def check_local_mods(self): """Check which mods are installed locally in the workshop folder (for backward compatibility)""" self.list_local_mod_folders() def _safe_update_output(self, text): """Safely update output text without recursion""" try: self.output_text.insert(tk.END, text) self.output_text.see(tk.END) except Exception: pass # Ignore update errors to prevent recursion def fetch_collection_items(self, collection_id): """Fetch workshop IDs from a Steam Workshop collection""" 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 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' } self._safe_update_output(f"Fetching collection page...\n") response = requests.get(url, headers=headers, timeout=15) response.raise_for_status() # Extract workshop IDs from collection items only html_content = response.text workshop_ids = [] # Find the collectionChildren section collection_start = html_content.find('
') if collection_start != -1: # Find the proper end of the collectionChildren section # Look for the closing
that matches the opening collectionChildren div # We need to count div tags to find the matching closing tag search_pos = collection_start + len('
') div_count = 1 # We've opened one div collection_end = -1 while search_pos < len(html_content) and div_count > 0: next_open = html_content.find('', search_pos) if next_close == -1: break if next_open != -1 and next_open < next_close: div_count += 1 search_pos = next_open + 4 else: div_count -= 1 if div_count == 0: collection_end = next_close + 6 break search_pos = next_close + 6 if collection_end == -1: collection_end = len(html_content) collection_section = html_content[collection_start:collection_end] # Extract IDs from sharedfile_ elements (these are the actual collection items) sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', collection_section) workshop_ids.extend(sharedfile_ids) self._safe_update_output(f"Found {len(sharedfile_ids)} collection items\n") else: # Fallback: search the entire page but be more selective self._safe_update_output("Collection section not found, using fallback method\n") sharedfile_ids = re.findall(r'id="sharedfile_(\d+)"', html_content) workshop_ids.extend(sharedfile_ids) # Remove duplicates and the collection ID itself unique_ids = list(set(workshop_ids)) if collection_id in unique_ids: unique_ids.remove(collection_id) return sorted(unique_ids) # Sort for consistent output except requests.RequestException as e: self._safe_update_output(f"Network error: {str(e)}\n") return [] except Exception as e: self._safe_update_output(f"Error fetching collection: {str(e)}\n") return [] def _update_output(self, text): """Update output text (called from main thread)""" try: self.output_text.insert(tk.END, text) self.output_text.see(tk.END) except Exception: pass # Prevent recursion errors def main(): root = tk.Tk() app = SteamWorkshopGUI(root) root.mainloop() if __name__ == "__main__": main()