#!/usr/bin/env python3 """Convert a packwiz pack into a simple-mod-sync manifest. Reads a packwiz pack rooted at PACK_DIR and emits a simple-mod-sync sync_version=3 manifest to stdout (or --output). Mods, resourcepacks, shaders and datapacks are emitted with their direct download URL pulled from each per-file packwiz metafile (.pw.toml). Server-only mods are dropped. Client-only and both-side mods are kept. CurseForge mods that lack a direct download URL (mode=metadata:curseforge) are skipped with a warning; the operator can either swap to Modrinth or pre-resolve URLs with `packwiz cf reexport`. Arbitrary files (config/, options.txt, servers.dat, ...) are not emitted by default since simple-mod-sync's `packed`/`config` types expect a zip. Pass --bundle-non-mods PATH to write a zip of all non-metafile files; a single `packed` entry pointing at / will be appended to the manifest. Operator is responsible for hosting that zip. """ from __future__ import annotations import argparse import json import re import sys import tomllib import zipfile from dataclasses import asdict, dataclass from pathlib import Path from typing import Any # --- packwiz model -------------------------------------------------------- @dataclass(frozen=True) class PackwizMetafile: """One mods/foo.pw.toml file (mod or resourcepack/shader/datapack).""" path: Path # e.g. "mods/sodium.pw.toml" name: str filename: str side: str # "client", "server", "both" url: str | None # None when packwiz can't resolve (CF metadata mode) hash_value: str | None hash_format: str | None def _load_pack_metafile(pack_root: Path, rel_path: str) -> PackwizMetafile: abs_path = pack_root / rel_path data = tomllib.loads(abs_path.read_text(encoding="utf-8")) download = data.get("download", {}) mode = download.get("mode", "url") url = download.get("url") if mode in ("url", "") else None return PackwizMetafile( path=Path(rel_path), name=data.get("name", abs_path.stem), filename=data.get("filename", ""), side=data.get("side", "both"), url=url, hash_value=download.get("hash"), hash_format=download.get("hash-format"), ) def _load_index(pack_root: Path) -> dict[str, Any]: return tomllib.loads((pack_root / "index.toml").read_text(encoding="utf-8")) def _load_pack(pack_root: Path) -> dict[str, Any]: return tomllib.loads((pack_root / "pack.toml").read_text(encoding="utf-8")) # --- simple-mod-sync model ------------------------------------------------ _DIR_TO_TYPE = { "mods": "mod", "resourcepacks": "resourcepack", "shaderpacks": "shader", } # simple-mod-sync writes downloaded files as -. # under 's target directory. See StringUtils.sanitize() in the mod source: # replaceAll("[^a-zA-Z0-9.\\-_]", "") _SANITIZE_RE = re.compile(r"[^a-zA-Z0-9.\-_]") _TYPE_TO_DIR_AND_EXT = { "mod": ("mods", "jar"), "resourcepack": ("resourcepacks", "zip"), "shader": ("shaderpacks", "zip"), "datapack": ("datapacks", "zip"), } def _sanitize(s: str) -> str: return _SANITIZE_RE.sub("", s) def _remove_pattern_for(entry: dict[str, Any]) -> str | None: """Build a regex that matches any on-disk file simple-mod-sync would have written for this content entry (current or older versions). Mirrors simple-mod-sync's `GetProjectName(): name + "-"` prefix search in `DirUtils.DirContains` (which uses startswith).""" ctype = entry.get("type") spec = _TYPE_TO_DIR_AND_EXT.get(ctype) if spec is None: return None target_dir, ext = spec safe_name = _sanitize(entry.get("name", "")) if not safe_name: return None # Anchor whole path, match any version suffix + the extension. return rf"^{re.escape(target_dir)}/{re.escape(safe_name)}-.*\.{ext}$" def _content_type_for(rel_path: Path) -> str | None: """Map mods/foo.pw.toml -> "mod", resourcepacks/foo.pw.toml -> "resourcepack" etc. `datapack` is intentionally not auto-mapped: packwiz puts datapacks under a world directory which varies. Operator should set type manually or place datapacks in a `datapacks/` top-level dir (uncommon). """ if rel_path.parent.name == "datapacks": return "datapack" return _DIR_TO_TYPE.get(rel_path.parts[0]) if rel_path.parts else None def _make_entry(meta: PackwizMetafile, content_type: str) -> dict[str, Any]: version = meta.filename or "unknown" # packwiz filenames carry the version (e.g. sodium-0.6.5+1.21.1.jar). # If we can extract a cleaner version, do it; otherwise keep the filename. if meta.filename: m = re.search(r"[-_]([\w.+]+?)\.(?:jar|zip)$", meta.filename) if m: version = m.group(1) return { "url": meta.url, "name": meta.name, "version": version, "type": content_type, } # --- conversion driver ---------------------------------------------------- @dataclass class ConvertResult: manifest: dict[str, Any] skipped_server_only: list[str] skipped_no_url: list[str] skipped_unknown_type: list[str] non_mod_files: list[Path] removed_entries: list[dict[str, Any]] = None # populated when --previous-manifest given def __post_init__(self) -> None: if self.removed_entries is None: self.removed_entries = [] def diff_removals( previous_sync: list[dict[str, Any]], current_sync: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Return simple-mod-sync `modify` entries that delete files for content present in `previous_sync` but absent from `current_sync`. Identity is `(name, type)` — covers the common case (rename a mod → treated as remove+add, which is the right behavior because the old file's prefix no longer matches anything in the sync list). """ cur_keys = {(e.get("name"), e.get("type")) for e in current_sync} removals: list[dict[str, Any]] = [] for prev in previous_sync: key = (prev.get("name"), prev.get("type")) if key in cur_keys: continue pattern = _remove_pattern_for(prev) if pattern is None: continue removals.append({ "type": "remove", "pattern": pattern, "path": ".", }) return removals def convert( pack_root: Path, *, include_client_only: bool = True, include_both: bool = True, previous_manifest: dict[str, Any] | None = None, ) -> ConvertResult: pack = _load_pack(pack_root) index = _load_index(pack_root) sync: list[dict[str, Any]] = [] skipped_server_only: list[str] = [] skipped_no_url: list[str] = [] skipped_unknown_type: list[str] = [] non_mod_files: list[Path] = [] for f in index.get("files", []): rel_path = Path(f["file"]) if not f.get("metafile"): non_mod_files.append(rel_path) continue meta = _load_pack_metafile(pack_root, f["file"]) if meta.side == "server": skipped_server_only.append(meta.name) continue if meta.side == "client" and not include_client_only: continue if meta.side == "both" and not include_both: continue content_type = _content_type_for(meta.path) if content_type is None: skipped_unknown_type.append(str(meta.path)) continue if meta.url is None: skipped_no_url.append(meta.name) continue sync.append(_make_entry(meta, content_type)) manifest: dict[str, Any] = { "sync_version": 3, "_generator": { "tool": "packwiz-to-sms", "pack_name": pack.get("name", ""), "pack_version": pack.get("version", ""), }, "sync": sync, } removed_entries: list[dict[str, Any]] = [] if previous_manifest is not None: prev_sync = previous_manifest.get("sync", []) modify = diff_removals(prev_sync, sync) if modify: manifest["modify"] = modify removed_entries = modify return ConvertResult( manifest=manifest, skipped_server_only=skipped_server_only, skipped_no_url=skipped_no_url, skipped_unknown_type=skipped_unknown_type, non_mod_files=non_mod_files, removed_entries=removed_entries, ) def bundle_non_mods( pack_root: Path, non_mod_files: list[Path], out_zip: Path, ) -> None: """Zip non-mod files (config/, options.txt, etc.) into one archive suitable for shipping as a simple-mod-sync `packed` entry with `directory: "."`.""" out_zip.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(out_zip, "w", zipfile.ZIP_DEFLATED) as zf: for rel in non_mod_files: abs_path = pack_root / rel if not abs_path.is_file(): continue zf.write(abs_path, arcname=str(rel)) def add_bundle_entry( manifest: dict[str, Any], bundle_url: str, bundle_version: str, ) -> None: manifest["sync"].append( { "url": bundle_url, "name": "Pack overrides", "version": bundle_version, "type": "packed", "directory": ".", } ) # --- CLI ------------------------------------------------------------------- def _build_arg_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser( prog="packwiz-to-sms", description="Convert a packwiz pack to a simple-mod-sync manifest.", ) p.add_argument("pack_root", type=Path, help="Path to packwiz pack root (contains pack.toml)") p.add_argument( "-o", "--output", type=Path, default=None, help="Write manifest to this file (default stdout).", ) p.add_argument( "--previous-manifest", type=Path, default=None, metavar="JSON_PATH", help="Path to the previously-published manifest. Names that disappeared " "between previous and current are emitted as modify[].type=remove entries " "matching simple-mod-sync's on-disk naming, so clients auto-clean removed mods.", ) p.add_argument( "--bundle-non-mods", type=Path, default=None, metavar="ZIP_PATH", help="Zip all non-mod files into this zip and add a 'packed' entry pointing at --bundle-url.", ) p.add_argument( "--bundle-url", type=str, default=None, help="URL where the non-mod bundle will be hosted. Required with --bundle-non-mods.", ) p.add_argument( "--include-server-only", action="store_true", help="Include mods marked side=server (off by default; only useful for mod-server distribution).", ) p.add_argument("--quiet", action="store_true", help="Suppress skip warnings on stderr.") return p def main(argv: list[str] | None = None) -> int: args = _build_arg_parser().parse_args(argv) if not (args.pack_root / "pack.toml").is_file(): print(f"error: {args.pack_root} does not contain pack.toml", file=sys.stderr) return 2 previous_manifest = None if args.previous_manifest: if not args.previous_manifest.is_file(): print( f"warning: --previous-manifest {args.previous_manifest} does not exist, " f"skipping removal detection", file=sys.stderr, ) else: previous_manifest = json.loads(args.previous_manifest.read_text(encoding="utf-8")) result = convert(args.pack_root, previous_manifest=previous_manifest) if args.bundle_non_mods: if not args.bundle_url: print("error: --bundle-non-mods requires --bundle-url", file=sys.stderr) return 2 bundle_non_mods(args.pack_root, result.non_mod_files, args.bundle_non_mods) pack = _load_pack(args.pack_root) add_bundle_entry(result.manifest, args.bundle_url, pack.get("version", "1")) if not args.quiet: for n in result.skipped_server_only: print(f"skip (server-only): {n}", file=sys.stderr) for n in result.skipped_no_url: print(f"skip (no resolvable url, likely CurseForge metadata mode): {n}", file=sys.stderr) for n in result.skipped_unknown_type: print(f"skip (unknown content type for path): {n}", file=sys.stderr) if result.non_mod_files and not args.bundle_non_mods: print( f"note: {len(result.non_mod_files)} non-mod file(s) in pack " f"(config/, options.txt, ...) — pass --bundle-non-mods to ship them", file=sys.stderr, ) for r in result.removed_entries: print(f"remove: {r['pattern']}", file=sys.stderr) payload = json.dumps(result.manifest, indent=2) + "\n" if args.output: args.output.write_text(payload, encoding="utf-8") else: sys.stdout.write(payload) return 0 if __name__ == "__main__": sys.exit(main())