#!/usr/bin/env python3 """Convert a raw ModrinthApp profile mods/ folder into packwiz .pw.toml metadata. All mods are Modrinth-only. For each jar: 1. read fabric.mod.json -> modid + side (client/both/server) 2. locate the Modrinth project (search by modid, fall back to filename) 3. find the exact version whose primary file matches the installed jar 4. write client_pack and/or server_pack mods/.pw.toml with correct side Usage: python3 tools/convert_pack.py # then run packwiz refresh in each pack """ import glob import hashlib import json import os import re import sys import time import urllib.request import urllib.parse import zipfile API = "https://api.modrinth.com/v2" def fetch(path): req = urllib.request.Request(API + path, headers={"User-Agent": "F##KRPG-packwiz-converter/0.1"}) with urllib.request.urlopen(req, timeout=30) as r: return json.loads(r.read()) def read_fabric_mod_json(jar): with zipfile.ZipFile(jar) as z: name = "fabric.mod.json" if name not in z.namelist(): cand = [n for n in z.namelist() if n.endswith("fabric.mod.json")] if not cand: return None name = cand[0] raw = z.read(name) # tolerate control characters in JSON (some mods emit them) cleaned = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", " ", raw.decode("utf-8", "replace")) return json.loads(cleaned) def detect_side(d): env = d.get("environment", "*") if isinstance(env, dict): client = env.get("client", "*") server = env.get("server", "*") # client=* && server=* -> both; client=* only-> both; else client-only heuristic if client == "*" and server == "*": return "both" if client == "*": return "both" if client == "client": return "client" if server == "server" and client in (None, "client"): return "server" return "both" return {"client": "client", "*": "both", "server": "server"}.get(env, "both") def find_project(modid, filename): # Gather candidate hits from a couple of search terms, then verify by # matching the installed jar filename against each candidate's versions. terms = [modid] if filename and filename != modid: stem = os.path.splitext(filename)[0] stem = re.sub(r"[-_](fabric|neoforge|forge|mc1?\.?\d.*|v?\d[\w.\-+]*)$", "", stem, flags=re.I) stem = re.sub(r"[+@].*$", "", stem) stem = re.sub(r"^\[Fabric\]", "", stem).strip() terms.append(stem) candidates = [] seen = set() for term in terms: if not term: continue try: d = fetch("/search?query=%s&limit=10" % urllib.parse.quote(term)) except Exception: continue for h in d.get("hits", []): pid = h["project_id"] if pid in seen: continue seen.add(pid) candidates.append((pid, h["slug"])) if not candidates: return None, None # 1) exact slug == modid for pid, slug in candidates: if slug.lower() == modid.lower(): return pid, slug # 2) any candidate whose versions contain the exact installed filename if filename: for pid, slug in candidates: try: vers = fetch("/project/%s/version" % pid) except Exception: continue for v in vers: for f in v.get("files", []): if f.get("filename") == filename: return pid, slug # 3) fall back to first candidate return candidates[0][0], candidates[0][1] def find_version(project_id, filename, side): try: versions = fetch("/project/%s/version" % project_id) except Exception as e: return None, None, str(e) # candidate versions supporting 1.21.1 + fabric, newest first (API returns newest first) cands = [] for v in versions: gv = v.get("game_versions", []) ld = v.get("loaders", []) if "1.21.1" not in gv and "1.21" not in gv: continue if "fabric" not in ld and "quilt" not in ld: continue for f in v.get("files", []): if f.get("filename") == filename: return v["id"], f, None # exact installed match wins cands.append(v) # otherwise pick the latest compatible version's primary file for v in cands: for f in v.get("files", []): if f.get("primary") or f.get("filename", "").lower().endswith(".jar"): return v["id"], f, None return None, None, "no matching 1.21.1 fabric version found" def make_pw_toml(modid, slug, version_id, file_info, side): sha512 = file_info["hashes"].get("sha512", "") if not sha512: return None url = file_info["url"] filename = file_info["filename"] lines = [ 'name = "%s"' % modid.title(), 'filename = "%s"' % filename, 'side = "%s"' % side, "", "[download]", 'url = "%s"' % url, 'hash-format = "sha512"', 'hash = "%s"' % sha512, "", "[update]", "[update.modrinth]", 'mod-id = "%s"' % slug, 'version = "%s"' % version_id, "", ] return "\n".join(lines) def main(): src = sys.argv[1] base = os.path.dirname(os.path.abspath(__file__)) repo = os.path.dirname(base) client_mods = os.path.join(repo, "client_pack", "mods") server_mods = os.path.join(repo, "server_pack", "mods") os.makedirs(client_mods, exist_ok=True) os.makedirs(server_mods, exist_ok=True) failures = [] counts = {"client": 0, "both": 0, "server": 0} files = sorted(glob.glob(os.path.join(src, "*.jar"))) print("Converting %d mods..." % len(files)) for jar in files: fn = os.path.basename(jar) try: d = read_fabric_mod_json(jar) except Exception as e: failures.append((fn, "read-error: %s" % e)) continue if not d: failures.append((fn, "no fabric.mod.json")) continue modid = d.get("id", fn) side = detect_side(d) project_id, slug = find_project(modid, fn) if not project_id: failures.append((fn, "modrinth project not found (modid=%s)" % modid)) continue version_id, file_info, err = find_version(project_id, fn, side) if not file_info: failures.append((fn, "version not found (slug=%s): %s" % (slug, err))) continue tom = make_pw_toml(modid, slug, version_id, file_info, side) if not tom: failures.append((fn, "no sha512 in matched file")) continue # write to appropriate pack(s) targets = [] if side in ("client", "both"): targets.append(client_mods) if side in ("server", "both"): targets.append(server_mods) for t in targets: with open(os.path.join(t, slug + ".pw.toml"), "w") as fh: fh.write(tom) counts[side] += 1 time.sleep(0.2) print(" ok %-30s -> %s (side=%s)" % (fn[:30], slug, side)) print("\n=== SUMMARY ===") print("counts:", counts) print("failures:", len(failures)) for fn, err in failures: print(" FAIL %-40s %s" % (fn, err)) if __name__ == "__main__": main()