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:
@@ -11,6 +11,7 @@ from .linker import (
|
||||
from .config import load_config, Config
|
||||
from .compat import is_windows, is_linux, get_os_label, fix_console_encoding
|
||||
from .reporter import build_missing_report, save_missing_report
|
||||
from .cleaner import find_orphan_folders, folder_size
|
||||
|
||||
__all__ = [
|
||||
# parser
|
||||
@@ -29,4 +30,6 @@ __all__ = [
|
||||
"is_windows", "is_linux", "get_os_label", "fix_console_encoding",
|
||||
# reporter
|
||||
"build_missing_report", "save_missing_report",
|
||||
# cleaner
|
||||
"find_orphan_folders", "folder_size",
|
||||
]
|
||||
|
||||
80
arma_modlist_tools/cleaner.py
Normal file
80
arma_modlist_tools/cleaner.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
arma_modlist_tools.cleaner
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Identify orphaned mod folders in the downloads directory.
|
||||
|
||||
An *orphan* is a downloaded ``@ModName`` folder that is no longer referenced
|
||||
by any group in ``comparison.json``. This happens when the user swaps out a
|
||||
modlist preset and re-runs the compare step — mods that were removed from the
|
||||
preset remain on disk but are no longer tracked.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from arma_modlist_tools.cleaner import find_orphan_folders
|
||||
|
||||
comparison = json.loads(Path("modlist_json/comparison.json").read_text())
|
||||
orphans = find_orphan_folders(Path("downloads"), comparison)
|
||||
for o in orphans:
|
||||
print(o["group"], o["name"], o["size"])
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .fetcher import _normalize_name as _normalize
|
||||
|
||||
|
||||
def folder_size(path: Path) -> int:
|
||||
"""Return the total size in bytes of all files under *path* (recursive)."""
|
||||
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
|
||||
|
||||
|
||||
def find_orphan_folders(
|
||||
downloads: Path,
|
||||
comparison: dict,
|
||||
) -> list[dict]:
|
||||
"""Return a list of orphan mod folder entries.
|
||||
|
||||
A folder ``downloads/{group}/@ModName`` is considered an orphan when its
|
||||
normalised name does not match any mod in *comparison* under the same
|
||||
group. Groups in ``downloads/`` that do not exist in *comparison* at all
|
||||
are treated as entirely orphaned.
|
||||
|
||||
:param downloads: Path to the ``downloads/`` directory.
|
||||
:param comparison: Parsed ``comparison.json`` dict (output of
|
||||
:func:`~arma_modlist_tools.compare.compare_presets`).
|
||||
:returns: List of dicts, each with:
|
||||
|
||||
- ``path`` — absolute :class:`~pathlib.Path` of the folder
|
||||
- ``group`` — group name (e.g. ``"shared"``)
|
||||
- ``name`` — folder name as it appears on disk (e.g. ``"@ace"``)
|
||||
- ``size`` — total size in bytes (recursive)
|
||||
"""
|
||||
# Build group → set-of-normalised-mod-names from comparison data
|
||||
known: dict[str, set[str]] = {}
|
||||
for mod in comparison.get("shared", {}).get("mods", []):
|
||||
known.setdefault("shared", set()).add(_normalize(mod["name"]))
|
||||
for preset, pdata in comparison.get("unique", {}).items():
|
||||
for mod in pdata.get("mods", []):
|
||||
known.setdefault(preset, set()).add(_normalize(mod["name"]))
|
||||
|
||||
orphans: list[dict] = []
|
||||
if not downloads.is_dir():
|
||||
return orphans
|
||||
|
||||
for group_dir in sorted(downloads.iterdir()):
|
||||
if not group_dir.is_dir():
|
||||
continue
|
||||
group_known = known.get(group_dir.name, set()) # empty → group removed
|
||||
for mod_dir in sorted(group_dir.iterdir()):
|
||||
if not mod_dir.is_dir() or not mod_dir.name.startswith("@"):
|
||||
continue
|
||||
if _normalize(mod_dir.name) not in group_known:
|
||||
orphans.append({
|
||||
"path": mod_dir,
|
||||
"group": group_dir.name,
|
||||
"name": mod_dir.name,
|
||||
"size": folder_size(mod_dir),
|
||||
})
|
||||
|
||||
return orphans
|
||||
Reference in New Issue
Block a user