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 hashlib
import json import json
import plistlib
import shutil import shutil
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@@ -14,11 +15,17 @@ from illogical.modules.backup_models import (
BackupManifest, BackupManifest,
BackupSettings, BackupSettings,
BackupTrigger, BackupTrigger,
ChangeType,
DetailedBackupChanges,
FieldChange,
PluginChange,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
from logic_plugin_manager import Logic
MANIFEST_VERSION = 1 MANIFEST_VERSION = 1
MANIFEST_FILENAME = "manifest.json" MANIFEST_FILENAME = "manifest.json"
BACKUP_INDEX_FILENAME = ".backup_index.json" BACKUP_INDEX_FILENAME = ".backup_index.json"
@@ -284,3 +291,117 @@ def should_create_auto_backup(min_interval_seconds: int = 300) -> bool:
return True return True
elapsed = (datetime.now(UTC) - last_auto).total_seconds() elapsed = (datetime.now(UTC) - last_auto).total_seconds()
return elapsed >= min_interval_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" 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 @dataclass
class BackupChanges: class BackupChanges:
added: list[str] = field(default_factory=list) added: list[str] = field(default_factory=list)

View File

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

View File

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

View File

@@ -16,7 +16,14 @@ from PySide6.QtWidgets import (
QWidget, 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 from illogical.modules.sf_symbols import sf_symbol
@@ -27,7 +34,7 @@ class RestoreBackupWindow(QDialog):
def __init__(self, parent: QWidget | None = None) -> None: def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._backups: list[BackupInfo] = [] self._backups: list[BackupInfo] = []
self._changes_cache: dict[str, BackupChanges] = {} self._changes_cache: dict[str, DetailedBackupChanges] = {}
self._setup_ui() self._setup_ui()
def _setup_ui(self) -> None: def _setup_ui(self) -> None:
@@ -143,14 +150,16 @@ class RestoreBackupWindow(QDialog):
self.backup_selected.emit(backup_name) 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 self._changes_cache[backup_name] = changes
current = self._backup_list.currentItem() current = self._backup_list.currentItem()
if current and current.data(Qt.ItemDataRole.UserRole) == backup_name: if current and current.data(Qt.ItemDataRole.UserRole) == backup_name:
self._display_changes(changes) self._display_changes(changes)
def _display_changes(self, changes: BackupChanges) -> None: def _display_changes(self, changes: DetailedBackupChanges) -> None:
self._changes_tree.clear() self._changes_tree.clear()
if changes.is_empty: if changes.is_empty:
@@ -158,24 +167,45 @@ class RestoreBackupWindow(QDialog):
self._changes_tree.addTopLevelItem(item) self._changes_tree.addTopLevelItem(item)
return return
self._add_change_group(changes.added, "Files to remove", "minus.circle") self._add_plugin_group(changes.added, "Plugins to remove", "minus.circle")
self._add_change_group( self._add_plugin_group(
changes.modified, "Files to revert", "arrow.uturn.backward.circle" 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: def _add_plugin_group(
if not files: self, plugins: list[PluginChange], label: str, icon_name: str
) -> None:
if not plugins:
return return
group_item = QTreeWidgetItem([f"{label} ({len(files)})"])
group_item = QTreeWidgetItem([f"{label} ({len(plugins)})"])
icon = sf_symbol(icon_name, 14) icon = sf_symbol(icon_name, 14)
if not icon.isNull(): if not icon.isNull():
group_item.setIcon(0, icon) 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) self._changes_tree.addTopLevelItem(group_item)
group_item.setExpanded(True) 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: def _on_restore_clicked(self) -> None:
current = self._backup_list.currentItem() current = self._backup_list.currentItem()
if not current: if not current: