Add dedicated Pruning tab with strategies and per-game overrides
This commit is contained in:
@@ -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
|
||||
|
||||
+1
-1
@@ -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"}
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
"""Auto-generated — do not edit manually."""
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
+65
-10
@@ -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.
|
||||
|
||||
|
||||
+22
-1
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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).")
|
||||
|
||||
Reference in New Issue
Block a user