feat: better change list in backups
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user