from __future__ import annotations import json import shutil import threading from tkinter import messagebox from typing import TYPE_CHECKING import customtkinter as ctk from arma_modlist_tools.cleaner import find_orphan_folders from arma_modlist_tools.linker import _is_junction, remove_junction from gui._constants import COLOR_WARN, PROJECT_ROOT from gui.locales import t from gui.views.base import BaseView if TYPE_CHECKING: from gui.app import ArmaModManagerApp _WARN_COLOR = COLOR_WARN class ToolsView(BaseView): """Per-tool panels inside a CTkTabview.""" def build(self) -> None: self.grid_columnconfigure(0, weight=1) self.grid_rowconfigure(1, weight=1) self._title_lbl = ctk.CTkLabel(self, text=t("tools.title"), font=ctk.CTkFont(size=22, weight="bold")) self._title_lbl.grid(row=0, column=0, sticky="w", padx=24, pady=(20, 8)) self._tab_view = ctk.CTkTabview(self) self._tab_view.grid(row=1, column=0, sticky="nsew", padx=16, pady=(0, 12)) # Per-tab group menu references so refresh() can repopulate them all self._group_menus: list[tuple[ctk.CTkOptionMenu, ctk.StringVar]] = [] # Tab-internal translatable labels (for hot-swap on refresh) self._translatable: list[tuple[ctk.CTkLabel | ctk.CTkButton | ctk.CTkCheckBox, str]] = [] self._build_check_names_tab() self._build_update_mods_tab() self._build_link_mods_tab() self._build_sync_missing_tab() self._build_report_missing_tab() self._build_clean_orphans_tab() # ========================================================================= # Public # ========================================================================= def refresh(self) -> None: self._title_lbl.configure(text=t("tools.title")) # Retranslate registered widgets for widget, key in self._translatable: widget.configure(text=t(key)) # Refresh link-mods button label (depends on current command selection) self._lm_on_change() groups = self._get_groups() all_groups = [t("tools.all_groups")] + groups # Repopulate generic group menus for menu, var in self._group_menus: prev = var.get() menu.configure(values=all_groups) # Keep selection if still valid, else reset to "All groups" if prev not in all_groups and prev not in groups: var.set(t("tools.all_groups")) # Link Mods group menu (no "All groups") lm_prev = self._lm_group_var.get() lm_vals = groups if groups else [t("tools.no_groups")] self._lm_group_menu.configure(values=lm_vals) if lm_prev not in lm_vals: self._lm_group_var.set(lm_vals[0]) # Info labels self._update_sm_label() self._update_rm_label() # ========================================================================= # Private — tab builders # ========================================================================= def _build_check_names_tab(self) -> None: # Tab name kept in English — used as CTkTabview lookup key self._tab_view.add("Check Names") tab = self._tab_view.tab("Check Names") tab.grid_columnconfigure(0, weight=1) desc_lbl = _desc(tab, row=0, text=t("tools.cn_desc")) self._translatable.append((desc_lbl, "tools.cn_desc")) # Group gf, group_lbl = _row(tab, row=1, label=t("tools.label_group")) self._translatable.append((group_lbl, "tools.label_group")) self._cn_group_var = ctk.StringVar(value=t("tools.all_groups")) menu = ctk.CTkOptionMenu(gf, variable=self._cn_group_var, values=[t("tools.all_groups")], width=200) menu.pack(side="left") self._group_menus.append((menu, self._cn_group_var)) # Checkboxes cf, opts_lbl = _row(tab, row=2, label=t("tools.label_options")) self._translatable.append((opts_lbl, "tools.label_options")) self._cn_fix_var = ctk.BooleanVar(value=False) self._cn_fix_ids_var = ctk.BooleanVar(value=False) cn_fix_chk = ctk.CTkCheckBox(cf, text=t("tools.cn_fix_chk"), variable=self._cn_fix_var, command=self._cn_on_toggle) cn_fix_chk.pack(side="left", padx=(0, 16)) self._translatable.append((cn_fix_chk, "tools.cn_fix_chk")) cn_ids_chk = ctk.CTkCheckBox(cf, text=t("tools.cn_fix_ids_chk"), variable=self._cn_fix_ids_var, command=self._cn_on_toggle) cn_ids_chk.pack(side="left") self._translatable.append((cn_ids_chk, "tools.cn_fix_ids_chk")) # Warning (hidden until checkbox ticked) self._cn_warn = ctk.CTkLabel(tab, text=t("tools.cn_warn"), text_color=_WARN_COLOR, anchor="w") self._translatable.append((self._cn_warn, "tools.cn_warn")) # Run button cn_btn = ctk.CTkButton(tab, text=t("tools.cn_btn"), width=180, command=self._cn_run) cn_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") self._translatable.append((cn_btn, "tools.cn_btn")) def _cn_on_toggle(self) -> None: if self._cn_fix_var.get() or self._cn_fix_ids_var.get(): self._cn_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w") else: self._cn_warn.grid_forget() def _cn_run(self) -> None: args = ["check_names.py"] g = self._cn_group_var.get() if g != t("tools.all_groups"): args += ["--group", g] if self._cn_fix_var.get(): args.append("--fix") if self._cn_fix_ids_var.get(): args.append("--fix-ids") self._launch(args) # ------------------------------------------------------------------------- def _build_update_mods_tab(self) -> None: self._tab_view.add("Update Mods") tab = self._tab_view.tab("Update Mods") tab.grid_columnconfigure(0, weight=1) desc_lbl = _desc(tab, row=0, text=t("tools.um_desc")) self._translatable.append((desc_lbl, "tools.um_desc")) # Group gf, group_lbl = _row(tab, row=1, label=t("tools.label_group")) self._translatable.append((group_lbl, "tools.label_group")) self._um_group_var = ctk.StringVar(value=t("tools.all_groups")) um_menu = ctk.CTkOptionMenu( gf, variable=self._um_group_var, values=[t("tools.all_groups")], width=200, command=self._um_on_group_change, ) um_menu.pack(side="left") self._group_menus.append((um_menu, self._um_group_var)) # Mod name (enabled only when a specific group is selected) mf, mod_lbl = _row(tab, row=2, label=t("tools.um_mod_label")) self._translatable.append((mod_lbl, "tools.um_mod_label")) self._um_mod_entry = ctk.CTkEntry( mf, placeholder_text=t("tools.um_mod_placeholder"), width=220, state="disabled", ) self._um_mod_entry.pack(side="left") um_hint = ctk.CTkLabel(mf, text=t("tools.um_mod_hint"), text_color="gray") um_hint.pack(side="left", padx=8) self._translatable.append((um_hint, "tools.um_mod_hint")) # Force checkbox ff, opts_lbl = _row(tab, row=3, label=t("tools.label_options")) self._translatable.append((opts_lbl, "tools.label_options")) self._um_force_var = ctk.BooleanVar(value=False) um_force_chk = ctk.CTkCheckBox( ff, text=t("tools.um_force_chk"), variable=self._um_force_var, command=self._um_on_toggle, ) um_force_chk.pack(side="left") self._translatable.append((um_force_chk, "tools.um_force_chk")) # Warning self._um_warn = ctk.CTkLabel(tab, text=t("tools.um_warn"), text_color=_WARN_COLOR, anchor="w") self._translatable.append((self._um_warn, "tools.um_warn")) um_btn = ctk.CTkButton(tab, text=t("tools.um_btn"), width=180, command=self._um_run) um_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") self._translatable.append((um_btn, "tools.um_btn")) def _um_on_group_change(self, _: str) -> None: is_specific = self._um_group_var.get() != t("tools.all_groups") self._um_mod_entry.configure(state="normal" if is_specific else "disabled") if not is_specific: self._um_mod_entry.delete(0, "end") def _um_on_toggle(self) -> None: if self._um_force_var.get(): self._um_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w") else: self._um_warn.grid_forget() def _um_run(self) -> None: args = ["update_mods.py"] g = self._um_group_var.get() if g != t("tools.all_groups"): args += ["--group", g] mod = self._um_mod_entry.get().strip() if mod: args += ["--mod", mod] if self._um_force_var.get(): args.append("--force") self._launch(args) # ------------------------------------------------------------------------- def _build_link_mods_tab(self) -> None: self._tab_view.add("Link Mods") tab = self._tab_view.tab("Link Mods") tab.grid_columnconfigure(0, weight=1) desc_lbl = _desc(tab, row=0, text=t("tools.lm_desc")) self._translatable.append((desc_lbl, "tools.lm_desc")) # Command selector — values kept in English (drive internal logic) cf, cmd_lbl = _row(tab, row=1, label=t("tools.label_command")) self._translatable.append((cmd_lbl, "tools.label_command")) self._lm_cmd_var = ctk.StringVar(value="Status") ctk.CTkSegmentedButton( cf, values=["Status", "Link", "Unlink"], variable=self._lm_cmd_var, command=self._lm_on_change, ).pack(side="left") # Group (required — no "All groups") gf, group_lbl = _row(tab, row=2, label=t("tools.label_group")) self._translatable.append((group_lbl, "tools.label_group")) self._lm_group_var = ctk.StringVar(value="") self._lm_group_menu = ctk.CTkOptionMenu( gf, variable=self._lm_group_var, values=[t("tools.no_groups")], width=200, command=lambda _: self._lm_on_change(), ) self._lm_group_menu.pack(side="left") # Warning (shown for Unlink) self._lm_warn = ctk.CTkLabel(tab, text=t("tools.lm_warn"), text_color=_WARN_COLOR, anchor="w") self._translatable.append((self._lm_warn, "tools.lm_warn")) # Run button (label changes with command) self._lm_run_btn = ctk.CTkButton( tab, text=t("tools.lm_show_status"), width=180, command=self._lm_run, ) self._lm_run_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") def _lm_on_change(self, _: str = "") -> None: cmd = self._lm_cmd_var.get() # Keys are English segmented-button values; values are translated labels labels = { "Status": t("tools.lm_show_status"), "Link": t("tools.lm_create_links"), "Unlink": t("tools.lm_remove_links"), } self._lm_run_btn.configure(text=labels.get(cmd, cmd)) if cmd == "Unlink": self._lm_warn.grid(row=5, column=0, padx=24, pady=(4, 0), sticky="w") else: self._lm_warn.grid_forget() def _lm_run(self) -> None: cmd = self._lm_cmd_var.get().lower() group = self._lm_group_var.get() if not group or group == t("tools.no_groups"): messagebox.showwarning(t("tools.lm_no_group_title"), t("tools.lm_no_group_body")) return args = ["link_mods.py", cmd, "--group", group] if cmd == "unlink": confirmed = messagebox.askyesno( t("tools.lm_confirm_title"), t("tools.lm_confirm_body", group=group), ) if not confirmed: return args.append("--yes") self._launch(args) # ------------------------------------------------------------------------- def _build_sync_missing_tab(self) -> None: self._tab_view.add("Sync Missing") tab = self._tab_view.tab("Sync Missing") tab.grid_columnconfigure(0, weight=1) desc_lbl = _desc(tab, row=0, text=t("tools.sm_desc")) self._translatable.append((desc_lbl, "tools.sm_desc")) self._sm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w") self._sm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w") sm_btn = ctk.CTkButton(tab, text=t("tools.sm_btn"), width=180, command=lambda: self._launch(["sync_missing.py"])) sm_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") self._translatable.append((sm_btn, "tools.sm_btn")) def _update_sm_label(self) -> None: cfg = self.app.cfg if not cfg: self._sm_info.configure(text="") return report_path = cfg.missing_report if hasattr(cfg, "missing_report") else ( cfg.modlist_json / "missing_report.json" if hasattr(cfg, "modlist_json") else None ) if report_path and report_path.exists(): try: data = json.loads(report_path.read_text(encoding="utf-8")) count = data.get("missing", len(data.get("missing_mods", []))) self._sm_info.configure(text=t("tools.sm_count", count=count)) return except Exception: pass self._sm_info.configure(text=t("tools.sm_no_report")) # ------------------------------------------------------------------------- def _build_report_missing_tab(self) -> None: self._tab_view.add("Report Missing") tab = self._tab_view.tab("Report Missing") tab.grid_columnconfigure(0, weight=1) desc_lbl = _desc(tab, row=0, text=t("tools.rm_desc")) self._translatable.append((desc_lbl, "tools.rm_desc")) self._rm_info = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w") self._rm_info.grid(row=1, column=0, padx=24, pady=(4, 0), sticky="w") rm_btn = ctk.CTkButton(tab, text=t("tools.rm_btn"), width=180, command=lambda: self._launch(["report_missing.py"])) rm_btn.grid(row=10, column=0, padx=24, pady=(16, 24), sticky="e") self._translatable.append((rm_btn, "tools.rm_btn")) def _update_rm_label(self) -> None: cfg = self.app.cfg if not cfg: self._rm_info.configure(text="") return report_path = cfg.missing_report if hasattr(cfg, "missing_report") else ( cfg.modlist_json / "missing_report.json" if hasattr(cfg, "modlist_json") else None ) if report_path and report_path.exists(): try: data = json.loads(report_path.read_text(encoding="utf-8")) ts = data.get("generated_at", "unknown") self._rm_info.configure(text=t("tools.rm_last", ts=ts)) return except Exception: pass self._rm_info.configure(text=t("tools.rm_none")) # ------------------------------------------------------------------------- def _build_clean_orphans_tab(self) -> None: self._tab_view.add("Clean Orphans") tab = self._tab_view.tab("Clean Orphans") tab.grid_columnconfigure(0, weight=1) tab.grid_rowconfigure(3, weight=1) desc_lbl = _desc(tab, row=0, text=t("tools.oc_desc")) self._translatable.append((desc_lbl, "tools.oc_desc")) oc_warn = ctk.CTkLabel(tab, text=t("tools.oc_warn"), text_color=_WARN_COLOR, anchor="w") oc_warn.grid(row=1, column=0, padx=24, pady=(0, 4), sticky="w") self._translatable.append((oc_warn, "tools.oc_warn")) self._oc_status = ctk.CTkLabel(tab, text="", text_color="gray", anchor="w") self._oc_status.grid(row=2, column=0, padx=24, pady=(0, 2), sticky="w") # Scrollable list for results self._oc_scroll = ctk.CTkScrollableFrame(tab) self._oc_scroll.grid(row=3, column=0, sticky="nsew", padx=16, pady=(0, 4)) self._oc_scroll.grid_columnconfigure(0, weight=1) # Bottom action bar bot = ctk.CTkFrame(tab, fg_color="transparent") bot.grid(row=4, column=0, sticky="ew", padx=16, pady=(4, 12)) self._oc_sel_all_btn = ctk.CTkButton( bot, text=t("tools.oc_sel_all"), width=110, command=self._oc_select_all, ) self._oc_sel_all_btn.pack(side="left", padx=(0, 4)) self._translatable.append((self._oc_sel_all_btn, "tools.oc_sel_all")) self._oc_sel_none_btn = ctk.CTkButton( bot, text=t("tools.oc_sel_none"), width=110, command=self._oc_deselect_all, ) self._oc_sel_none_btn.pack(side="left", padx=4) self._translatable.append((self._oc_sel_none_btn, "tools.oc_sel_none")) self._oc_scan_btn = ctk.CTkButton( bot, text=t("tools.oc_scan_btn"), width=150, command=self._oc_scan, ) self._oc_scan_btn.pack(side="right", padx=(4, 0)) self._translatable.append((self._oc_scan_btn, "tools.oc_scan_btn")) self._oc_delete_btn = ctk.CTkButton( bot, text=t("tools.oc_delete_btn"), width=150, fg_color="darkred", hover_color="#8b0000", command=self._oc_delete_selected, state="disabled", ) self._oc_delete_btn.pack(side="right", padx=4) self._translatable.append((self._oc_delete_btn, "tools.oc_delete_btn")) # Internal scan state self._oc_orphans: list[dict] = [] self._oc_check_vars: list[ctk.BooleanVar] = [] self._oc_pending_done_msg: str | None = None def _oc_scan(self) -> None: cfg = self.app.cfg if not cfg: self._oc_status.configure(text=t("tools.oc_no_config"), text_color="gray") return if not cfg.comparison.exists(): self._oc_status.configure(text=t("tools.oc_no_comparison"), text_color="gray") return self._oc_scan_btn.configure(state="disabled", text=t("tools.oc_scanning")) self._oc_delete_btn.configure(state="disabled") self._oc_status.configure(text=t("tools.oc_scanning"), text_color="gray") def _run() -> None: try: comparison = json.loads(cfg.comparison.read_text(encoding="utf-8")) orphans = find_orphan_folders(cfg.downloads, comparison) except Exception as e: self.after(0, lambda: self._oc_scan_done(None, str(e))) return self.after(0, lambda: self._oc_scan_done(orphans, None)) threading.Thread(target=_run, daemon=True).start() def _oc_scan_done(self, orphans: list[dict] | None, error: str | None) -> None: self._oc_scan_btn.configure(state="normal", text=t("tools.oc_scan_btn")) # Consume any pending success message from a previous delete operation done_msg = self._oc_pending_done_msg self._oc_pending_done_msg = None # Clear previous results for w in self._oc_scroll.winfo_children(): w.destroy() self._oc_orphans = [] self._oc_check_vars = [] if error: self._oc_status.configure(text=t("tools.oc_scan_error", e=error), text_color="red") return if not orphans: msg = done_msg or t("tools.oc_none_found") self._oc_status.configure(text=msg, text_color="gray") return total_size = sum(o["size"] for o in orphans) self._oc_status.configure( text=t("tools.oc_found", count=len(orphans), size=_fmt_size(total_size)), text_color="gray", ) self._oc_orphans = orphans self._oc_delete_btn.configure(state="normal") for i, orphan in enumerate(orphans): var = ctk.BooleanVar(value=True) self._oc_check_vars.append(var) bg = ("gray90", "gray17") if i % 2 == 0 else ("gray86", "gray14") row = ctk.CTkFrame(self._oc_scroll, fg_color=bg, corner_radius=4) row.pack(fill="x", pady=1) row.columnconfigure(1, weight=1) ctk.CTkCheckBox(row, text="", variable=var, width=24).grid( row=0, column=0, padx=(8, 4), pady=4, ) ctk.CTkLabel( row, text=f" {orphan['group']} / {orphan['name']}", anchor="w", ).grid(row=0, column=1, sticky="ew", padx=4) ctk.CTkLabel( row, text=_fmt_size(orphan["size"]), text_color="gray", width=80, anchor="e", ).grid(row=0, column=2, padx=(4, 12)) def _oc_select_all(self) -> None: for var in self._oc_check_vars: var.set(True) def _oc_deselect_all(self) -> None: for var in self._oc_check_vars: var.set(False) def _oc_delete_selected(self) -> None: selected = [ self._oc_orphans[i] for i, var in enumerate(self._oc_check_vars) if var.get() ] if not selected: return total_size = sum(o["size"] for o in selected) confirmed = messagebox.askyesno( t("tools.oc_confirm_title"), t("tools.oc_confirm_body", count=len(selected), size=_fmt_size(total_size)), ) if not confirmed: return self._oc_delete_btn.configure(state="disabled") self._oc_scan_btn.configure(state="disabled") def _run() -> None: freed = 0 errors = [] for orphan in selected: try: p = orphan["path"] if _is_junction(p): # Safety: never rmtree a junction — it follows the # reparse point and deletes the target's contents. # Use remove_junction() which calls os.rmdir() instead. ok, err = remove_junction(p) if not ok: errors.append(t("tools.oc_error", path=p.name, e=err)) continue else: shutil.rmtree(p) freed += orphan["size"] except Exception as e: errors.append(t("tools.oc_error", path=orphan["path"].name, e=e)) self.after(0, lambda: self._oc_delete_done(len(selected), freed, errors)) threading.Thread(target=_run, daemon=True).start() def _oc_delete_done(self, count: int, freed: int, errors: list[str]) -> None: # Store success message so _oc_scan_done() can display it after the rescan self._oc_pending_done_msg = ( None if errors else t("tools.oc_done", count=count, size=_fmt_size(freed)) ) self._oc_scan_btn.configure(state="normal") self._oc_scan() if errors: messagebox.showerror(t("tools.oc_error_title"), "\n".join(errors)) # ========================================================================= # Private — helpers # ========================================================================= def _get_groups(self) -> list[str]: cfg = self.app.cfg if cfg and cfg.downloads.is_dir(): return sorted(p.name for p in cfg.downloads.iterdir() if p.is_dir()) # Fallback: read comparison.json if cfg and cfg.comparison.exists(): try: comp = json.loads(cfg.comparison.read_text(encoding="utf-8")) return ["shared"] + list(comp.get("unique", {}).keys()) except Exception: pass return [] def _launch(self, args: list[str]) -> None: self.app.navigate_to("Logs") self.app.run_tool(args) # --------------------------------------------------------------------------- # Size formatting helper # --------------------------------------------------------------------------- def _fmt_size(n: int) -> str: """Human-readable file size string.""" if n < 1024: return f"{n} B" if n < 1024 ** 2: return f"{n / 1024:.1f} KB" if n < 1024 ** 3: return f"{n / 1024 ** 2:.1f} MB" return f"{n / 1024 ** 3:.2f} GB" # --------------------------------------------------------------------------- # Layout helpers # --------------------------------------------------------------------------- def _desc(parent, row: int, text: str) -> ctk.CTkLabel: lbl = ctk.CTkLabel(parent, text=text, justify="left", wraplength=700, text_color="gray", anchor="w") lbl.grid(row=row, column=0, padx=24, pady=(16, 8), sticky="ew") return lbl def _row(parent, row: int, label: str) -> tuple[ctk.CTkFrame, ctk.CTkLabel]: """A label + horizontal frame for a settings row. Returns (content_frame, label_widget) so callers can register the label for later retranslation. """ lbl = ctk.CTkLabel(parent, text=label, anchor="w", width=110) lbl.grid(row=row, column=0, padx=(24, 0), pady=6, sticky="w") f = ctk.CTkFrame(parent, fg_color="transparent") f.grid(row=row, column=0, padx=(140, 24), pady=6, sticky="w") return f, lbl