Intial Release
Some checks failed
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Has been cancelled
Some checks failed
Build and Upload Release (Windows EXE) / Build Windows EXE (release) Has been cancelled
This commit is contained in:
66
.gitea/workflows/release.yml
Normal file
66
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
name: Build and Upload Release (Windows EXE)
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-windows-exe:
|
||||||
|
name: Build Windows EXE
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
if (Test-Path requirements.txt) { pip install -r requirements.txt }
|
||||||
|
pip install pyinstaller
|
||||||
|
|
||||||
|
- name: Build EXE with PyInstaller
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
# Include SeaLoader.png so the packaged app icon in-app works
|
||||||
|
$addData = "SeaLoader.png;." # Windows uses ';' for --add-data
|
||||||
|
pyinstaller --noconfirm --onefile --windowed sealoader_gui.py --name SeaLoader --add-data "$addData"
|
||||||
|
|
||||||
|
- name: Prepare artifact
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
New-Item -ItemType Directory -Force -Path dist_upload | Out-Null
|
||||||
|
Copy-Item dist\SeaLoader.exe dist_upload\SeaLoader.exe
|
||||||
|
if (Test-Path README.md) { Copy-Item README.md dist_upload\ }
|
||||||
|
if (Test-Path LICENSE) { Copy-Item LICENSE dist_upload\ }
|
||||||
|
Compress-Archive -Path dist_upload\* -DestinationPath SeaLoader_Windows_x64.zip -Force
|
||||||
|
|
||||||
|
- name: Upload asset to Release
|
||||||
|
if: ${{ github.event_name == 'release' && github.event.action == 'published' }}
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
RELEASE_ID: ${{ github.event.release.id }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
run: |
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$uploadUrl = "$env:SERVER_URL/api/v1/repos/$env:REPO/releases/$env:RELEASE_ID/assets?name=SeaLoader_Windows_x64.zip"
|
||||||
|
Write-Host "Uploading asset to $uploadUrl"
|
||||||
|
Invoke-RestMethod -Method Post -Uri $uploadUrl -Headers @{ Authorization = "token $env:TOKEN" } -ContentType "application/zip" -InFile "SeaLoader_Windows_x64.zip"
|
||||||
|
|
||||||
|
- name: Upload artifact (CI logs)
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SeaLoader_Windows_x64
|
||||||
|
path: |
|
||||||
|
SeaLoader_Windows_x64.zip
|
||||||
|
dist/SeaLoader/SeaLoader.exe
|
||||||
|
|
||||||
BIN
SeaLoader.png
Normal file
BIN
SeaLoader.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 322 KiB |
BIN
SeaLoader.psd
Normal file
BIN
SeaLoader.psd
Normal file
Binary file not shown.
BIN
__pycache__/steam_required_ids.cpython-310.pyc
Normal file
BIN
__pycache__/steam_required_ids.cpython-310.pyc
Normal file
Binary file not shown.
37
hrsys_logo.svg
Normal file
37
hrsys_logo.svg
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 2048 2048">
|
||||||
|
<!-- Generator: Adobe Illustrator 29.6.0, SVG Export Plug-In . SVG Version: 2.1.1 Build 207) -->
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0, .st1, .st2 {
|
||||||
|
fill: #f16465;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st0, .st1, .st3 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st1 {
|
||||||
|
font-family: FMBolyarSansPro-400, 'FM Bolyar Sans Pro';
|
||||||
|
font-size: 176.84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4 {
|
||||||
|
letter-spacing: .6em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<g id="Systems" class="st3">
|
||||||
|
<text/>
|
||||||
|
</g>
|
||||||
|
<g id="H">
|
||||||
|
<g>
|
||||||
|
<text class="st1" transform="translate(151.86 1585.59)"><tspan class="st4" x="0" y="0">SYSTEM</tspan><tspan x="1589.37" y="0">S</tspan></text>
|
||||||
|
<rect class="st0" x="43.86" y="1699.23" width="1960.29" height="16.64"/>
|
||||||
|
<g>
|
||||||
|
<path class="st2" d="M516.81,384.73c354.34.79,708.68,1.58,1063.01,2.37,25.33-.26,191.03.81,306.99,134.63,27.84,32.13,97.9,123.95,92.85,253.41-5.85,150-109.21,265.44-206.05,311.69-40.43,19.31-82.89,28.07-95.95,30.69-75.28,15.13-139.12,8.06-179.02.41,180.82,180.82,361.64,361.64,542.47,542.47h-281.15c-240.47-240.47-480.95-480.95-721.42-721.42h600.36c98.23-15.25,167-101.58,161.66-191.46-6.01-101.15-104.58-184.37-216.28-168.88h-897.5c-56.65-64.64-113.31-129.28-169.96-193.92Z"/>
|
||||||
|
<polygon class="st2" points="6.89 1663.27 6.89 385.2 190.13 385.88 189.56 945.29 940.52 945.29 1173.93 1166.01 1173.93 1663.27 991.26 1663.27 991.26 1125.42 192.09 1125.42 192.09 1663.27 6.89 1663.27"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
requests>=2.31.0
|
||||||
|
beautifulsoup4>=4.12.2
|
||||||
385
sealoader_gui.py
Normal file
385
sealoader_gui.py
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
import configparser
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import messagebox, ttk
|
||||||
|
|
||||||
|
from steam_required_ids import extract_required_item_ids, resolve_workshop_names
|
||||||
|
|
||||||
|
|
||||||
|
# Minimal dark theme colors
|
||||||
|
BG = "#16181c"
|
||||||
|
PANEL_BG = "#1f232a"
|
||||||
|
FG = "#e6e6e6"
|
||||||
|
MUTED = "#a0a6ad"
|
||||||
|
ACCENT = "#4f8cff"
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_INI_PATH = str(Path.home() / "AppData" / "LocalLow" / "Triassic Games" / "Sea Power" / "usersettings.ini")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class InstalledMod:
|
||||||
|
mod_id: str
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
def read_installed_mods(ini_path: str) -> Dict[str, InstalledMod]:
|
||||||
|
parser = configparser.ConfigParser()
|
||||||
|
parser.optionxform = str
|
||||||
|
if not os.path.exists(ini_path):
|
||||||
|
raise FileNotFoundError(f"usersettings.ini not found at: {ini_path}")
|
||||||
|
parser.read(ini_path, encoding="utf-8")
|
||||||
|
|
||||||
|
if "[LoadOrder]" in "\n".join(open(ini_path, encoding="utf-8").readlines()):
|
||||||
|
section = "LoadOrder"
|
||||||
|
else:
|
||||||
|
section = "LoadOrder"
|
||||||
|
|
||||||
|
if section not in parser:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
mods: Dict[str, InstalledMod] = {}
|
||||||
|
for key, value in parser[section].items():
|
||||||
|
if not key.lower().startswith("mod"):
|
||||||
|
continue
|
||||||
|
# Format: ModXDirectory=<id or name>,<True|False>
|
||||||
|
parts = value.split(",")
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
left_token = parts[0].strip()
|
||||||
|
# Prefer numeric Workshop ID if present at start, else keep full token
|
||||||
|
num_prefix = ""
|
||||||
|
for ch in left_token:
|
||||||
|
if ch.isdigit():
|
||||||
|
num_prefix += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
mod_id = num_prefix if num_prefix else left_token
|
||||||
|
enabled = False
|
||||||
|
if len(parts) > 1:
|
||||||
|
enabled = parts[1].strip().lower() == "true"
|
||||||
|
mods[mod_id] = InstalledMod(mod_id=mod_id, enabled=enabled)
|
||||||
|
return mods
|
||||||
|
|
||||||
|
|
||||||
|
def enable_mods_in_ini(ini_path: str, ids_to_enable: List[str]) -> Tuple[int, int]:
|
||||||
|
parser = configparser.ConfigParser()
|
||||||
|
parser.optionxform = str
|
||||||
|
parser.read(ini_path, encoding="utf-8")
|
||||||
|
|
||||||
|
section = "LoadOrder"
|
||||||
|
if section not in parser:
|
||||||
|
raise ValueError("[LoadOrder] section not found in usersettings.ini")
|
||||||
|
|
||||||
|
# Backup before changes
|
||||||
|
backup_path = ini_path + ".bak"
|
||||||
|
shutil.copyfile(ini_path, backup_path)
|
||||||
|
|
||||||
|
enabled_count = 0
|
||||||
|
missing_count = 0
|
||||||
|
|
||||||
|
# Maintain order and preserve original left token (may include name after ID)
|
||||||
|
items = list(parser[section].items())
|
||||||
|
# Maintain order
|
||||||
|
for target_id in ids_to_enable:
|
||||||
|
found_key = None
|
||||||
|
for key, value in items:
|
||||||
|
if not key.lower().startswith("mod"):
|
||||||
|
continue
|
||||||
|
parts = value.split(",")
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
left_token = parts[0].strip()
|
||||||
|
# Extract numeric prefix for matching
|
||||||
|
num_prefix = ""
|
||||||
|
for ch in left_token:
|
||||||
|
if ch.isdigit():
|
||||||
|
num_prefix += ch
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
mod_id = num_prefix if num_prefix else left_token
|
||||||
|
if mod_id == target_id:
|
||||||
|
found_key = key
|
||||||
|
current_enabled = False
|
||||||
|
if len(parts) > 1:
|
||||||
|
current_enabled = parts[1].strip().lower() == "true"
|
||||||
|
if not current_enabled:
|
||||||
|
# Preserve original left token, only toggle enabled flag
|
||||||
|
new_value = f"{left_token},True"
|
||||||
|
parser[section][key] = new_value
|
||||||
|
enabled_count += 1
|
||||||
|
break
|
||||||
|
if not found_key:
|
||||||
|
missing_count += 1
|
||||||
|
|
||||||
|
with open(ini_path, "w", encoding="utf-8") as f:
|
||||||
|
parser.write(f)
|
||||||
|
|
||||||
|
return enabled_count, missing_count
|
||||||
|
|
||||||
|
|
||||||
|
class SeaLoaderApp(tk.Tk):
|
||||||
|
def __init__(self, ini_path: str):
|
||||||
|
super().__init__()
|
||||||
|
self.title("SeaLoader")
|
||||||
|
self.configure(bg=BG)
|
||||||
|
self.geometry("960x560")
|
||||||
|
self.minsize(800, 480)
|
||||||
|
|
||||||
|
self.ini_path = ini_path
|
||||||
|
self.installed_mods: Dict[str, InstalledMod] = {}
|
||||||
|
self.required_ids: List[str] = []
|
||||||
|
self.installed_names_map: Dict[str, str] = {}
|
||||||
|
self._icon_img = None
|
||||||
|
|
||||||
|
self._style()
|
||||||
|
self._layout()
|
||||||
|
self._load_installed_mods()
|
||||||
|
|
||||||
|
# App icon from PNG (optional)
|
||||||
|
try:
|
||||||
|
icon_path = self._resource_path("SeaLoader.png")
|
||||||
|
if icon_path.exists():
|
||||||
|
self._icon_img = tk.PhotoImage(file=str(icon_path))
|
||||||
|
self.iconphoto(True, self._icon_img)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Help shortcut
|
||||||
|
try:
|
||||||
|
self.bind("<F1>", lambda e: self._on_help())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _style(self) -> None:
|
||||||
|
style = ttk.Style(self)
|
||||||
|
try:
|
||||||
|
style.theme_use("clam")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
style.configure("TFrame", background=BG)
|
||||||
|
style.configure("Panel.TFrame", background=PANEL_BG)
|
||||||
|
style.configure("TLabel", background=PANEL_BG, foreground=FG)
|
||||||
|
style.configure("Header.TLabel", background=BG, foreground=FG, font=("Segoe UI", 12, "bold"))
|
||||||
|
style.configure("TButton", background=PANEL_BG, foreground=FG, borderwidth=0)
|
||||||
|
style.map("TButton", background=[("active", ACCENT)])
|
||||||
|
style.configure("TEntry", fieldbackground=PANEL_BG, foreground=FG, insertcolor=FG)
|
||||||
|
style.configure("TScrollbar", background=PANEL_BG, troughcolor=BG)
|
||||||
|
style.configure("Treeview", background=PANEL_BG, fieldbackground=PANEL_BG, foreground=FG, borderwidth=0)
|
||||||
|
style.configure("Treeview.Heading", background=PANEL_BG, foreground=MUTED)
|
||||||
|
|
||||||
|
def _layout(self) -> None:
|
||||||
|
top = ttk.Frame(self, style="TFrame")
|
||||||
|
top.pack(fill=tk.X, padx=12, pady=(12, 6))
|
||||||
|
|
||||||
|
url_label = ttk.Label(top, text="Workshop URL:", style="TLabel")
|
||||||
|
url_label.pack(side=tk.LEFT)
|
||||||
|
self.url_var = tk.StringVar()
|
||||||
|
self.url_entry = ttk.Entry(top, textvariable=self.url_var, width=70)
|
||||||
|
self.url_entry.pack(side=tk.LEFT, padx=8, fill=tk.X, expand=True)
|
||||||
|
self.fetch_btn = ttk.Button(top, text="Fetch Required Mods", command=self._on_fetch)
|
||||||
|
self.fetch_btn.pack(side=tk.LEFT)
|
||||||
|
|
||||||
|
main = ttk.Frame(self, style="TFrame")
|
||||||
|
main.pack(fill=tk.BOTH, expand=True, padx=12, pady=6)
|
||||||
|
|
||||||
|
left_panel = ttk.Frame(main, style="Panel.TFrame")
|
||||||
|
left_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||||
|
right_panel = ttk.Frame(main, style="Panel.TFrame")
|
||||||
|
right_panel.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(8, 0))
|
||||||
|
|
||||||
|
left_header = ttk.Label(left_panel, text="Installed Mods", style="Header.TLabel")
|
||||||
|
left_header.pack(anchor="w", padx=8, pady=8)
|
||||||
|
self.installed_tree = self._create_tree(left_panel, ["Mod ID", "Name", "Enabled"], [0.30, 0.55, 0.15])
|
||||||
|
self.installed_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))
|
||||||
|
|
||||||
|
right_header = ttk.Label(right_panel, text="Scenario Required Mods", style="Header.TLabel")
|
||||||
|
right_header.pack(anchor="w", padx=8, pady=8)
|
||||||
|
self.required_tree = self._create_tree(right_panel, ["Mod ID", "Name", "Status"], [0.25, 0.60, 0.15])
|
||||||
|
self.required_tree.pack(fill=tk.BOTH, expand=True, padx=8, pady=(0, 8))
|
||||||
|
|
||||||
|
bottom = ttk.Frame(self, style="TFrame")
|
||||||
|
bottom.pack(fill=tk.X, padx=12, pady=(0, 12))
|
||||||
|
self.enable_btn = ttk.Button(bottom, text="Enable Matching Mods", command=self._on_enable)
|
||||||
|
self.enable_btn.pack(side=tk.LEFT)
|
||||||
|
self.help_btn = ttk.Button(bottom, text="Help", command=self._on_help)
|
||||||
|
self.help_btn.pack(side=tk.RIGHT)
|
||||||
|
|
||||||
|
def _create_tree(self, parent, columns: List[str], ratios: List[float]):
|
||||||
|
tree = ttk.Treeview(parent, columns=columns, show="headings", height=12)
|
||||||
|
for col in columns:
|
||||||
|
tree.heading(col, text=col)
|
||||||
|
tree.column(col, width=50, anchor=tk.W, stretch=True)
|
||||||
|
|
||||||
|
vsb = ttk.Scrollbar(parent, orient="vertical", command=tree.yview)
|
||||||
|
hsb = ttk.Scrollbar(parent, orient="horizontal", command=tree.xview)
|
||||||
|
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
|
||||||
|
|
||||||
|
tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||||
|
vsb.pack(side=tk.LEFT, fill=tk.Y)
|
||||||
|
hsb.pack(side=tk.BOTTOM, fill=tk.X)
|
||||||
|
|
||||||
|
self._bind_auto_columns(parent, tree, ratios)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
def _bind_auto_columns(self, container, tree: ttk.Treeview, ratios: List[float]) -> None:
|
||||||
|
ratios = [r / sum(ratios) for r in ratios]
|
||||||
|
def on_resize(event):
|
||||||
|
# Reserve some space for vertical scrollbar and paddings
|
||||||
|
total = max(event.width - 18, 100)
|
||||||
|
for col, ratio in zip(tree["columns"], ratios):
|
||||||
|
tree.column(col, width=int(total * ratio))
|
||||||
|
container.bind("<Configure>", on_resize, add="+")
|
||||||
|
|
||||||
|
def _load_installed_mods(self) -> None:
|
||||||
|
try:
|
||||||
|
self.installed_mods = read_installed_mods(self.ini_path)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
messagebox.showerror("SeaLoader", str(e))
|
||||||
|
self.installed_mods = {}
|
||||||
|
self._refresh_installed_list()
|
||||||
|
self._resolve_installed_names_async()
|
||||||
|
|
||||||
|
def _refresh_installed_list(self) -> None:
|
||||||
|
for row in self.installed_tree.get_children():
|
||||||
|
self.installed_tree.delete(row)
|
||||||
|
for mod in self.installed_mods.values():
|
||||||
|
status = "Yes" if mod.enabled else "No"
|
||||||
|
display_name = self.installed_names_map.get(mod.mod_id, "")
|
||||||
|
self.installed_tree.insert("", tk.END, values=(mod.mod_id, display_name, status))
|
||||||
|
|
||||||
|
def _resolve_installed_names_async(self) -> None:
|
||||||
|
ids = [mid for mid in self.installed_mods.keys() if mid.isdigit()]
|
||||||
|
ids_to_lookup = [mid for mid in ids if mid not in self.installed_names_map]
|
||||||
|
if not ids_to_lookup:
|
||||||
|
return
|
||||||
|
|
||||||
|
def task():
|
||||||
|
try:
|
||||||
|
names = resolve_workshop_names(ids_to_lookup)
|
||||||
|
except Exception:
|
||||||
|
names = {}
|
||||||
|
|
||||||
|
def update():
|
||||||
|
if names:
|
||||||
|
self.installed_names_map.update(names)
|
||||||
|
self._refresh_installed_list()
|
||||||
|
|
||||||
|
self.after(0, update)
|
||||||
|
|
||||||
|
threading.Thread(target=task, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_fetch(self) -> None:
|
||||||
|
url = self.url_var.get().strip()
|
||||||
|
if not url:
|
||||||
|
messagebox.showwarning("SeaLoader", "Please paste a Workshop URL.")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.fetch_btn.configure(state=tk.DISABLED)
|
||||||
|
self.enable_btn.configure(state=tk.DISABLED)
|
||||||
|
|
||||||
|
def task():
|
||||||
|
try:
|
||||||
|
ids = extract_required_item_ids(url)
|
||||||
|
# Reuse installed cache to reduce API calls
|
||||||
|
names_map = {i: self.installed_names_map[i] for i in ids if i in self.installed_names_map}
|
||||||
|
ids_to_lookup = [i for i in ids if i not in names_map]
|
||||||
|
if ids_to_lookup:
|
||||||
|
fetched = resolve_workshop_names(ids_to_lookup)
|
||||||
|
names_map.update(fetched)
|
||||||
|
except Exception as exc:
|
||||||
|
self.after(0, lambda: messagebox.showerror("SeaLoader", f"Failed to fetch IDs: {exc}"))
|
||||||
|
self.after(0, lambda: self.fetch_btn.configure(state=tk.NORMAL))
|
||||||
|
self.after(0, lambda: self.enable_btn.configure(state=tk.NORMAL))
|
||||||
|
return
|
||||||
|
|
||||||
|
def update():
|
||||||
|
self.required_ids = ids
|
||||||
|
self._refresh_required_list(names_map)
|
||||||
|
self.fetch_btn.configure(state=tk.NORMAL)
|
||||||
|
self.enable_btn.configure(state=tk.NORMAL)
|
||||||
|
|
||||||
|
self.after(0, update)
|
||||||
|
|
||||||
|
threading.Thread(target=task, daemon=True).start()
|
||||||
|
|
||||||
|
def _refresh_required_list(self, names_map: Dict[str, str] | None = None) -> None:
|
||||||
|
for row in self.required_tree.get_children():
|
||||||
|
self.required_tree.delete(row)
|
||||||
|
installed_ids = set(self.installed_mods.keys())
|
||||||
|
names_map = names_map or {}
|
||||||
|
for mod_id in self.required_ids:
|
||||||
|
status = "Installed" if mod_id in installed_ids else "Missing"
|
||||||
|
display_name = names_map.get(mod_id, "")
|
||||||
|
self.required_tree.insert("", tk.END, values=(mod_id, display_name, status))
|
||||||
|
|
||||||
|
def _on_enable(self) -> None:
|
||||||
|
if not self.required_ids:
|
||||||
|
messagebox.showinfo("SeaLoader", "No required mods loaded. Fetch from URL first.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
enabled_count, missing_count = enable_mods_in_ini(self.ini_path, self.required_ids)
|
||||||
|
except Exception as exc:
|
||||||
|
messagebox.showerror("SeaLoader", f"Failed to update INI: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Reload to reflect changes
|
||||||
|
self._load_installed_mods()
|
||||||
|
self._refresh_required_list()
|
||||||
|
|
||||||
|
msg_lines = [f"Enabled {enabled_count} mod(s)."]
|
||||||
|
if missing_count:
|
||||||
|
msg_lines.append(f"{missing_count} required mod(s) not found in usersettings.ini.")
|
||||||
|
else:
|
||||||
|
msg_lines.append("All required mods were found.")
|
||||||
|
messagebox.showinfo("SeaLoader", "\n".join(msg_lines))
|
||||||
|
|
||||||
|
def _on_help(self) -> None:
|
||||||
|
ini_hint = (
|
||||||
|
f"Default INI: {self.ini_path}\n\n"
|
||||||
|
)
|
||||||
|
text = (
|
||||||
|
"Usage:\n\n"
|
||||||
|
"1) Paste a Steam Workshop URL at the top and click 'Fetch Required Mods'.\n"
|
||||||
|
" - Right list shows required Mod IDs, names and whether they are installed.\n"
|
||||||
|
"2) Installed mods load automatically from usersettings.ini (left list).\n"
|
||||||
|
"3) Click 'Enable Matching Mods' to turn on any installed required mods.\n"
|
||||||
|
" - A .bak backup of usersettings.ini is created before changes.\n"
|
||||||
|
"4) Mods marked Missing must be installed separately in Sea Power.\n\n"
|
||||||
|
"Tips:\n"
|
||||||
|
"- Run with a custom INI path: python sealoader_gui.py \"<path-to-usersettings.ini>\"\n"
|
||||||
|
"- Press F1 to open this help.\n\n"
|
||||||
|
+ ini_hint
|
||||||
|
)
|
||||||
|
messagebox.showinfo("SeaLoader Help", text)
|
||||||
|
|
||||||
|
def _resource_path(self, name: str) -> Path:
|
||||||
|
try:
|
||||||
|
base = getattr(sys, "_MEIPASS", None)
|
||||||
|
if base:
|
||||||
|
return Path(base) / name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return Path(__file__).resolve().parent / name
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
ini_path = DEFAULT_INI_PATH
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
ini_path = sys.argv[1]
|
||||||
|
app = SeaLoaderApp(ini_path)
|
||||||
|
app.mainloop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
234
steam_required_ids.py
Normal file
234
steam_required_ids.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import Iterable, List, Set, Dict
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
|
||||||
|
WORKSHOP_ITEM_ID_REGEX = re.compile(r"id=(\d+)")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_id_from_href(href: str) -> str | None:
|
||||||
|
if not href:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Accept absolute or relative Steam workshop/sharedfiles links
|
||||||
|
if "filedetails" not in href or "id=" not in href:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(href)
|
||||||
|
# Handle relative URLs like "/sharedfiles/filedetails/?id=123"
|
||||||
|
query = parsed.query or href.split("?", 1)[1] if "?" in href else ""
|
||||||
|
qs = parse_qs(query)
|
||||||
|
if "id" in qs and qs["id"]:
|
||||||
|
candidate = qs["id"][0]
|
||||||
|
return candidate if candidate.isdigit() else None
|
||||||
|
except Exception:
|
||||||
|
match = WORKSHOP_ITEM_ID_REGEX.search(href)
|
||||||
|
if match:
|
||||||
|
return match.group(1)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_main_item_id(url: str) -> str | None:
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
if "id" in qs and qs["id"]:
|
||||||
|
candidate = qs["id"][0]
|
||||||
|
return candidate if candidate.isdigit() else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def collect_ids_from_elements(elements: Iterable) -> Set[str]:
|
||||||
|
ids: Set[str] = set()
|
||||||
|
for el in elements:
|
||||||
|
href = getattr(el, "get", None)
|
||||||
|
if callable(href):
|
||||||
|
link = el.get("href", "")
|
||||||
|
else:
|
||||||
|
link = ""
|
||||||
|
item_id = extract_id_from_href(link)
|
||||||
|
if item_id:
|
||||||
|
ids.add(item_id)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def extract_required_item_ids_from_html(html: str) -> Set[str]:
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
|
||||||
|
# Strategy 1: Look for a section headed "Required items" and parse links within
|
||||||
|
section_ids: Set[str] = set()
|
||||||
|
heading_candidates = soup.find_all(string=re.compile(r"^\s*Required\s+items\s*$", re.IGNORECASE))
|
||||||
|
for heading in heading_candidates:
|
||||||
|
parent = heading.parent
|
||||||
|
if parent is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Search within nearby container siblings/descendants for links
|
||||||
|
container = parent
|
||||||
|
for _ in range(3): # climb up a few levels to catch the full block
|
||||||
|
if container is None:
|
||||||
|
break
|
||||||
|
links = container.find_all("a", href=True)
|
||||||
|
section_ids |= collect_ids_from_elements(links)
|
||||||
|
container = container.parent
|
||||||
|
|
||||||
|
if section_ids:
|
||||||
|
return section_ids
|
||||||
|
|
||||||
|
# Strategy 2: Look for any block that contains the sentence used by Steam
|
||||||
|
hint_blocks = soup.find_all(string=re.compile(r"requires\s+all\s+of\s+the\s+following\s+other\s+items", re.IGNORECASE))
|
||||||
|
for hint in hint_blocks:
|
||||||
|
container = hint.parent
|
||||||
|
for _ in range(3):
|
||||||
|
if container is None:
|
||||||
|
break
|
||||||
|
links = container.find_all("a", href=True)
|
||||||
|
section_ids |= collect_ids_from_elements(links)
|
||||||
|
container = container.parent
|
||||||
|
|
||||||
|
if section_ids:
|
||||||
|
return section_ids
|
||||||
|
|
||||||
|
# Strategy 3 (fallback): scan all anchors on the page
|
||||||
|
all_links = soup.find_all("a", href=True)
|
||||||
|
return collect_ids_from_elements(all_links)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_page(url: str, timeout: int = 20) -> str:
|
||||||
|
headers = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/126.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
"Accept-Language": "en-US,en;q=0.9",
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||||
|
}
|
||||||
|
# Steam can occasionally require a cookie for age gates. Provide innocuous defaults.
|
||||||
|
cookies = {
|
||||||
|
"birthtime": "568022401", # 1987-12-20
|
||||||
|
"lastagecheckage": "1-January-1990",
|
||||||
|
"mature_content": "1",
|
||||||
|
}
|
||||||
|
resp = requests.get(url, headers=headers, cookies=cookies, timeout=timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.text
|
||||||
|
|
||||||
|
|
||||||
|
def extract_required_item_ids(url: str) -> List[str]:
|
||||||
|
html = fetch_page(url)
|
||||||
|
found_ids = extract_required_item_ids_from_html(html)
|
||||||
|
|
||||||
|
# Remove the current page's ID if present
|
||||||
|
current_id = parse_main_item_id(url)
|
||||||
|
if current_id and current_id in found_ids:
|
||||||
|
found_ids.remove(current_id)
|
||||||
|
|
||||||
|
return sorted(found_ids, key=int)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_workshop_names(ids: List[str], timeout: int = 20) -> Dict[str, str]:
|
||||||
|
"""Resolve Workshop IDs to human-readable titles using Steam API, with HTML fallback.
|
||||||
|
|
||||||
|
Uses ISteamRemoteStorage.GetPublishedFileDetails, batching up to 100 IDs per call.
|
||||||
|
Falls back to scraping each item's page if the API fails.
|
||||||
|
"""
|
||||||
|
id_list = [i for i in dict.fromkeys([i for i in ids if i and i.isdigit()])]
|
||||||
|
if not id_list:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/126.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
api_url = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
||||||
|
resolved: Dict[str, str] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
session = requests.Session()
|
||||||
|
session.headers.update(headers)
|
||||||
|
batch_size = 100
|
||||||
|
for start in range(0, len(id_list), batch_size):
|
||||||
|
batch = id_list[start:start + batch_size]
|
||||||
|
data = {"itemcount": len(batch)}
|
||||||
|
for idx, pub_id in enumerate(batch):
|
||||||
|
data[f"publishedfileids[{idx}]"] = pub_id
|
||||||
|
resp = session.post(api_url, data=data, timeout=timeout)
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
details = payload.get("response", {}).get("publishedfiledetails", [])
|
||||||
|
for entry in details:
|
||||||
|
if entry.get("result") == 1:
|
||||||
|
title = entry.get("title")
|
||||||
|
pub_id = str(entry.get("publishedfileid"))
|
||||||
|
if pub_id and title:
|
||||||
|
resolved[pub_id] = title
|
||||||
|
except Exception:
|
||||||
|
# API failure; fall back to HTML scraping below
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback for unresolved IDs: scrape the item page
|
||||||
|
unresolved = [i for i in id_list if i not in resolved]
|
||||||
|
for pub_id in unresolved:
|
||||||
|
try:
|
||||||
|
page_url = f"https://steamcommunity.com/sharedfiles/filedetails/?id={pub_id}"
|
||||||
|
html = fetch_page(page_url, timeout=timeout)
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
name = None
|
||||||
|
og = soup.find("meta", attrs={"property": "og:title"})
|
||||||
|
if og and og.get("content"):
|
||||||
|
name = og.get("content").strip()
|
||||||
|
if not name:
|
||||||
|
title_div = soup.find("div", class_="workshopItemTitle")
|
||||||
|
if title_div and title_div.text:
|
||||||
|
name = title_div.text.strip()
|
||||||
|
if name:
|
||||||
|
resolved[pub_id] = name
|
||||||
|
except Exception:
|
||||||
|
# Leave unresolved if both methods fail
|
||||||
|
pass
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Extract Steam Workshop 'Required items' IDs from a Workshop item page")
|
||||||
|
parser.add_argument("url", help="Steam Workshop item URL (e.g., https://steamcommunity.com/sharedfiles/filedetails/?id=XXXXXXXX)")
|
||||||
|
parser.add_argument("--json", action="store_true", help="Print JSON array instead of plain text")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
ids = extract_required_item_ids(args.url)
|
||||||
|
except requests.HTTPError as http_err:
|
||||||
|
print(f"HTTP error: {http_err}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to extract IDs: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(ids))
|
||||||
|
else:
|
||||||
|
if not ids:
|
||||||
|
print("No required item IDs found.")
|
||||||
|
else:
|
||||||
|
print("\n".join(ids))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user