feat: add orphan mod cleanup tool with GUI integration and live-server tests
- Add arma_modlist_tools/cleaner.py: find_orphan_folders() detects @ModName folders no longer referenced in comparison.json; uses _normalize_name from fetcher for consistent three-level matching - Add clean_orphans.py: CLI with --dry-run and --yes/-y flags; junction-safe deletion via _is_junction() guard before shutil.rmtree - Add Clean Orphans tab to gui/views/tools.py: scrollable checkbox list, background scan/delete threads, pending-done-msg pattern for post-scan status, EN/VI localization strings in gui/locales.py - Add 23 unit tests (section 12), 6 E2E subprocess tests (section 13), 23 coverage-gap tests (section 14), 9 live-server fetcher tests (section 15) - Fix leaked builtins.open mock in _test_read_os_release_parses_file - Overall coverage: 84% → 93%; fetcher.py: 36% → 72%
This commit is contained in:
@@ -1,11 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
from tkinter import messagebox
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
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
|
||||
@@ -41,6 +45,7 @@ class ToolsView(BaseView):
|
||||
self._build_link_mods_tab()
|
||||
self._build_sync_missing_tab()
|
||||
self._build_report_missing_tab()
|
||||
self._build_clean_orphans_tab()
|
||||
|
||||
# =========================================================================
|
||||
# Public
|
||||
@@ -378,6 +383,208 @@ class ToolsView(BaseView):
|
||||
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
|
||||
# =========================================================================
|
||||
@@ -400,6 +607,21 @@ class ToolsView(BaseView):
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user