""" Sync Profile Editor Dialog Allows creating and editing sync profiles with source and destination paths """ from PyQt6.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QGroupBox, QLineEdit, QCheckBox, QPushButton, QTextEdit, QDialogButtonBox, QFileDialog, QMessageBox, QLabel ) from PyQt6.QtCore import Qt from loguru import logger from pathlib import Path from core.database import Database class SyncProfileDialog(QDialog): """Dialog for creating/editing sync profiles""" def __init__(self, parent, db: Database, profile_id: int = None): super().__init__(parent) self.db = db self.profile_id = profile_id # Determine mode self.is_edit = profile_id is not None self.setWindowTitle("Edit Sync Profile" if self.is_edit else "New Sync Profile") self.setMinimumSize(700, 400) self._setup_ui() if self.is_edit: self._load_profile_data() def _setup_ui(self): """Setup dialog UI""" layout = QVBoxLayout(self) layout.setSpacing(15) # Group 1: Basic Info basic_group = QGroupBox("Basic Information") basic_layout = QFormLayout(basic_group) self.name_edit = QLineEdit() self.name_edit.setPlaceholderText("e.g., WebDAV Sync, Network Share Mirror") basic_layout.addRow("Profile Name *:", self.name_edit) self.description_edit = QLineEdit() self.description_edit.setPlaceholderText("Optional description") basic_layout.addRow("Description:", self.description_edit) layout.addWidget(basic_group) # Group 2: Sync Settings settings_group = QGroupBox("Synchronization Settings") settings_layout = QFormLayout(settings_group) # Info label info_label = QLabel( "⚠ Unidirectional mirror: Destination will be identical to Source.\n" "Files deleted from Source will be deleted from Destination." ) info_label.setStyleSheet("color: #ff6600; font-weight: bold;") settings_layout.addRow("", info_label) # Source path with browse button source_layout = QHBoxLayout() self.source_edit = QLineEdit() self.source_edit.setPlaceholderText("e.g., C:\\Documents or Z:\\shared") source_layout.addWidget(self.source_edit) browse_source_btn = QPushButton("Browse...") browse_source_btn.clicked.connect(self._browse_source) source_layout.addWidget(browse_source_btn) settings_layout.addRow("Source Path *:", source_layout) # Destination path with browse button dest_layout = QHBoxLayout() self.destination_edit = QLineEdit() self.destination_edit.setPlaceholderText("e.g., D:\\backup or Z:\\archive") dest_layout.addWidget(self.destination_edit) browse_dest_btn = QPushButton("Browse...") browse_dest_btn.clicked.connect(self._browse_destination) dest_layout.addWidget(browse_dest_btn) settings_layout.addRow("Destination Path *:", dest_layout) self.enabled_check = QCheckBox("Enable this profile") self.enabled_check.setChecked(True) settings_layout.addRow("", self.enabled_check) layout.addWidget(settings_group) # Group 3: Exclusions exclusions_group = QGroupBox("Exclusion Patterns") exclusions_layout = QVBoxLayout(exclusions_group) exclusions_label = QLabel("Files and directories to exclude (one pattern per line):") exclusions_layout.addWidget(exclusions_label) self.exclusions_edit = QTextEdit() self.exclusions_edit.setPlaceholderText( "Examples:\n" "venv/ # Exclude venv directory\n" "__pycache__/ # Exclude Python cache\n" "*.log # Exclude log files\n" "*.db # Exclude database files\n" "*.pyc # Exclude compiled Python" ) self.exclusions_edit.setMaximumHeight(120) exclusions_layout.addWidget(self.exclusions_edit) exclusions_help = QLabel( "Use folder/ for directories, *.ext for file patterns. Lines starting with # are comments." ) exclusions_help.setStyleSheet("color: #666; font-size: 10px;") exclusions_layout.addWidget(exclusions_help) # Protect excluded checkbox self.protect_excluded_check = QCheckBox("Protect excluded files in destination") self.protect_excluded_check.setToolTip( "When enabled, files matching exclusion patterns won't be deleted from destination.\n" "Use this when syncing TO a machine that has its own local files (venv, database, etc.).\n\n" "When disabled (default), destination becomes a clean mirror - excluded files are deleted." ) exclusions_layout.addWidget(self.protect_excluded_check) layout.addWidget(exclusions_group) # Help text help_label = QLabel( "Network paths (like Z:\\) are supported. Files are compared by size for fast synchronization." ) help_label.setStyleSheet("color: #666; font-size: 10px;") layout.addWidget(help_label) # Dialog buttons button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel ) button_box.accepted.connect(self._save_profile) button_box.rejected.connect(self.reject) layout.addWidget(button_box) def _load_profile_data(self): """Load profile data in edit mode""" try: profile = self.db.get_sync_profile(self.profile_id) if not profile: QMessageBox.critical(self, "Error", f"Profile {self.profile_id} not found") self.reject() return self.name_edit.setText(profile['name']) self.description_edit.setText(profile.get('description', '')) self.source_edit.setText(profile['source_path']) self.destination_edit.setText(profile['destination_path']) self.exclusions_edit.setPlainText(profile.get('exclude_patterns', '')) self.protect_excluded_check.setChecked(bool(profile.get('protect_excluded', False))) self.enabled_check.setChecked(bool(profile['enabled'])) except Exception as e: logger.error(f"Error loading profile data: {e}") QMessageBox.critical(self, "Error", f"Failed to load profile: {e}") self.reject() def _browse_source(self): """Browse for source directory""" directory = QFileDialog.getExistingDirectory( self, "Select Source Directory", self.source_edit.text() or "" ) if directory: self.source_edit.setText(directory) def _browse_destination(self): """Browse for destination directory""" directory = QFileDialog.getExistingDirectory( self, "Select Destination Directory", self.destination_edit.text() or "" ) if directory: self.destination_edit.setText(directory) def _validate_inputs(self) -> bool: """Validate form inputs""" # Name required if not self.name_edit.text().strip(): QMessageBox.warning(self, "Validation Error", "Profile name is required") self.name_edit.setFocus() return False # Source required if not self.source_edit.text().strip(): QMessageBox.warning(self, "Validation Error", "Source path is required") self.source_edit.setFocus() return False # Destination required if not self.destination_edit.text().strip(): QMessageBox.warning(self, "Validation Error", "Destination path is required") self.destination_edit.setFocus() return False # Source must exist source_path = Path(self.source_edit.text().strip()) if not source_path.exists(): QMessageBox.warning( self, "Validation Error", f"Source path does not exist:\n{source_path}" ) self.source_edit.setFocus() return False # Source and destination cannot be same dest_path = Path(self.destination_edit.text().strip()) try: if source_path.resolve() == dest_path.resolve(): QMessageBox.warning( self, "Validation Error", "Source and destination paths cannot be the same" ) self.destination_edit.setFocus() return False except: pass # If path resolution fails, continue # Warn if destination inside source or vice versa try: if source_path in dest_path.parents: reply = QMessageBox.warning( self, "Warning", "Destination is inside source directory.\n" "This may cause infinite recursion.\n\n" "Continue anyway?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.No: return False if dest_path in source_path.parents: reply = QMessageBox.warning( self, "Warning", "Source is inside destination directory.\n" "This may cause unexpected behavior.\n\n" "Continue anyway?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.No: return False except: pass # If path comparison fails, proceed return True def _save_profile(self): """Save profile to database""" if not self._validate_inputs(): return try: # Collect form data name = self.name_edit.text().strip() description = self.description_edit.text().strip() source_path = self.source_edit.text().strip() destination_path = self.destination_edit.text().strip() exclude_patterns = self.exclusions_edit.toPlainText() protect_excluded = self.protect_excluded_check.isChecked() enabled = self.enabled_check.isChecked() if self.is_edit: # Update existing profile self.db.update_sync_profile( profile_id=self.profile_id, name=name, description=description, source_path=source_path, destination_path=destination_path, exclude_patterns=exclude_patterns, protect_excluded=protect_excluded, enabled=enabled ) logger.info(f"Updated sync profile {self.profile_id}: {name}") else: # Create new profile profile_id = self.db.create_sync_profile( name=name, description=description, source_path=source_path, destination_path=destination_path, exclude_patterns=exclude_patterns, protect_excluded=protect_excluded, enabled=enabled ) logger.info(f"Created sync profile {profile_id}: {name}") self.accept() except Exception as e: logger.error(f"Error saving profile: {e}") QMessageBox.critical(self, "Error", f"Failed to save profile: {e}")