#!/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 # --------------------------------------------------------------------------- # Classification helpers # --------------------------------------------------------------------------- def _resolve_status( disk_name: str, server_name: str | None, ok_disk_names: set[str], ) -> tuple[str, str]: """ Return (status, display_col) for one mod, after false-positive filtering. A MISMATCH is downgraded to NOT_ON_SERVER when the proposed server name is already the exact disk name of another folder (steam_id collision on server). """ if server_name is None: return "NOT_ON_SERVER", "---" if server_name == disk_name: return "OK", server_name if server_name in ok_disk_names: return "NOT_ON_SERVER", "--- (steam_id collision)" return "MISMATCH", server_name # --------------------------------------------------------------------------- # 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) # ---- Pass 1: raw lookup for every mod ---- # result row: (group, mod_dir, server_name | None) raw: list[tuple[str, Path, str | None]] = [] for group, mod_dir in mods: raw.append((group, mod_dir, _lookup_server_name(mod_dir.name, mod_dir, index))) # ---- Pass 2: discard false positives caused by steam_id collisions ---- # If a server name is already an exact disk name (i.e. an OK match exists), # any other folder whose steam_id lookup points to that same server name is a # false positive — the server has a bad/shared publishedid in meta.cpp. # Reclassify those entries as NOT_ON_SERVER. ok_disk_names: set[str] = { mod_dir.name for _, mod_dir, server_name in raw if server_name == mod_dir.name # exact match = genuinely OK } def _classify(disk_name: str, server_name: str | None) -> tuple[str, str]: return _resolve_status(disk_name, server_name, ok_disk_names) # ---- Print table ---- ok_count = mismatch_count = unknown_count = 0 mismatches: list[tuple[str, Path, str]] = [] 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, server_name in raw: status, server_col = _classify(mod_dir.name, server_name) if status == "OK": ok_count += 1 elif status == "MISMATCH": mismatch_count += 1 mismatches.append((group, mod_dir, server_name)) else: unknown_count += 1 print(f" {mod_dir.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()