feat: better change list in backups

This commit is contained in:
h
2026-01-28 00:55:44 +01:00
parent 9ad0794872
commit 080d39199e
5 changed files with 244 additions and 18 deletions

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import json
import plistlib
import shutil
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
@@ -14,11 +15,17 @@ from illogical.modules.backup_models import (
BackupManifest,
BackupSettings,
BackupTrigger,
ChangeType,
DetailedBackupChanges,
FieldChange,
PluginChange,
)
if TYPE_CHECKING:
from pathlib import Path
from logic_plugin_manager import Logic
MANIFEST_VERSION = 1
MANIFEST_FILENAME = "manifest.json"
BACKUP_INDEX_FILENAME = ".backup_index.json"
@@ -284,3 +291,117 @@ def should_create_auto_backup(min_interval_seconds: int = 300) -> bool:
return True
elapsed = (datetime.now(UTC) - last_auto).total_seconds()
return elapsed >= min_interval_seconds
def _parse_tagset_plist(path: Path) -> dict | None:
try:
with path.open("rb") as f:
return plistlib.load(f)
except (plistlib.InvalidFileException, OSError):
return None
def _compute_field_changes(
old_data: dict | None, new_data: dict | None
) -> list[FieldChange]:
changes: list[FieldChange] = []
old_data = old_data or {}
new_data = new_data or {}
backup_nickname = old_data.get("nickname", "")
current_nickname = new_data.get("nickname", "")
if backup_nickname != current_nickname:
changes.append(
FieldChange("nickname", current_nickname or None, backup_nickname or None)
)
backup_shortname = old_data.get("shortname", "")
current_shortname = new_data.get("shortname", "")
if backup_shortname != current_shortname:
changes.append(
FieldChange(
"shortname", current_shortname or None, backup_shortname or None
)
)
backup_tags = set(old_data.get("tags", {}).keys())
current_tags = set(new_data.get("tags", {}).keys())
changes.extend(
FieldChange(f"category:{tag}", None, "removed")
for tag in current_tags - backup_tags
)
changes.extend(
FieldChange(f"category:{tag}", None, "added")
for tag in backup_tags - current_tags
)
return changes
def _resolve_plugin_name(tags_id: str, logic: Logic | None) -> str:
if logic is None:
return tags_id
plugin = logic.plugins.get_by_tags_id(tags_id)
if plugin:
return plugin.name
return tags_id
def _tags_id_from_filename(filename: str) -> str:
return filename.removesuffix(".tagset")
def compute_detailed_changes(
backup_name: str, logic: Logic | None = None
) -> DetailedBackupChanges:
backup_path = BACKUPS_PATH / backup_name
if not backup_path.exists():
return DetailedBackupChanges()
backup_manifest = _load_manifest(backup_path)
if not backup_manifest:
return DetailedBackupChanges()
current_files = _get_backup_files()
current_checksums = {f.name: _compute_file_checksum(f) for f in current_files}
backup_checksums = backup_manifest.checksums
added_files = [f for f in current_checksums if f not in backup_checksums]
deleted_files = [f for f in backup_checksums if f not in current_checksums]
modified_files = [
f
for f in current_checksums
if f in backup_checksums and current_checksums[f] != backup_checksums[f]
]
plugin_changes: list[PluginChange] = []
for filename in added_files:
tags_id = _tags_id_from_filename(filename)
plugin_name = _resolve_plugin_name(tags_id, logic)
plugin_changes.append(PluginChange(tags_id, plugin_name, ChangeType.ADDED))
for filename in deleted_files:
tags_id = _tags_id_from_filename(filename)
plugin_name = _resolve_plugin_name(tags_id, logic)
plugin_changes.append(PluginChange(tags_id, plugin_name, ChangeType.DELETED))
for filename in modified_files:
tags_id = _tags_id_from_filename(filename)
plugin_name = _resolve_plugin_name(tags_id, logic)
current_path = TAGS_PATH / filename
backup_file_path = backup_path / filename
current_data = _parse_tagset_plist(current_path)
backup_data = _parse_tagset_plist(backup_file_path)
field_changes = _compute_field_changes(backup_data, current_data)
plugin_changes.append(
PluginChange(tags_id, plugin_name, ChangeType.MODIFIED, field_changes)
)
return DetailedBackupChanges(plugins=plugin_changes)

View File

@@ -14,6 +14,48 @@ class BackupTrigger(Enum):
AUTO = "auto"
class ChangeType(Enum):
ADDED = "added"
MODIFIED = "modified"
DELETED = "deleted"
@dataclass
class FieldChange:
field_name: str
old_value: str | None
new_value: str | None
@dataclass
class PluginChange:
tags_id: str
plugin_name: str
change_type: ChangeType
field_changes: list[FieldChange] = field(default_factory=list)
@dataclass
class DetailedBackupChanges:
plugins: list[PluginChange] = field(default_factory=list)
@property
def added(self) -> list[PluginChange]:
return [p for p in self.plugins if p.change_type == ChangeType.ADDED]
@property
def modified(self) -> list[PluginChange]:
return [p for p in self.plugins if p.change_type == ChangeType.MODIFIED]
@property
def deleted(self) -> list[PluginChange]:
return [p for p in self.plugins if p.change_type == ChangeType.DELETED]
@property
def is_empty(self) -> bool:
return len(self.plugins) == 0
@dataclass
class BackupChanges:
added: list[str] = field(default_factory=list)

View File

@@ -1,12 +1,16 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from PySide6.QtCore import QObject, QThread, Signal
from illogical.modules import backup_manager
from illogical.modules.backup_models import BackupSettings, BackupTrigger
if TYPE_CHECKING:
from logic_plugin_manager import Logic
logger = logging.getLogger(__name__)
@@ -15,10 +19,18 @@ class BackupWorker(QObject):
backup_list_ready = Signal(list)
restore_completed = Signal(bool, str)
changes_computed = Signal(str, object)
detailed_changes_computed = Signal(str, object)
storage_usage_ready = Signal(int, int)
purge_completed = Signal(int)
error_occurred = Signal(str)
def __init__(self) -> None:
super().__init__()
self._logic: Logic | None = None
def set_logic(self, logic: Logic) -> None:
self._logic = logic
def create_backup(
self, trigger: BackupTrigger = BackupTrigger.MANUAL, description: str = ""
) -> None:
@@ -53,6 +65,14 @@ class BackupWorker(QObject):
logger.exception("Computing changes failed")
self.error_occurred.emit(str(e))
def compute_detailed_changes(self, backup_name: str) -> None:
try:
changes = backup_manager.compute_detailed_changes(backup_name, self._logic)
self.detailed_changes_computed.emit(backup_name, changes)
except OSError as e:
logger.exception("Computing detailed changes failed")
self.error_occurred.emit(str(e))
def get_storage_usage(self) -> None:
try:
total_bytes, count = backup_manager.get_storage_usage()
@@ -86,6 +106,7 @@ class BackupService(QObject):
backup_list_ready = Signal(list)
restore_completed = Signal(bool, str)
changes_computed = Signal(str, object)
detailed_changes_computed = Signal(str, object)
storage_usage_ready = Signal(int, int)
purge_completed = Signal(int)
error_occurred = Signal(str)
@@ -100,6 +121,7 @@ class BackupService(QObject):
self._worker.backup_list_ready.connect(self.backup_list_ready)
self._worker.restore_completed.connect(self.restore_completed)
self._worker.changes_computed.connect(self.changes_computed)
self._worker.detailed_changes_computed.connect(self.detailed_changes_computed)
self._worker.storage_usage_ready.connect(self.storage_usage_ready)
self._worker.purge_completed.connect(self.purge_completed)
self._worker.error_occurred.connect(self.error_occurred)
@@ -120,6 +142,12 @@ class BackupService(QObject):
def compute_changes(self, backup_name: str) -> None:
self._worker.compute_changes(backup_name)
def compute_detailed_changes(self, backup_name: str) -> None:
self._worker.compute_detailed_changes(backup_name)
def set_logic(self, logic: Logic) -> None:
self._worker.set_logic(logic)
def get_storage_usage(self) -> None:
self._worker.get_storage_usage()

View File

@@ -24,9 +24,9 @@ if TYPE_CHECKING:
from PySide6.QtGui import QCloseEvent, QKeyEvent, QShowEvent
from illogical.modules.backup_models import (
BackupChanges,
BackupInfo,
BackupSettings,
DetailedBackupChanges,
)
@@ -101,7 +101,9 @@ class MainWindow(QMainWindow):
self._backup_service.backup_created.connect(self._on_backup_created)
self._backup_service.backup_list_ready.connect(self._on_backup_list_ready)
self._backup_service.restore_completed.connect(self._on_restore_completed)
self._backup_service.changes_computed.connect(self._on_changes_computed)
self._backup_service.detailed_changes_computed.connect(
self._on_detailed_changes_computed
)
self._backup_service.storage_usage_ready.connect(self._on_storage_usage_ready)
self._backup_service.purge_completed.connect(self._on_purge_completed)
self._backup_service.error_occurred.connect(self._on_backup_error)
@@ -130,6 +132,7 @@ class MainWindow(QMainWindow):
def _on_plugins_loaded(self, logic: Logic) -> None:
self._logic = logic
self._backup_service.set_logic(logic)
self._sidebar.populate(logic)
self._plugin_table.set_plugins(logic)
self._loading_overlay.hide()
@@ -213,11 +216,13 @@ class MainWindow(QMainWindow):
self._restore_window.set_backups(backups)
def _on_restore_backup_selected(self, backup_name: str) -> None:
self._backup_service.compute_changes(backup_name)
self._backup_service.compute_detailed_changes(backup_name)
def _on_changes_computed(self, backup_name: str, changes: BackupChanges) -> None:
def _on_detailed_changes_computed(
self, backup_name: str, changes: DetailedBackupChanges
) -> None:
if self._restore_window:
self._restore_window.set_changes(backup_name, changes)
self._restore_window.set_detailed_changes(backup_name, changes)
def _on_restore_requested(self, backup_name: str) -> None:
self._backup_service.restore_backup(backup_name)

View File

@@ -16,7 +16,14 @@ from PySide6.QtWidgets import (
QWidget,
)
from illogical.modules.backup_models import BackupChanges, BackupInfo, BackupTrigger
from illogical.modules.backup_models import (
BackupInfo,
BackupTrigger,
ChangeType,
DetailedBackupChanges,
FieldChange,
PluginChange,
)
from illogical.modules.sf_symbols import sf_symbol
@@ -27,7 +34,7 @@ class RestoreBackupWindow(QDialog):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._backups: list[BackupInfo] = []
self._changes_cache: dict[str, BackupChanges] = {}
self._changes_cache: dict[str, DetailedBackupChanges] = {}
self._setup_ui()
def _setup_ui(self) -> None:
@@ -143,14 +150,16 @@ class RestoreBackupWindow(QDialog):
self.backup_selected.emit(backup_name)
def set_changes(self, backup_name: str, changes: BackupChanges) -> None:
def set_detailed_changes(
self, backup_name: str, changes: DetailedBackupChanges
) -> None:
self._changes_cache[backup_name] = changes
current = self._backup_list.currentItem()
if current and current.data(Qt.ItemDataRole.UserRole) == backup_name:
self._display_changes(changes)
def _display_changes(self, changes: BackupChanges) -> None:
def _display_changes(self, changes: DetailedBackupChanges) -> None:
self._changes_tree.clear()
if changes.is_empty:
@@ -158,24 +167,45 @@ class RestoreBackupWindow(QDialog):
self._changes_tree.addTopLevelItem(item)
return
self._add_change_group(changes.added, "Files to remove", "minus.circle")
self._add_change_group(
changes.modified, "Files to revert", "arrow.uturn.backward.circle"
self._add_plugin_group(changes.added, "Plugins to remove", "minus.circle")
self._add_plugin_group(
changes.modified, "Plugins to revert", "arrow.uturn.backward.circle"
)
self._add_change_group(changes.deleted, "Files to restore", "plus.circle")
self._add_plugin_group(changes.deleted, "Plugins to restore", "plus.circle")
def _add_change_group(self, files: list[str], label: str, icon_name: str) -> None:
if not files:
def _add_plugin_group(
self, plugins: list[PluginChange], label: str, icon_name: str
) -> None:
if not plugins:
return
group_item = QTreeWidgetItem([f"{label} ({len(files)})"])
group_item = QTreeWidgetItem([f"{label} ({len(plugins)})"])
icon = sf_symbol(icon_name, 14)
if not icon.isNull():
group_item.setIcon(0, icon)
for filename in sorted(files):
QTreeWidgetItem(group_item, [filename])
for plugin in sorted(plugins, key=lambda p: p.plugin_name.lower()):
plugin_item = QTreeWidgetItem(group_item, [plugin.plugin_name])
if plugin.change_type == ChangeType.MODIFIED and plugin.field_changes:
for field_change in plugin.field_changes:
change_text = self._format_field_change(field_change)
QTreeWidgetItem(plugin_item, [change_text])
self._changes_tree.addTopLevelItem(group_item)
group_item.setExpanded(True)
def _format_field_change(self, change: FieldChange) -> str:
if change.field_name.startswith("category:"):
category = change.field_name.split(":", 1)[1]
if change.new_value == "added":
return f"+ {category}"
return f" {category}"
current = change.old_value or "(empty)"
restored = change.new_value or "(empty)"
return f"{change.field_name}: '{current}''{restored}'"
def _on_restore_clicked(self) -> None:
current = self._backup_list.currentItem()
if not current: