From e706e71f29ac87ddc9e8ac6efbfa76f4b1ef9f83 Mon Sep 17 00:00:00 2001 From: revernomad17 Date: Tue, 7 Apr 2026 16:56:10 +0700 Subject: [PATCH] add check_names.py: detect and fix mod folder name mismatches Compares @Mod folder names in downloads/ against server canonical names. Uses meta.cpp steam_id as primary lookup (most reliable), falls back to normalized name. Reports OK / MISMATCH / NOT_ON_SERVER per folder. --fix mode renames mismatched folders and updates arma_dir junctions in one pass so the full pipeline can run cleanly afterward. Co-Authored-By: Claude Sonnet 4.6 --- check_names.py | 237 +++++++++++++++++++++++++++++++++++++++++++++++++ test_suite.py | 88 +++++++++++++++++- 2 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 check_names.py diff --git a/check_names.py b/check_names.py new file mode 100644 index 0000000..b780640 --- /dev/null +++ b/check_names.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +""" +Diagnostic: compare mod folder names on disk against the server's canonical +names, and optionally rename mismatched folders + fix broken junctions. + +Matching strategy (most-reliable first): + 1. Read meta.cpp from the local folder -> steam_id -> server by_steam_id + 2. Normalize folder name (@CBA_A3 -> cbaa3) -> server by_name + +Status codes: + OK disk name matches server name exactly + MISMATCH disk name differs from server canonical name + NOT_ON_SERVER server has no folder for this mod (new mod? typo?) + +Usage: + python check_names.py # report only + python check_names.py --fix # rename + fix junctions + python check_names.py --group shared --fix +""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path + +from arma_modlist_tools.compat import fix_console_encoding +from arma_modlist_tools.config import load_config +from arma_modlist_tools.fetcher import ( + _normalize_name, _parse_meta_cpp, + build_server_index, make_session, +) +from arma_modlist_tools.linker import _is_junction, create_junction, remove_junction + +fix_console_encoding() + +# Column widths +W_DISK = 44 +W_SERVER = 44 +W_GROUP = 24 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _server_name_from_url(url: str) -> str: + """Extract the folder name from a trailing-slash server URL.""" + return url.rstrip("/").split("/")[-1] + + +def _read_local_steam_id(mod_dir: Path) -> str | None: + """Try to read publishedid from meta.cpp inside a local mod folder.""" + for candidate in (mod_dir / "meta.cpp", mod_dir / "Meta.cpp"): + if candidate.exists(): + try: + return _parse_meta_cpp(candidate.read_text(encoding="utf-8", errors="ignore")) + except OSError: + pass + return None + + +def _lookup_server_name(local_name: str, mod_dir: Path, index: dict) -> str | None: + """ + Return the server's canonical folder name for this local mod, or None. + Tries steam_id first, then normalized name. + """ + # 1. Steam ID from local meta.cpp (most reliable) + steam_id = _read_local_steam_id(mod_dir) + if steam_id: + url = index["by_steam_id"].get(steam_id) + if url: + return _server_name_from_url(url) + + # 2. Normalized name fallback + url = index["by_name"].get(_normalize_name(local_name)) + if url: + return _server_name_from_url(url) + + return None + + +def _collect_mod_folders(cfg, group_filter: str | None) -> list[tuple[str, Path]]: + """Return list of (group_name, mod_path) for every @Mod in downloads/.""" + result = [] + if not cfg.downloads.is_dir(): + return result + for group_dir in sorted(cfg.downloads.iterdir()): + if not group_dir.is_dir(): + continue + if group_filter and group_dir.name != group_filter: + continue + for mod_dir in sorted(group_dir.iterdir()): + if mod_dir.is_dir() and mod_dir.name.startswith("@"): + result.append((group_dir.name, mod_dir)) + return result + + +# --------------------------------------------------------------------------- +# Fix: rename folder + update junction +# --------------------------------------------------------------------------- + +def _fix_mismatch( + group: str, + mod_dir: Path, + server_name: str, + cfg, +) -> tuple[bool, str]: + """ + Rename mod_dir to server_name and update the arma_dir junction. + + Returns (success, message). + """ + new_dir = mod_dir.parent / server_name + + if new_dir.exists(): + return False, f"target already exists: {new_dir}" + + # --- Remove old junction in arma_dir (if present) --- + old_link = cfg.arma_dir / mod_dir.name + removed_old_link = False + if _is_junction(old_link): + ok, err = remove_junction(old_link) + if not ok: + return False, f"could not remove old junction {old_link}: {err}" + removed_old_link = True + + # --- Rename the download folder --- + try: + mod_dir.rename(new_dir) + except OSError as exc: + # Try to restore the junction we just removed + if removed_old_link: + create_junction(old_link, mod_dir) + return False, f"rename failed: {exc}" + + # --- Create new junction in arma_dir --- + new_link = cfg.arma_dir / server_name + if cfg.arma_dir.exists(): + if _is_junction(new_link) or new_link.exists(): + pass # already correct or non-junction exists; leave it + else: + create_junction(new_link, new_dir) + + return True, f"{mod_dir.name} -> {server_name}" + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + cfg = load_config() + + parser = argparse.ArgumentParser( + description="Check and optionally fix mod folder name mismatches." + ) + parser.add_argument("--group", "-g", metavar="GROUP", + help="Only check this group (default: all)") + parser.add_argument("--fix", action="store_true", + help="Rename mismatched folders and fix junctions") + args = parser.parse_args() + + # ---- Build server index ---- + print(f"\nBuilding server index...") + index = build_server_index(cfg.server_url, cfg.server_auth) + print(f" {len(index['by_steam_id'])} mods indexed by steam_id, " + f"{len(index['by_name'])} by name\n") + + # ---- Collect local mod folders ---- + mods = _collect_mod_folders(cfg, args.group) + if not mods: + print("No @Mod folders found in downloads/.\n") + sys.exit(0) + + # ---- Classify each mod ---- + ok_count = mismatch_count = unknown_count = 0 + mismatches: list[tuple[str, Path, str]] = [] # (group, mod_dir, server_name) + + print(f" {'Disk name':<{W_DISK}} {'Group':<{W_GROUP}} {'Server name':<{W_SERVER}} Status") + print(f" {'-'*W_DISK} {'-'*W_GROUP} {'-'*W_SERVER} ------") + + for group, mod_dir in mods: + disk_name = mod_dir.name + server_name = _lookup_server_name(disk_name, mod_dir, index) + + if server_name is None: + status = "NOT_ON_SERVER" + unknown_count += 1 + server_col = "---" + elif server_name == disk_name: + status = "OK" + ok_count += 1 + server_col = server_name + else: + status = "MISMATCH" + mismatch_count += 1 + server_col = server_name + mismatches.append((group, mod_dir, server_name)) + + print(f" {disk_name:<{W_DISK}} {group:<{W_GROUP}} {server_col:<{W_SERVER}} {status}") + + print(f"\n {ok_count} OK, {mismatch_count} mismatch, {unknown_count} not on server\n") + + if not mismatches: + if mismatch_count == 0: + print(" All folder names match the server. Safe to run full pipeline.\n") + sys.exit(0) + + # ---- Fix mode ---- + if not args.fix: + print(" Run with --fix to rename mismatched folders and update junctions.\n") + sys.exit(0) + + print(f"Fixing {len(mismatches)} mismatch(es)...\n") + fixed = failed = 0 + + for group, mod_dir, server_name in mismatches: + ok, msg = _fix_mismatch(group, mod_dir, server_name, cfg) + if ok: + print(f" [+] {msg}") + fixed += 1 + else: + print(f" [X] {mod_dir.name} FAILED: {msg}") + failed += 1 + + print(f"\n Done: {fixed} renamed" + + (f", {failed} failed" if failed else "") + + "\n") + + if fixed: + print(" Run 'python run.py --skip-fetch' to verify links are correct.\n") + + +if __name__ == "__main__": + main() diff --git a/test_suite.py b/test_suite.py index 4402fc5..889ff2a 100644 --- a/test_suite.py +++ b/test_suite.py @@ -1059,7 +1059,93 @@ test("all exported symbols are callable or types", _test_exports_are_callable) # --------------------------------------------------------------------------- -# 9. Integration: parse → compare → reporter (offline) +# 9. check_names helpers +# --------------------------------------------------------------------------- + +group("check_names helpers") + +from check_names import _server_name_from_url, _read_local_steam_id, _lookup_server_name + + +def _test_server_name_from_url(): + assert_eq(_server_name_from_url("https://x.com/mods/@ace/"), "@ace") + assert_eq(_server_name_from_url("https://x.com/mods/@cba_a3"), "@cba_a3") + + +def _test_read_local_steam_id_present(): + with tempfile.TemporaryDirectory() as d: + d = Path(d) + (d / "meta.cpp").write_text('publishedid = 463939057;\nname = "ACE3";', encoding="utf-8") + assert_eq(_read_local_steam_id(d), "463939057") + + +def _test_read_local_steam_id_missing(): + with tempfile.TemporaryDirectory() as d: + assert _read_local_steam_id(Path(d)) is None + + +def _test_read_local_steam_id_no_id_in_file(): + with tempfile.TemporaryDirectory() as d: + d = Path(d) + (d / "meta.cpp").write_text('name = "LocalMod";\n', encoding="utf-8") + assert _read_local_steam_id(d) is None + + +def _test_lookup_server_name_by_steam_id(): + index = { + "by_steam_id": {"463939057": "https://x.com/@ace3/"}, + "by_name": {}, + } + with tempfile.TemporaryDirectory() as d: + d = Path(d) + (d / "meta.cpp").write_text("publishedid = 463939057;", encoding="utf-8") + result = _lookup_server_name("@ACE3", d, index) + assert_eq(result, "@ace3") + + +def _test_lookup_server_name_by_name_fallback(): + index = { + "by_steam_id": {}, + "by_name": {"cbaa3": "https://x.com/@cba_a3/"}, + } + with tempfile.TemporaryDirectory() as d: + # No meta.cpp — name fallback + result = _lookup_server_name("@CBA_A3", Path(d), index) + assert_eq(result, "@cba_a3") + + +def _test_lookup_server_name_steam_id_beats_name(): + """steam_id lookup must take priority over name fallback.""" + index = { + "by_steam_id": {"111": "https://x.com/@correct/"}, + "by_name": {"mymod": "https://x.com/@wrong/"}, + } + with tempfile.TemporaryDirectory() as d: + d = Path(d) + (d / "meta.cpp").write_text("publishedid = 111;", encoding="utf-8") + result = _lookup_server_name("@MyMod", d, index) + assert_eq(result, "@correct") + + +def _test_lookup_server_name_not_found(): + index = {"by_steam_id": {}, "by_name": {}} + with tempfile.TemporaryDirectory() as d: + result = _lookup_server_name("@Unknown", Path(d), index) + assert result is None + + +test("_server_name_from_url: extracts name from URL", _test_server_name_from_url) +test("_read_local_steam_id: reads meta.cpp", _test_read_local_steam_id_present) +test("_read_local_steam_id: no meta.cpp -> None", _test_read_local_steam_id_missing) +test("_read_local_steam_id: meta.cpp without id -> None", _test_read_local_steam_id_no_id_in_file) +test("_lookup_server_name: matches by steam_id", _test_lookup_server_name_by_steam_id) +test("_lookup_server_name: falls back to name", _test_lookup_server_name_by_name_fallback) +test("_lookup_server_name: steam_id beats name fallback", _test_lookup_server_name_steam_id_beats_name) +test("_lookup_server_name: not found -> None", _test_lookup_server_name_not_found) + + +# --------------------------------------------------------------------------- +# 10. Integration: parse → compare → reporter (offline) # --------------------------------------------------------------------------- group("integration: parse → compare → reporter (offline)")