- 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%
647 lines
26 KiB
Python
647 lines
26 KiB
Python
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
|