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