Add dedicated Pruning tab with strategies and per-game overrides
This commit is contained in:
@@ -1,5 +1,18 @@
|
|||||||
# Changelog
|
# 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
|
## [1.0.1] — 2026-05-12
|
||||||
|
|
||||||
### Improvements
|
### Improvements
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "gogupdater"
|
name = "gogupdater"
|
||||||
version = "1.0.1"
|
version = "1.1.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
|
{name = "Jan Doubravský",email = "jan.doubravsky@gmail.com"}
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
"""Auto-generated — do not edit manually."""
|
"""Auto-generated — do not edit manually."""
|
||||||
__version__ = "1.0.0"
|
__version__ = "1.1.0"
|
||||||
|
|||||||
+64
-9
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from loguru import logger
|
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"
|
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "gogupdater"
|
||||||
METADATA_FILENAME = "gogupdater.json"
|
METADATA_FILENAME = "gogupdater.json"
|
||||||
@@ -25,6 +25,8 @@ class AppConfig:
|
|||||||
self.include_bonus: bool = False
|
self.include_bonus: bool = False
|
||||||
self.game_settings: dict[str, GameSettings] = {}
|
self.game_settings: dict[str, GameSettings] = {}
|
||||||
self.version_decisions: dict[str, bool] = {}
|
self.version_decisions: dict[str, bool] = {}
|
||||||
|
self.prune_keep_count: int = 1
|
||||||
|
self.prune_strategy: PruneStrategy = PruneStrategy.LATEST_N
|
||||||
self._load()
|
self._load()
|
||||||
|
|
||||||
def _load(self) -> None:
|
def _load(self) -> None:
|
||||||
@@ -43,6 +45,11 @@ class AppConfig:
|
|||||||
for gid, gs in data.get("game_settings", {}).items()
|
for gid, gs in data.get("game_settings", {}).items()
|
||||||
}
|
}
|
||||||
self.version_decisions = data.get("version_decisions", {})
|
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):
|
except (json.JSONDecodeError, OSError):
|
||||||
logger.warning("Failed to read config.json, using defaults")
|
logger.warning("Failed to read config.json, using defaults")
|
||||||
|
|
||||||
@@ -61,6 +68,8 @@ class AppConfig:
|
|||||||
if not gs.is_default()
|
if not gs.is_default()
|
||||||
},
|
},
|
||||||
"version_decisions": self.version_decisions,
|
"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")
|
self.config_file.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
@@ -149,33 +158,79 @@ class MetadataStore:
|
|||||||
return []
|
return []
|
||||||
return list({i.version for i in record.installers})
|
return list({i.version for i in record.installers})
|
||||||
|
|
||||||
def prune_old_versions(self, game_id: str, keep_latest: int = 1) -> list[Path]:
|
def prune_old_versions(
|
||||||
"""Remove old version directories, keeping the N most recent. Returns deleted paths."""
|
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)
|
record = self.games.get(game_id)
|
||||||
if not record:
|
if not record:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
versions = sorted({i.version for i in record.installers})
|
all_versions = sorted({i.version for i in record.installers})
|
||||||
if len(versions) <= keep_latest:
|
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 []
|
return []
|
||||||
|
|
||||||
versions_to_remove = versions[:-keep_latest]
|
import shutil
|
||||||
deleted: list[Path] = []
|
|
||||||
|
|
||||||
|
deleted: list[Path] = []
|
||||||
for version in versions_to_remove:
|
for version in versions_to_remove:
|
||||||
version_dir = self.base_path / record.name / version
|
version_dir = self.base_path / record.name / version
|
||||||
if version_dir.exists():
|
if version_dir.exists():
|
||||||
import shutil
|
|
||||||
|
|
||||||
shutil.rmtree(version_dir)
|
shutil.rmtree(version_dir)
|
||||||
deleted.append(version_dir)
|
deleted.append(version_dir)
|
||||||
logger.info(f"Pruned {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()
|
self.save()
|
||||||
return deleted
|
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:
|
def verify_metadata(self) -> int:
|
||||||
"""Verify that recorded files still exist on disk.
|
"""Verify that recorded files still exist on disk.
|
||||||
|
|
||||||
|
|||||||
+22
-1
@@ -87,6 +87,13 @@ class GameStatus(Enum):
|
|||||||
UNVERSIONED = "unversioned"
|
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
|
@dataclass
|
||||||
class InstallerInfo:
|
class InstallerInfo:
|
||||||
"""Installer metadata from GOG API."""
|
"""Installer metadata from GOG API."""
|
||||||
@@ -276,6 +283,8 @@ class GameSettings:
|
|||||||
languages: list[str] | None = None
|
languages: list[str] | None = None
|
||||||
english_only: bool | None = None
|
english_only: bool | None = None
|
||||||
include_bonus: 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:
|
def to_dict(self) -> dict:
|
||||||
data: dict = {}
|
data: dict = {}
|
||||||
@@ -285,6 +294,10 @@ class GameSettings:
|
|||||||
data["english_only"] = self.english_only
|
data["english_only"] = self.english_only
|
||||||
if self.include_bonus is not None:
|
if self.include_bonus is not None:
|
||||||
data["include_bonus"] = self.include_bonus
|
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
|
return data
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -293,11 +306,19 @@ class GameSettings:
|
|||||||
languages=data.get("languages"),
|
languages=data.get("languages"),
|
||||||
english_only=data.get("english_only"),
|
english_only=data.get("english_only"),
|
||||||
include_bonus=data.get("include_bonus"),
|
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:
|
def is_default(self) -> bool:
|
||||||
"""True if all values are None (no overrides)."""
|
"""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
|
@dataclass
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ from PySide6.QtWidgets import (
|
|||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QGroupBox,
|
QGroupBox,
|
||||||
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
QListWidgetItem,
|
QListWidgetItem,
|
||||||
|
QSpinBox,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
@@ -89,12 +91,48 @@ class GameSettingsDialog(QDialog):
|
|||||||
bonus_layout.addWidget(self.bonus_cb)
|
bonus_layout.addWidget(self.bonus_cb)
|
||||||
layout.addWidget(self.bonus_group)
|
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
|
||||||
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
|
||||||
buttons.accepted.connect(self._accept)
|
buttons.accepted.connect(self._accept)
|
||||||
buttons.rejected.connect(self.reject)
|
buttons.rejected.connect(self.reject)
|
||||||
layout.addWidget(buttons)
|
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:
|
def _accept(self) -> None:
|
||||||
# English only
|
# English only
|
||||||
english_only: bool | None = None
|
english_only: bool | None = None
|
||||||
@@ -118,10 +156,21 @@ class GameSettingsDialog(QDialog):
|
|||||||
if self.bonus_group.isChecked():
|
if self.bonus_group.isChecked():
|
||||||
include_bonus = self.bonus_cb.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(
|
new_settings = GameSettings(
|
||||||
languages=languages,
|
languages=languages,
|
||||||
english_only=english_only,
|
english_only=english_only,
|
||||||
include_bonus=include_bonus,
|
include_bonus=include_bonus,
|
||||||
|
prune_exclude=prune_exclude,
|
||||||
|
prune_keep_count=prune_keep_count,
|
||||||
)
|
)
|
||||||
self.config.set_game_settings(self.game_id, new_settings)
|
self.config.set_game_settings(self.game_id, new_settings)
|
||||||
logger.info(f"Per-game settings saved for {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_auth import AuthTab
|
||||||
from src.ui.tab_languages import LanguagesTab
|
from src.ui.tab_languages import LanguagesTab
|
||||||
from src.ui.tab_library import LibraryTab
|
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_settings import SettingsTab
|
||||||
from src.ui.tab_status import StatusTab
|
from src.ui.tab_status import StatusTab
|
||||||
|
|
||||||
@@ -38,12 +39,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.languages_tab = LanguagesTab(self.config)
|
self.languages_tab = LanguagesTab(self.config)
|
||||||
self.settings_tab = SettingsTab(self.api, self.config)
|
self.settings_tab = SettingsTab(self.api, self.config)
|
||||||
self.status_tab = StatusTab(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.auth_tab, "Login")
|
||||||
self.tabs.addTab(self.library_tab, "Library")
|
self.tabs.addTab(self.library_tab, "Library")
|
||||||
self.tabs.addTab(self.languages_tab, "Languages")
|
self.tabs.addTab(self.languages_tab, "Languages")
|
||||||
self.tabs.addTab(self.settings_tab, "Settings")
|
self.tabs.addTab(self.settings_tab, "Settings")
|
||||||
self.tabs.addTab(self.status_tab, "Status")
|
self.tabs.addTab(self.status_tab, "Status")
|
||||||
|
self.tabs.addTab(self.prune_tab, "Pruning")
|
||||||
|
|
||||||
self._update_tab_states()
|
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.QtGui import QBrush, QColor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
QLabel,
|
QLabel,
|
||||||
@@ -90,15 +89,6 @@ class StatusTab(QWidget):
|
|||||||
self.download_btn.setEnabled(False)
|
self.download_btn.setEnabled(False)
|
||||||
controls.addWidget(self.download_btn)
|
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 = QCheckBox("Show unversioned/unknown")
|
||||||
self.show_unknown_cb.setChecked(False)
|
self.show_unknown_cb.setChecked(False)
|
||||||
self.show_unknown_cb.toggled.connect(self._repopulate_current)
|
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 = GameVersionsDialog(game_id, game_name, metadata, self)
|
||||||
dialog.exec()
|
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