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 <noreply@anthropic.com>
This commit is contained in:
237
check_names.py
Normal file
237
check_names.py
Normal file
@@ -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()
|
||||||
@@ -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)")
|
group("integration: parse → compare → reporter (offline)")
|
||||||
|
|||||||
Reference in New Issue
Block a user