From 16d5c2857d6d31f04852a081d5f22a532cd58c17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Wed, 13 May 2026 16:58:44 +0200 Subject: [PATCH] Add dedicated Pruning tab with strategies and per-game overrides --- CHANGELOG.md | 13 ++ pyproject.toml | 2 +- src/_version.py | 2 +- src/config.py | 75 +++++++-- src/models.py | 23 ++- src/ui/dialog_game_settings.py | 49 ++++++ src/ui/main_window.py | 3 + src/ui/tab_prune.py | 293 +++++++++++++++++++++++++++++++++ src/ui/tab_status.py | 35 ---- 9 files changed, 447 insertions(+), 48 deletions(-) create mode 100644 src/ui/tab_prune.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ae5aa6c..88dca6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.1.0] — 2026-05-13 + +### Features + +- Dedicated Pruning tab — global keep-count setting with four strategies: latest N only, latest N + oldest, latest N + one per year, latest N + one per year + oldest +- Per-game pruning overrides — exclude individual games from global pruning or set a custom keep count, configurable via the existing game settings dialog +- Prune confirmation dialog shows a per-platform preview (Windows and Linux pruned independently — no cross-platform version comparison) + +### Changes + +- Pruning controls removed from Status tab and moved to the new Pruning tab +- Fixed bug in `prune_old_versions()`: installer list was reassigned inside the loop and `latest_version` was not updated after pruning + ## [1.0.1] — 2026-05-12 ### Improvements diff --git a/pyproject.toml b/pyproject.toml index d4814e5..61f2ff7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "gogupdater" -version = "1.0.1" +version = "1.1.0" description = "" authors = [ {name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"} diff --git a/src/_version.py b/src/_version.py index e922c7e..be22502 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/src/config.py b/src/config.py index 1b41cb2..e99b575 100644 --- a/src/config.py +++ b/src/config.py @@ -5,7 +5,7 @@ from pathlib import Path from loguru import logger -from src.models import DownloadedInstaller, GameRecord, GameSettings, InstallerType, LANGUAGE_NAMES, language_folder_name +from src.models import DownloadedInstaller, GameRecord, GameSettings, InstallerType, LANGUAGE_NAMES, PruneStrategy, language_folder_name DEFAULT_CONFIG_DIR = Path.home() / ".config" / "gogupdater" METADATA_FILENAME = "gogupdater.json" @@ -25,6 +25,8 @@ class AppConfig: self.include_bonus: bool = False self.game_settings: dict[str, GameSettings] = {} self.version_decisions: dict[str, bool] = {} + self.prune_keep_count: int = 1 + self.prune_strategy: PruneStrategy = PruneStrategy.LATEST_N self._load() def _load(self) -> None: @@ -43,6 +45,11 @@ class AppConfig: for gid, gs in data.get("game_settings", {}).items() } self.version_decisions = data.get("version_decisions", {}) + self.prune_keep_count = int(data.get("prune_keep_count", 1)) + try: + self.prune_strategy = PruneStrategy(data.get("prune_strategy", PruneStrategy.LATEST_N.value)) + except ValueError: + self.prune_strategy = PruneStrategy.LATEST_N except (json.JSONDecodeError, OSError): logger.warning("Failed to read config.json, using defaults") @@ -61,6 +68,8 @@ class AppConfig: if not gs.is_default() }, "version_decisions": self.version_decisions, + "prune_keep_count": self.prune_keep_count, + "prune_strategy": self.prune_strategy.value, } self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8") @@ -149,33 +158,79 @@ class MetadataStore: return [] return list({i.version for i in record.installers}) - def prune_old_versions(self, game_id: str, keep_latest: int = 1) -> list[Path]: - """Remove old version directories, keeping the N most recent. Returns deleted paths.""" + def prune_old_versions( + self, + game_id: str, + keep_latest: int = 1, + strategy: PruneStrategy = PruneStrategy.LATEST_N, + ) -> list[Path]: + """Remove old version directories according to strategy. Returns deleted paths.""" record = self.games.get(game_id) if not record: return [] - versions = sorted({i.version for i in record.installers}) - if len(versions) <= keep_latest: + all_versions = sorted({i.version for i in record.installers}) + versions_to_keep = self._select_versions_to_keep(record, all_versions, keep_latest, strategy) + versions_to_remove = [v for v in all_versions if v not in versions_to_keep] + + if not versions_to_remove: return [] - versions_to_remove = versions[:-keep_latest] - deleted: list[Path] = [] + import shutil + deleted: list[Path] = [] for version in versions_to_remove: version_dir = self.base_path / record.name / version if version_dir.exists(): - import shutil - shutil.rmtree(version_dir) deleted.append(version_dir) logger.info(f"Pruned {version_dir}") - record.installers = [i for i in record.installers if i.version not in versions_to_remove] + record.installers = [i for i in record.installers if i.version not in versions_to_remove] + remaining = sorted({i.version for i in record.installers}) + record.latest_version = remaining[-1] if remaining else "" self.save() return deleted + @staticmethod + def _select_versions_to_keep( + record: "GameRecord", + all_versions: list[str], + keep_latest: int, + strategy: PruneStrategy, + ) -> set[str]: + if len(all_versions) <= keep_latest: + return set(all_versions) + + keep: set[str] = set(all_versions[-keep_latest:]) + + if strategy in (PruneStrategy.LATEST_N_OLDEST, PruneStrategy.LATEST_N_YEARLY_OLDEST): + keep.add(all_versions[0]) + + if strategy in (PruneStrategy.YEARLY, PruneStrategy.LATEST_N_YEARLY_OLDEST): + keep.update(MetadataStore._yearly_representatives(record, all_versions)) + + return keep + + @staticmethod + def _yearly_representatives(record: "GameRecord", all_versions: list[str]) -> set[str]: + """Return one version per calendar year (the latest in that year by sorted order).""" + version_year: dict[str, int] = {} + for inst in record.installers: + if inst.downloaded_at and inst.version not in version_year: + try: + version_year[inst.version] = int(inst.downloaded_at[:4]) + except (ValueError, IndexError): + pass + year_rep: dict[int, str] = {} + for version in all_versions: + year = version_year.get(version) + if year is not None: + if year not in year_rep or all_versions.index(version) > all_versions.index(year_rep[year]): + year_rep[year] = version + return set(year_rep.values()) + def verify_metadata(self) -> int: """Verify that recorded files still exist on disk. diff --git a/src/models.py b/src/models.py index 7a2a019..82e6edc 100644 --- a/src/models.py +++ b/src/models.py @@ -87,6 +87,13 @@ class GameStatus(Enum): UNVERSIONED = "unversioned" +class PruneStrategy(Enum): + LATEST_N = "latest_n" # keep N most recent versions only + LATEST_N_OLDEST = "latest_n_oldest" # keep N most recent + the oldest + YEARLY = "yearly" # keep N most recent + one per calendar year + LATEST_N_YEARLY_OLDEST = "latest_n_yearly_oldest" # keep N most recent + one per year + oldest + + @dataclass class InstallerInfo: """Installer metadata from GOG API.""" @@ -276,6 +283,8 @@ class GameSettings: languages: list[str] | None = None english_only: bool | None = None include_bonus: bool | None = None + prune_exclude: bool | None = None # True = skip in global prune + prune_keep_count: int | None = None # override global keep count def to_dict(self) -> dict: data: dict = {} @@ -285,6 +294,10 @@ class GameSettings: data["english_only"] = self.english_only if self.include_bonus is not None: data["include_bonus"] = self.include_bonus + if self.prune_exclude is not None: + data["prune_exclude"] = self.prune_exclude + if self.prune_keep_count is not None: + data["prune_keep_count"] = self.prune_keep_count return data @classmethod @@ -293,11 +306,19 @@ class GameSettings: languages=data.get("languages"), english_only=data.get("english_only"), include_bonus=data.get("include_bonus"), + prune_exclude=data.get("prune_exclude"), + prune_keep_count=data.get("prune_keep_count"), ) def is_default(self) -> bool: """True if all values are None (no overrides).""" - return self.languages is None and self.english_only is None and self.include_bonus is None + return ( + self.languages is None + and self.english_only is None + and self.include_bonus is None + and self.prune_exclude is None + and self.prune_keep_count is None + ) @dataclass diff --git a/src/ui/dialog_game_settings.py b/src/ui/dialog_game_settings.py index 363e611..ce5df1e 100644 --- a/src/ui/dialog_game_settings.py +++ b/src/ui/dialog_game_settings.py @@ -6,9 +6,11 @@ from PySide6.QtWidgets import ( QDialog, QDialogButtonBox, QGroupBox, + QHBoxLayout, QLabel, QListWidget, QListWidgetItem, + QSpinBox, QVBoxLayout, QWidget, ) @@ -89,12 +91,48 @@ class GameSettingsDialog(QDialog): bonus_layout.addWidget(self.bonus_cb) layout.addWidget(self.bonus_group) + # Pruning override + self.prune_group = QGroupBox("Override pruning") + self.prune_group.setCheckable(True) + gs_prune_active = self.settings.prune_exclude is not None or self.settings.prune_keep_count is not None + self.prune_group.setChecked(gs_prune_active) + prune_layout = QVBoxLayout(self.prune_group) + + self.prune_exclude_cb = QCheckBox("Exclude from global pruning") + self.prune_exclude_cb.setChecked(bool(self.settings.prune_exclude)) + prune_layout.addWidget(self.prune_exclude_cb) + + keep_row = QHBoxLayout() + self.prune_keep_cb = QCheckBox("Override keep count:") + self.prune_keep_cb.setChecked(self.settings.prune_keep_count is not None) + keep_row.addWidget(self.prune_keep_cb) + self.prune_keep_spin = QSpinBox() + self.prune_keep_spin.setRange(1, 20) + self.prune_keep_spin.setValue( + self.settings.prune_keep_count if self.settings.prune_keep_count is not None + else self.config.prune_keep_count + ) + self.prune_keep_spin.setFixedWidth(60) + self.prune_keep_spin.setEnabled(self.prune_keep_cb.isChecked()) + keep_row.addWidget(self.prune_keep_spin) + keep_row.addStretch() + prune_layout.addLayout(keep_row) + + self.prune_exclude_cb.toggled.connect(self._on_prune_exclude_toggled) + self.prune_keep_cb.toggled.connect(self.prune_keep_spin.setEnabled) + layout.addWidget(self.prune_group) + # Buttons buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(self._accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) + def _on_prune_exclude_toggled(self, checked: bool) -> None: + if checked: + self.prune_keep_cb.setChecked(False) + self.prune_keep_spin.setEnabled(False) + def _accept(self) -> None: # English only english_only: bool | None = None @@ -118,10 +156,21 @@ class GameSettingsDialog(QDialog): if self.bonus_group.isChecked(): include_bonus = self.bonus_cb.isChecked() + # Pruning + prune_exclude: bool | None = None + prune_keep_count: int | None = None + if self.prune_group.isChecked(): + if self.prune_exclude_cb.isChecked(): + prune_exclude = True + if self.prune_keep_cb.isChecked() and not self.prune_exclude_cb.isChecked(): + prune_keep_count = self.prune_keep_spin.value() + new_settings = GameSettings( languages=languages, english_only=english_only, include_bonus=include_bonus, + prune_exclude=prune_exclude, + prune_keep_count=prune_keep_count, ) self.config.set_game_settings(self.game_id, new_settings) logger.info(f"Per-game settings saved for {self.game_id}: {new_settings}") diff --git a/src/ui/main_window.py b/src/ui/main_window.py index 90bf640..bfd29f4 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -10,6 +10,7 @@ from src.constants import APP_TITLE from src.ui.tab_auth import AuthTab from src.ui.tab_languages import LanguagesTab from src.ui.tab_library import LibraryTab +from src.ui.tab_prune import PruneTab from src.ui.tab_settings import SettingsTab from src.ui.tab_status import StatusTab @@ -38,12 +39,14 @@ class MainWindow(QMainWindow): self.languages_tab = LanguagesTab(self.config) self.settings_tab = SettingsTab(self.api, self.config) self.status_tab = StatusTab(self.api, self.config) + self.prune_tab = PruneTab(self.config) self.tabs.addTab(self.auth_tab, "Login") self.tabs.addTab(self.library_tab, "Library") self.tabs.addTab(self.languages_tab, "Languages") self.tabs.addTab(self.settings_tab, "Settings") self.tabs.addTab(self.status_tab, "Status") + self.tabs.addTab(self.prune_tab, "Pruning") self._update_tab_states() diff --git a/src/ui/tab_prune.py b/src/ui/tab_prune.py new file mode 100644 index 0000000..447206c --- /dev/null +++ b/src/ui/tab_prune.py @@ -0,0 +1,293 @@ +"""Pruning tab — manage how many old installer versions to keep.""" + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QButtonGroup, + QGroupBox, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QRadioButton, + QSpinBox, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) +from loguru import logger + +from src.config import AppConfig, MetadataStore +from src.models import PruneStrategy + +COL_GAME = 0 +COL_WIN = 1 +COL_LIN = 2 +COL_OVERRIDE = 3 +COL_BTN = 4 + +_STRATEGY_LABELS = { + PruneStrategy.LATEST_N: "Latest N only", + PruneStrategy.LATEST_N_OLDEST: "Latest N + oldest", + PruneStrategy.YEARLY: "Latest N + one per year", + PruneStrategy.LATEST_N_YEARLY_OLDEST: "Latest N + one per year + oldest", +} + + +class PruneTab(QWidget): + """Tab for configuring and running installer version pruning.""" + + def __init__(self, config: AppConfig, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.config = config + self._setup_ui() + + def _setup_ui(self) -> None: + layout = QVBoxLayout(self) + + # --- Global settings --- + settings_group = QGroupBox("Global Pruning Settings") + sg_layout = QVBoxLayout(settings_group) + + keep_row = QHBoxLayout() + keep_row.addWidget(QLabel("Keep last")) + self.keep_spin = QSpinBox() + self.keep_spin.setRange(1, 20) + self.keep_spin.setValue(self.config.prune_keep_count) + self.keep_spin.setFixedWidth(60) + keep_row.addWidget(self.keep_spin) + keep_row.addWidget(QLabel("version(s)")) + keep_row.addStretch() + sg_layout.addLayout(keep_row) + + strategy_row = QHBoxLayout() + strategy_row.addWidget(QLabel("Strategy:")) + self._strategy_group = QButtonGroup(self) + for strategy, label in _STRATEGY_LABELS.items(): + rb = QRadioButton(label) + rb.setProperty("strategy", strategy) + rb.setChecked(strategy == self.config.prune_strategy) + self._strategy_group.addButton(rb) + strategy_row.addWidget(rb) + strategy_row.addStretch() + sg_layout.addLayout(strategy_row) + + layout.addWidget(settings_group) + + # --- Action buttons --- + btn_row = QHBoxLayout() + refresh_btn = QPushButton("Refresh") + refresh_btn.setFixedWidth(100) + refresh_btn.clicked.connect(self.refresh) + btn_row.addWidget(refresh_btn) + btn_row.addStretch() + prune_all_btn = QPushButton("Prune All") + prune_all_btn.setFixedWidth(120) + prune_all_btn.clicked.connect(self._prune_all) + btn_row.addWidget(prune_all_btn) + layout.addLayout(btn_row) + + # --- Game list --- + self.tree = QTreeWidget() + self.tree.setColumnCount(5) + self.tree.setHeaderLabels(["Game", "Windows", "Linux", "Override", ""]) + self.tree.header().setSectionResizeMode(COL_GAME, QHeaderView.ResizeMode.Stretch) + self.tree.header().setSectionResizeMode(COL_WIN, QHeaderView.ResizeMode.ResizeToContents) + self.tree.header().setSectionResizeMode(COL_LIN, QHeaderView.ResizeMode.ResizeToContents) + self.tree.header().setSectionResizeMode(COL_OVERRIDE, QHeaderView.ResizeMode.ResizeToContents) + self.tree.header().setSectionResizeMode(COL_BTN, QHeaderView.ResizeMode.Fixed) + self.tree.header().resizeSection(COL_BTN, 80) + self.tree.setSortingEnabled(True) + layout.addWidget(self.tree) + + self.status_label = QLabel("") + layout.addWidget(self.status_label) + + # Connect settings signals after UI is built + self.keep_spin.valueChanged.connect(self._on_keep_count_changed) + self._strategy_group.buttonToggled.connect(self._on_strategy_changed) + + # --- Settings persistence --- + + def _on_keep_count_changed(self, value: int) -> None: + self.config.prune_keep_count = value + self.config.save() + + def _on_strategy_changed(self) -> None: + for btn in self._strategy_group.buttons(): + if btn.isChecked(): + self.config.prune_strategy = btn.property("strategy") + self.config.save() + return + + # --- Game list --- + + def refresh(self) -> None: + """Reload local metadata and repopulate the game list.""" + self.tree.setSortingEnabled(False) + self.tree.clear() + + win_store = MetadataStore(self.config.windows_path) if self.config.windows_path else None + lin_store = MetadataStore(self.config.linux_path) if self.config.linux_path else None + + all_games: dict[str, str] = {} + if win_store: + for gid, rec in win_store.games.items(): + all_games[gid] = rec.name + if lin_store: + for gid, rec in lin_store.games.items(): + all_games.setdefault(gid, rec.name) + + for game_id, name in sorted(all_games.items(), key=lambda x: x[1].lower()): + win_rec = win_store.games.get(game_id) if win_store else None + lin_rec = lin_store.games.get(game_id) if lin_store else None + win_versions = sorted({i.version for i in win_rec.installers}) if win_rec else [] + lin_versions = sorted({i.version for i in lin_rec.installers}) if lin_rec else [] + + gs = self.config.get_game_settings(game_id) + + item = QTreeWidgetItem() + item.setText(COL_GAME, name) + item.setData(COL_GAME, Qt.ItemDataRole.UserRole, game_id) + item.setText(COL_WIN, self._format_versions(win_versions)) + item.setText(COL_LIN, self._format_versions(lin_versions)) + + if gs.prune_exclude: + item.setText(COL_OVERRIDE, "Excluded") + elif gs.prune_keep_count is not None: + item.setText(COL_OVERRIDE, f"Keep {gs.prune_keep_count}") + else: + item.setText(COL_OVERRIDE, "") + + self.tree.addTopLevelItem(item) + + effective_keep = gs.prune_keep_count if gs.prune_keep_count is not None else self.config.prune_keep_count + can_prune = (len(win_versions) > effective_keep or len(lin_versions) > effective_keep) and not gs.prune_exclude + btn = QPushButton("Excluded" if gs.prune_exclude else "Prune") + btn.setEnabled(can_prune) + btn.clicked.connect(lambda _checked=False, gid=game_id: self._prune_game(gid)) + self.tree.setItemWidget(item, COL_BTN, btn) + + self.tree.setSortingEnabled(True) + self.status_label.setText(f"Showing {self.tree.topLevelItemCount()} game(s) with local installers.") + + @staticmethod + def _format_versions(versions: list[str]) -> str: + if not versions: + return "—" + if len(versions) <= 4: + return ", ".join(versions) + return f"{', '.join(versions[:3])}, … ({len(versions)} total)" + + # --- Prune actions --- + + def _effective_keep(self, game_id: str) -> int: + gs = self.config.get_game_settings(game_id) + return gs.prune_keep_count if gs.prune_keep_count is not None else self.config.prune_keep_count + + def _prune_preview( + self, game_id: str, keep: int, strategy: PruneStrategy + ) -> list[tuple[str, list[str], list[str]]]: + """Return [(platform_label, to_keep, to_delete)] — each platform evaluated independently.""" + result = [] + for label, path_str in [("Windows", self.config.windows_path), ("Linux", self.config.linux_path)]: + if not path_str: + continue + store = MetadataStore(path_str) + record = store.games.get(game_id) + if not record: + continue + all_versions = sorted({i.version for i in record.installers}) + if not all_versions: + continue + keep_set = MetadataStore._select_versions_to_keep(record, all_versions, keep, strategy) + result.append(( + label, + [v for v in all_versions if v in keep_set], + [v for v in all_versions if v not in keep_set], + )) + return result + + def _prune_game(self, game_id: str) -> None: + gs = self.config.get_game_settings(game_id) + if gs.prune_exclude: + return + + keep = self._effective_keep(game_id) + strategy = self.config.prune_strategy + name = self._game_name(game_id) + preview = self._prune_preview(game_id, keep, strategy) + + if not any(to_delete for _, _, to_delete in preview): + self.status_label.setText(f"'{name}': nothing to prune.") + return + + lines = [f"Prune '{name}'?", f"Strategy: {_STRATEGY_LABELS[strategy]}", ""] + for platform_label, to_keep, to_delete in preview: + lines.append(f"{platform_label}:") + lines.append(f" Keep: {', '.join(to_keep) or '—'}") + if to_delete: + lines.append(f" Delete: {', '.join(to_delete)}") + lines.append("") + + reply = QMessageBox.question( + self, + "Prune", + "\n".join(lines).rstrip(), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + total = 0 + for path_str in [self.config.windows_path, self.config.linux_path]: + if path_str: + store = MetadataStore(path_str) + deleted = store.prune_old_versions(game_id, keep, strategy) + total += len(deleted) + if deleted: + logger.info(f"Pruned {len(deleted)} folder(s) for '{name}' in {path_str}") + + self.status_label.setText(f"Pruned {total} version folder(s) for '{name}'.") + self.refresh() + + def _prune_all(self) -> None: + keep_count = self.config.prune_keep_count + strategy = self.config.prune_strategy + + reply = QMessageBox.question( + self, + "Prune All", + f"Run global pruning for all non-excluded games?\n\nKeep: {keep_count} most recent ({_STRATEGY_LABELS[strategy]})", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + total_folders = 0 + total_games = 0 + + for path_str in [self.config.windows_path, self.config.linux_path]: + if not path_str: + continue + store = MetadataStore(path_str) + for game_id in list(store.games.keys()): + gs = self.config.get_game_settings(game_id) + if gs.prune_exclude: + continue + effective_keep = gs.prune_keep_count if gs.prune_keep_count is not None else keep_count + deleted = store.prune_old_versions(game_id, effective_keep, strategy) + if deleted: + total_folders += len(deleted) + total_games += 1 + + self.status_label.setText(f"Pruned {total_folders} folder(s) from {total_games} game(s).") + self.refresh() + + def _game_name(self, game_id: str) -> str: + for i in range(self.tree.topLevelItemCount()): + item = self.tree.topLevelItem(i) + if item and item.data(COL_GAME, Qt.ItemDataRole.UserRole) == game_id: + return item.text(COL_GAME) + return game_id diff --git a/src/ui/tab_status.py b/src/ui/tab_status.py index d3a71b8..4a1cecd 100644 --- a/src/ui/tab_status.py +++ b/src/ui/tab_status.py @@ -6,7 +6,6 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QBrush, QColor from PySide6.QtWidgets import ( QCheckBox, - QComboBox, QHBoxLayout, QHeaderView, QLabel, @@ -90,15 +89,6 @@ class StatusTab(QWidget): self.download_btn.setEnabled(False) controls.addWidget(self.download_btn) - - self.prune_btn = QPushButton("Prune Old Versions") - self.prune_btn.clicked.connect(self.prune_versions) - controls.addWidget(self.prune_btn) - - self.prune_keep_combo = QComboBox() - self.prune_keep_combo.addItems(["Keep 1", "Keep 2", "Keep 3"]) - controls.addWidget(self.prune_keep_combo) - self.show_unknown_cb = QCheckBox("Show unversioned/unknown") self.show_unknown_cb.setChecked(False) self.show_unknown_cb.toggled.connect(self._repopulate_current) @@ -584,28 +574,3 @@ class StatusTab(QWidget): dialog = GameVersionsDialog(game_id, game_name, metadata, self) dialog.exec() - def prune_versions(self) -> None: - """Remove old installer versions for the currently visible platform.""" - platform = self._current_platform() - base_path = self._base_path_for(platform) - if not base_path: - self.status_label.setText("No path configured for this platform.") - return - - keep = self.prune_keep_combo.currentIndex() + 1 - reply = QMessageBox.question( - self, - "Prune Old Versions", - f"This will delete all but the {keep} most recent version(s) of each managed game " - f"in the {platform.value.capitalize()} path.\n\nContinue?", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if reply != QMessageBox.StandardButton.Yes: - return - - metadata = MetadataStore(base_path) - total_deleted = sum( - len(metadata.prune_old_versions(game_id, keep_latest=keep)) - for game_id in self.config.managed_game_ids - ) - self.status_label.setText(f"Pruned {total_deleted} old version(s).")