Files
arma-modlist-tools/gui/views/mods.py
revernomad17 6197659568 Fix mods view showing wrong download status for name-mismatched mods
_find_folder() used a plain lowercase compare which failed when the
server canonical folder name differs from the comparison.json mod name
in more than just case (e.g. spaces vs underscores: "NIArms All in One"
vs "@NIArms_All_In_One"). These mods showed ✗ even though the pipeline
found and linked them correctly.

Add a normalized-name fallback (strips non-alphanumeric, same logic as
fetcher._normalize_name) so the lookup matches the same way the fetcher
resolves mods from the server index.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 15:54:30 +07:00

366 lines
15 KiB
Python

from __future__ import annotations
import json
import threading
from pathlib import Path
from typing import TYPE_CHECKING, Optional
import customtkinter as ctk
from arma_modlist_tools.fetcher import _normalize_name
from gui._constants import COLOR_OK, COLOR_ERROR, COLOR_WARN, COLOR_RUNNING
from gui.views.base import BaseView
if TYPE_CHECKING:
from gui.app import ArmaModManagerApp
def _find_folder(group_dir: Path, mod_name: str) -> Optional[Path]:
"""Return the local mod folder path, or None if not downloaded.
Matches in priority order:
1. Exact folder name ``@{mod_name}``
2. Case-insensitive name (handles ``@CBA_A3`` vs ``CBA_A3``)
3. Normalized name — strips non-alphanumeric (handles ``@cba_a3`` vs ``CBA A3``)
"""
if not group_dir.is_dir():
return None
candidate = group_dir / f"@{mod_name}"
if candidate.is_dir():
return candidate
target_lower = mod_name.lower()
target_norm = _normalize_name(mod_name)
for p in group_dir.iterdir():
if not p.is_dir():
continue
if p.name.lstrip("@").lower() == target_lower:
return p
if _normalize_name(p.name) == target_norm:
return p
return None
class ModsView(BaseView):
"""Tabbed mod browser — one tab per comparison group with server status checking."""
def build(self) -> None:
self.grid_columnconfigure(0, weight=1)
self.grid_rowconfigure(2, weight=1)
# ── State ─────────────────────────────────────────────────────────────
self._search_var = ctk.StringVar()
self._checking = False
self._check_btn: Optional[ctk.CTkButton] = None
self._tab_view: Optional[ctk.CTkTabview] = None
# key = "{group}/{folder_name_or_mod_name}"
# value = dict(status_label, update_btn, group, folder_path, mod_dict, name_label)
self._mod_rows: dict[str, dict] = {}
# ── Header ────────────────────────────────────────────────────────────
hdr = ctk.CTkFrame(self, fg_color="transparent")
hdr.grid(row=0, column=0, sticky="ew", padx=24, pady=(20, 6))
hdr.columnconfigure(1, weight=1)
ctk.CTkLabel(hdr, text="Mods",
font=ctk.CTkFont(size=22, weight="bold")).grid(
row=0, column=0, sticky="w")
btn_frame = ctk.CTkFrame(hdr, fg_color="transparent")
btn_frame.grid(row=0, column=2, sticky="e")
ctk.CTkButton(btn_frame, text="⟳ Refresh", width=100,
command=self.refresh).pack(side="left", padx=(0, 6))
self._check_btn = ctk.CTkButton(
btn_frame, text="☁ Check Updates", width=130,
command=self._check_updates,
)
self._check_btn.pack(side="left")
# ── Search ────────────────────────────────────────────────────────────
bar = ctk.CTkFrame(self, fg_color="transparent")
bar.grid(row=1, column=0, sticky="ew", padx=24, pady=(0, 8))
ctk.CTkLabel(bar, text="Search:").pack(side="left", padx=(0, 6))
ctk.CTkEntry(bar, textvariable=self._search_var,
placeholder_text="Filter mods in active tab…",
width=220).pack(side="left")
self._search_var.trace_add("write", lambda *_: self._apply_search())
# ── Tab area placeholder ───────────────────────────────────────────────
self._tab_area = ctk.CTkFrame(self, fg_color="transparent")
self._tab_area.grid(row=2, column=0, sticky="nsew", padx=16, pady=(0, 12))
self._tab_area.grid_columnconfigure(0, weight=1)
self._tab_area.grid_rowconfigure(0, weight=1)
self._msg_label: Optional[ctk.CTkLabel] = None
# =========================================================================
# Public
# =========================================================================
def refresh(self) -> None:
self._mod_rows.clear()
# Destroy previous tab_view / message
if self._tab_view is not None:
self._tab_view.destroy()
self._tab_view = None
if self._msg_label is not None:
self._msg_label.destroy()
self._msg_label = None
cfg = self.app.cfg
if not cfg:
self._show_msg("No config found. Complete Setup first.")
return
if not cfg.comparison.exists():
self._show_msg(
"No mod data yet.\n"
"Go to Dashboard, select your presets, then click Run Full Pipeline."
)
return
try:
comp = json.loads(cfg.comparison.read_text(encoding="utf-8"))
except Exception as e:
self._show_msg(f"Error reading comparison.json: {e}", error=True)
return
# Build ordered group list: shared first, then unique groups
groups: list[tuple[str, dict]] = [("shared", comp["shared"])]
for preset, data in comp["unique"].items():
groups.append((preset, data))
# Precompute link maps per group (one get_link_status call per group)
link_maps: dict[str, dict[str, bool]] = {}
try:
from arma_modlist_tools.linker import get_link_status
for group, _ in groups:
gdir = cfg.downloads / group
if gdir.is_dir():
link_maps[group] = {
e["name"].lower(): e["is_linked"]
for e in get_link_status(gdir, cfg.arma_dir)
}
except Exception:
pass
# Build CTkTabview
tv = ctk.CTkTabview(self._tab_area)
tv.grid(row=0, column=0, sticky="nsew")
self._tab_view = tv
for group, data in groups:
mods = data.get("mods", [])
count = len(mods)
tab_label = f"{group} ({count})"
tv.add(tab_label)
tab_frame = tv.tab(tab_label)
tab_frame.grid_columnconfigure(0, weight=1)
tab_frame.grid_rowconfigure(1, weight=1)
# Column header
col_hdr = ctk.CTkFrame(tab_frame,
fg_color=("gray82", "gray22"), corner_radius=6)
col_hdr.grid(row=0, column=0, sticky="ew", padx=4, pady=(6, 2))
col_hdr.columnconfigure(0, weight=1)
for col, (w, lbl) in enumerate([
(0, "Mod Name"),
(80, "Downloaded"),
(80, "Linked"),
(160, "Server Status"),
(80, ""),
]):
ctk.CTkLabel(col_hdr, text=lbl,
font=ctk.CTkFont(weight="bold"),
anchor="w", width=w or 1).grid(
row=0, column=col,
padx=(10 if col == 0 else 4, 4), pady=5,
sticky="ew" if col == 0 else "")
# Scrollable rows
scroll = ctk.CTkScrollableFrame(tab_frame)
scroll.grid(row=1, column=0, sticky="nsew", padx=4, pady=(0, 4))
self._build_group_rows(scroll, group, mods,
cfg, link_maps.get(group, {}))
if groups:
tv.set(f"{groups[0][0]} ({len(groups[0][1].get('mods', []))})")
# =========================================================================
# Private — layout helpers
# =========================================================================
def _show_msg(self, text: str, error: bool = False) -> None:
self._msg_label = ctk.CTkLabel(
self._tab_area, text=text, justify="left",
text_color="#F44336" if error else "gray",
)
self._msg_label.grid(row=0, column=0, padx=24, pady=24, sticky="nw")
def _build_group_rows(
self,
parent: ctk.CTkScrollableFrame,
group: str,
mods: list[dict],
cfg,
link_map: dict[str, bool],
) -> None:
for i, mod in enumerate(sorted(mods, key=lambda m: m["name"].lower())):
folder_path = _find_folder(cfg.downloads / group, mod["name"])
downloaded = folder_path is not None
linked = (link_map.get(folder_path.name.lower(), False)
if folder_path else False)
bg = ("gray90", "gray17") if i % 2 == 0 else ("gray86", "gray14")
row = ctk.CTkFrame(parent, fg_color=bg, corner_radius=4)
row.pack(fill="x", pady=1)
row.columnconfigure(0, weight=1)
# Mod name
name_lbl = ctk.CTkLabel(row, text=f" {mod['name']}", anchor="w")
name_lbl.grid(row=0, column=0, sticky="ew", padx=4, pady=3)
# Downloaded
ctk.CTkLabel(
row,
text="" if downloaded else "",
text_color=COLOR_OK if downloaded else COLOR_ERROR,
width=80, anchor="w",
).grid(row=0, column=1, padx=4)
# Linked
ctk.CTkLabel(
row,
text="" if linked else ("" if not downloaded else ""),
text_color=COLOR_OK if linked else "gray",
width=80, anchor="w",
).grid(row=0, column=2, padx=4)
# Server status
status_lbl = ctk.CTkLabel(
row, text="", text_color="gray", width=160, anchor="w",
)
status_lbl.grid(row=0, column=3, padx=4)
# Update button (hidden until stale detected)
folder_name = folder_path.name if folder_path else None
update_btn = ctk.CTkButton(
row, text="Update", width=70,
command=(lambda g=group, fn=folder_name:
self._update_mod(g, fn)) if folder_name else None,
state="normal" if folder_name else "disabled",
)
update_btn.grid(row=0, column=4, padx=(4, 8), pady=2)
update_btn.grid_remove() # hidden until check finds stale files
# Register in row map
key = f"{group}/{folder_name or mod['name']}"
self._mod_rows[key] = {
"status_label": status_lbl,
"update_btn": update_btn,
"name_label": name_lbl,
"row_frame": row,
"group": group,
"folder_path": folder_path,
"mod_dict": mod,
"mod_name": mod["name"],
}
# =========================================================================
# Private — server update check
# =========================================================================
def _check_updates(self) -> None:
if self._checking:
return
cfg = self.app.cfg
if not cfg:
return
if not self._mod_rows:
return
self._checking = True
self._check_btn.configure(text="Checking…", state="disabled")
# Reset downloaded rows to "Checking…"
for row in self._mod_rows.values():
if row["folder_path"]:
row["status_label"].configure(
text="Checking…", text_color=COLOR_RUNNING)
else:
row["status_label"].configure(text="", text_color="gray")
# Snapshot rows for thread (avoid race with refresh)
rows_snapshot = dict(self._mod_rows)
def worker() -> None:
from arma_modlist_tools.fetcher import (
build_server_index, list_mod_updates, make_session, find_mod_folder,
)
results: dict[str, tuple[str, int]] = {}
try:
idx = build_server_index(cfg.server_url, cfg.server_auth)
session = make_session(cfg.server_auth)
for key, row in rows_snapshot.items():
if not row["folder_path"]:
results[key] = ("not_downloaded", 0)
continue
try:
folder_url = find_mod_folder(row["mod_dict"], idx)
if not folder_url:
results[key] = ("not_on_server", 0)
continue
stale = list_mod_updates(
folder_url, row["folder_path"], session)
results[key] = ("stale" if stale else "ok", len(stale))
except Exception:
results[key] = ("error", 0)
except Exception:
for key in rows_snapshot:
results[key] = ("error", 0)
self.after(0, lambda: self._apply_check_results(results))
threading.Thread(target=worker, daemon=True).start()
def _apply_check_results(self, results: dict[str, tuple[str, int]]) -> None:
_STATUS: dict[str, tuple[str, str]] = {
"ok": ("✓ Up to date", COLOR_OK),
"stale": ("{n} outdated", COLOR_WARN),
"not_downloaded": ("", "gray"),
"not_on_server": ("Not on server", "gray"),
"error": ("✗ Error", COLOR_ERROR),
}
for key, (status, n) in results.items():
row = self._mod_rows.get(key)
if not row:
continue
tmpl, color = _STATUS.get(status, ("", "gray"))
row["status_label"].configure(
text=tmpl.replace("{n}", str(n)), text_color=color)
if status == "stale" and row["folder_path"]:
row["update_btn"].grid()
else:
row["update_btn"].grid_remove()
self._checking = False
self._check_btn.configure(text="☁ Check Updates", state="normal")
def _update_mod(self, group: str, folder_name: str) -> None:
self.app.navigate_to("Logs")
self.app.run_tool(["update_mods.py", "--group", group, "--mod", folder_name])
# =========================================================================
# Private — search filter
# =========================================================================
def _apply_search(self) -> None:
search = self._search_var.get().lower()
for row in self._mod_rows.values():
frame = row["row_frame"]
if not search or search in row["mod_name"].lower():
frame.pack(fill="x", pady=1)
else:
frame.pack_forget()