438b1f7c65
Adds removal-detection: when --previous-manifest <path> is given, the converter diffs the previous publish against current packwiz state and emits modify[].type=remove entries for mods/resourcepacks/etc that disappeared, using simple-mod-sync's on-disk naming convention as the regex pattern. Reverse-engineered from upstream source: - simple-mod-sync writes <sanitized_name>-<sanitized_version>.<ext> - StringUtils.sanitize strips [^a-zA-Z0-9.\-_] - GetOlderVersion() finds files starting with <name>- and auto-deletes on version bumps. So version upgrades need no converter handling; only full removals do. 8 new tests including end-to-end CLI verification with a synthetic previous manifest. 23/23 pass.
388 lines
13 KiB
Python
388 lines
13 KiB
Python
#!/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 <base-url>/<bundle-name> 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 <sanitized_name>-<sanitized_version>.<ext>
|
|
# under <type>'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())
|