diff --git a/src/illogical/modules/backup_manager.py b/src/illogical/modules/backup_manager.py index 8bc614d..b2ae5a5 100644 --- a/src/illogical/modules/backup_manager.py +++ b/src/illogical/modules/backup_manager.py @@ -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) diff --git a/src/illogical/modules/backup_models.py b/src/illogical/modules/backup_models.py index 1fd6172..90b43ab 100644 --- a/src/illogical/modules/backup_models.py +++ b/src/illogical/modules/backup_models.py @@ -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) diff --git a/src/illogical/modules/backup_service.py b/src/illogical/modules/backup_service.py index 8684f99..b060b21 100644 --- a/src/illogical/modules/backup_service.py +++ b/src/illogical/modules/backup_service.py @@ -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() diff --git a/src/illogical/ui/main_window.py b/src/illogical/ui/main_window.py index 35e8c2a..3c80216 100644 --- a/src/illogical/ui/main_window.py +++ b/src/illogical/ui/main_window.py @@ -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) diff --git a/src/illogical/ui/restore_backup_window.py b/src/illogical/ui/restore_backup_window.py index 423930d..02940ed 100644 --- a/src/illogical/ui/restore_backup_window.py +++ b/src/illogical/ui/restore_backup_window.py @@ -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: