""" Backup Restore Dialog - Allows selective restore of files and directories """ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QPushButton, QTreeWidget, QTreeWidgetItem, QLabel, QMessageBox, QFileDialog, QGroupBox, QRadioButton, QLineEdit, QProgressDialog, QFrame ) from PyQt6.QtCore import Qt, QTimer from PyQt6.QtGui import QFont, QIcon from loguru import logger from pathlib import Path from typing import Optional, List, Dict class BackupRestoreDialog(QDialog): """Dialog for selective backup restore""" def __init__(self, parent, backup_root: Path, backup_name: str, profile=None): super().__init__(parent) self.backup_root = Path(backup_root) self.backup_name = backup_name self.backup_path = self.backup_root / backup_name self.profile = profile self.selected_items = [] self.setWindowTitle(f"Restore from: {backup_name}") self.resize(800, 600) self._setup_ui() # Show loading dialog while loading backup structure self._show_loading_and_load_structure() def _setup_ui(self): """Setup dialog UI""" layout = QVBoxLayout(self) layout.setContentsMargins(15, 15, 15, 15) layout.setSpacing(15) # Header header_label = QLabel(f"📁 Select items to restore from backup:") header_font = QFont() header_font.setPointSize(11) header_font.setBold(True) header_label.setFont(header_font) layout.addWidget(header_label) # Info label info_label = QLabel(f"Backup: {self.backup_name}") layout.addWidget(info_label) # Warning banner for missing sources (hidden by default) self.missing_sources_frame = QFrame() self.missing_sources_frame.setStyleSheet( "QFrame { background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 4px; }" ) warning_layout = QVBoxLayout(self.missing_sources_frame) warning_layout.setContentsMargins(8, 6, 8, 6) self.missing_sources_label = QLabel() self.missing_sources_label.setWordWrap(True) warning_layout.addWidget(self.missing_sources_label) self.missing_sources_frame.setVisible(False) layout.addWidget(self.missing_sources_frame) # Tree widget to show backup structure tree_group = QGroupBox("Backup Contents") tree_layout = QVBoxLayout(tree_group) self.tree = QTreeWidget() self.tree.setHeaderLabels(["Name", "Type", "Size"]) self.tree.setColumnWidth(0, 400) self.tree.setColumnWidth(1, 100) self.tree.setColumnWidth(2, 100) self.tree.itemChanged.connect(self._on_item_checked) tree_layout.addWidget(self.tree) layout.addWidget(tree_group) # Restore destination dest_group = QGroupBox("Restore Destination") dest_layout = QVBoxLayout(dest_group) # Radio buttons for restore type self.restore_original_radio = QRadioButton("Restore to original location") self.restore_custom_radio = QRadioButton("Restore to custom location:") self.restore_custom_radio.setChecked(True) dest_layout.addWidget(self.restore_original_radio) dest_layout.addWidget(self.restore_custom_radio) # Custom path input path_layout = QHBoxLayout() self.dest_path_input = QLineEdit() self.dest_path_input.setPlaceholderText("Select destination folder...") self.dest_path_input.setText(str(Path.home() / "restored_backup")) path_layout.addWidget(self.dest_path_input) browse_btn = QPushButton("Browse...") browse_btn.clicked.connect(self._browse_destination) path_layout.addWidget(browse_btn) dest_layout.addLayout(path_layout) layout.addWidget(dest_group) # Buttons button_layout = QHBoxLayout() button_layout.addStretch() select_all_btn = QPushButton("Select All") select_all_btn.clicked.connect(self._select_all) button_layout.addWidget(select_all_btn) deselect_all_btn = QPushButton("Deselect All") deselect_all_btn.clicked.connect(self._deselect_all) button_layout.addWidget(deselect_all_btn) self.restore_btn = QPushButton("🔄 Restore Selected") self.restore_btn.clicked.connect(self._restore_selected) self.restore_btn.setDefault(True) button_layout.addWidget(self.restore_btn) cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(self.reject) button_layout.addWidget(cancel_btn) layout.addLayout(button_layout) def _show_loading_and_load_structure(self): """Show loading dialog and load backup structure""" # Create progress dialog progress = QProgressDialog("Loading backup contents...", None, 0, 0, self) progress.setWindowTitle("Loading") progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setCancelButton(None) # No cancel button progress.setMinimumDuration(0) # Show immediately progress.setValue(0) # Use QTimer to allow the dialog to show before starting heavy work QTimer.singleShot(100, lambda: self._load_backup_structure_with_progress(progress)) def _load_backup_structure_with_progress(self, progress): """Load backup structure and close progress dialog when done""" try: self._load_backup_structure() finally: progress.close() def _load_backup_structure(self): """Load and display backup directory structure""" try: if not self.backup_path.exists(): QMessageBox.critical(self, "Error", f"Backup not found: {self.backup_path}") return # Load root items (directories and files at backup root) for item in sorted(self.backup_path.iterdir()): if item.is_dir(): self._add_directory_item(self.tree, item, self.backup_path) elif item.is_file(): self._add_file_item(self.tree, item, self.backup_path) # Check for missing sources vs profile self._check_missing_sources() except Exception as e: logger.error(f"Error loading backup structure: {e}") QMessageBox.critical(self, "Error", f"Failed to load backup structure: {e}") def _check_missing_sources(self): """Show warning if some profile sources are not present in this backup.""" if not self.profile: return try: import json sources_raw = self.profile.get('sources', '[]') sources = json.loads(sources_raw) if isinstance(sources_raw, str) else sources_raw # Get directory names actually present in the backup backed_up = {item.name for item in self.backup_path.iterdir() if item.is_dir()} missing = [s for s in sources if Path(s).name not in backed_up] if missing: lines = ' • '.join(missing) self.missing_sources_label.setText( f"⚠ Sorgenti non incluse in questo backup (drive non disponibili al momento del backup):\n" f" • {lines}" ) self.missing_sources_frame.setVisible(True) except Exception as e: logger.warning(f"Could not check missing sources: {e}") def _add_directory_item(self, parent, dir_path: Path, root_path: Path): """Add directory item to tree""" relative_path = dir_path.relative_to(root_path) item = QTreeWidgetItem(parent) item.setText(0, dir_path.name) item.setText(1, "Directory") item.setText(2, "") item.setCheckState(0, Qt.CheckState.Unchecked) item.setData(0, Qt.ItemDataRole.UserRole, str(relative_path)) item.setData(1, Qt.ItemDataRole.UserRole, "dir") # Add subdirectories and files try: for sub_item in sorted(dir_path.iterdir()): if sub_item.is_dir(): self._add_directory_item(item, sub_item, root_path) elif sub_item.is_file(): self._add_file_item(item, sub_item, root_path) except PermissionError: pass # Skip directories we can't read def _add_file_item(self, parent, file_path: Path, root_path: Path): """Add file item to tree""" relative_path = file_path.relative_to(root_path) try: size = file_path.stat().st_size size_str = self._format_size(size) except: size_str = "Unknown" item = QTreeWidgetItem(parent) item.setText(0, file_path.name) item.setText(1, "File") item.setText(2, size_str) item.setCheckState(0, Qt.CheckState.Unchecked) item.setData(0, Qt.ItemDataRole.UserRole, str(relative_path)) item.setData(1, Qt.ItemDataRole.UserRole, "file") def _format_size(self, size: int) -> str: """Format file size in human readable format""" for unit in ['B', 'KB', 'MB', 'GB']: if size < 1024.0: return f"{size:.1f} {unit}" size /= 1024.0 return f"{size:.1f} TB" def _on_item_checked(self, item: QTreeWidgetItem, column: int): """Handle item check state change""" if column != 0: return # Propagate check state to children check_state = item.checkState(0) self._set_children_check_state(item, check_state) def _set_children_check_state(self, item: QTreeWidgetItem, state: Qt.CheckState): """Recursively set check state for all children""" for i in range(item.childCount()): child = item.child(i) child.setCheckState(0, state) self._set_children_check_state(child, state) def _select_all(self): """Select all items in tree""" root = self.tree.invisibleRootItem() for i in range(root.childCount()): item = root.child(i) item.setCheckState(0, Qt.CheckState.Checked) def _deselect_all(self): """Deselect all items in tree""" root = self.tree.invisibleRootItem() for i in range(root.childCount()): item = root.child(i) item.setCheckState(0, Qt.CheckState.Unchecked) def _browse_destination(self): """Browse for destination directory""" dest = QFileDialog.getExistingDirectory( self, "Select Restore Destination", self.dest_path_input.text(), QFileDialog.Option.ShowDirsOnly ) if dest: self.dest_path_input.setText(dest) def _get_selected_items(self) -> List[Dict]: """Get list of selected items""" selected = [] root = self.tree.invisibleRootItem() self._collect_checked_items(root, selected) return selected def _collect_checked_items(self, parent, selected: List[Dict]): """Recursively collect checked items""" for i in range(parent.childCount()): item = parent.child(i) if item.checkState(0) == Qt.CheckState.Checked: relative_path = item.data(0, Qt.ItemDataRole.UserRole) item_type = item.data(1, Qt.ItemDataRole.UserRole) selected.append({ 'path': relative_path, 'type': item_type, 'name': item.text(0) }) # Don't recurse if parent is checked (will restore entire directory) elif item.checkState(0) == Qt.CheckState.Unchecked: self._collect_checked_items(item, selected) def _restore_selected(self): """Restore selected items""" from modules.backup.backup_engine import BackupEngine selected = self._get_selected_items() if not selected: QMessageBox.warning(self, "No Selection", "Please select at least one item to restore") return # Get destination if self.restore_custom_radio.isChecked(): dest_path = Path(self.dest_path_input.text()) if not dest_path: QMessageBox.warning(self, "No Destination", "Please select a destination folder") return else: dest_path = None # Will use original locations # Confirm reply = QMessageBox.question( self, "Confirm Restore", f"Restore {len(selected)} selected item(s)?\n\n" f"Destination: {dest_path if dest_path else 'Original locations'}", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return # Create progress dialog progress = QProgressDialog("Restoring files...", "Cancel", 0, len(selected), self) progress.setWindowModality(Qt.WindowModality.WindowModal) progress.setMinimumDuration(0) try: engine = BackupEngine(backup_root=self.backup_root) restored_count = 0 failed_count = 0 for idx, item in enumerate(selected): if progress.wasCanceled(): break progress.setValue(idx) progress.setLabelText(f"Restoring: {item['name']}") try: if item['type'] == 'dir': # Restore directory restore_to = dest_path / item['name'] if dest_path else None engine.restore_directory(self.backup_name, item['path'], restore_to) else: # Restore file restore_to = dest_path / item['name'] if dest_path else None engine.restore_file(self.backup_name, item['path'], restore_to) restored_count += 1 except Exception as e: logger.error(f"Error restoring {item['path']}: {e}") failed_count += 1 progress.setValue(len(selected)) # Show result if restored_count > 0: msg = f"Successfully restored {restored_count} item(s)" if failed_count > 0: msg += f"\n{failed_count} item(s) failed" QMessageBox.information(self, "Restore Complete", msg) self.accept() else: QMessageBox.warning(self, "Restore Failed", "No items were restored") except Exception as e: logger.error(f"Error during restore: {e}") QMessageBox.critical(self, "Restore Failed", f"Failed to restore items:\n{str(e)}")