diff-based mod removal via --previous-manifest
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.
This commit is contained in:
+98
-2
@@ -85,6 +85,40 @@ _DIR_TO_TYPE = {
|
||||
"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.
|
||||
@@ -124,6 +158,39 @@ class ConvertResult:
|
||||
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(
|
||||
@@ -131,6 +198,7 @@ def convert(
|
||||
*,
|
||||
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)
|
||||
@@ -166,7 +234,7 @@ def convert(
|
||||
|
||||
sync.append(_make_entry(meta, content_type))
|
||||
|
||||
manifest = {
|
||||
manifest: dict[str, Any] = {
|
||||
"sync_version": 3,
|
||||
"_generator": {
|
||||
"tool": "packwiz-to-sms",
|
||||
@@ -176,12 +244,21 @@ def convert(
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
@@ -231,6 +308,12 @@ def _build_arg_parser() -> argparse.ArgumentParser:
|
||||
"-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.",
|
||||
@@ -254,7 +337,18 @@ def main(argv: list[str] | None = None) -> int:
|
||||
print(f"error: {args.pack_root} does not contain pack.toml", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
result = convert(args.pack_root)
|
||||
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:
|
||||
@@ -277,6 +371,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user