Files
ProgressionMods/steam_workshop_gui.py

502 lines
23 KiB
Python

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('<KeyRelease>', 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('<div class="collectionChildren">')
if collection_start != -1:
# Find the proper end of the collectionChildren section
# Look for the closing </div> that matches the opening collectionChildren div
# We need to count div tags to find the matching closing tag
search_pos = collection_start + len('<div class="collectionChildren">')
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('<div', search_pos)
next_close = html_content.find('</div>', 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()