"""Settings dialog for configuration management.""" import logging from pathlib import Path from typing import Any, Dict, Optional from PySide6.QtWidgets import ( QComboBox, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QPushButton, QSpinBox, QTableWidget, QTableWidgetItem, QTabWidget, QVBoxLayout, QWidget, ) from webdrop_bridge.config import Config, ConfigurationError from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator from webdrop_bridge.utils.i18n import get_available_languages, tr from webdrop_bridge.utils.logging import reconfigure_logging logger = logging.getLogger(__name__) class SettingsDialog(QDialog): """Dialog for managing application settings and configuration.""" def __init__(self, config: Config, parent: Optional[QWidget] = None): """Initialize the settings dialog. Args: config: Current application configuration parent: Parent widget """ super().__init__(parent) self.config = config self.profile_manager = ConfigProfile() self.setWindowTitle(tr("settings.title")) self.setGeometry(100, 100, 600, 500) self.setup_ui() def setup_ui(self) -> None: """Set up the dialog UI with tabs.""" layout = QVBoxLayout() self.tabs = QTabWidget() self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general")) self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source")) self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths")) self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls")) self.tabs.addTab(self._create_logging_tab(), tr("settings.tab.logging")) self.tabs.addTab(self._create_window_tab(), tr("settings.tab.window")) self.tabs.addTab(self._create_profiles_tab(), tr("settings.tab.profiles")) layout.addWidget(self.tabs) button_box = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) button_box.accepted.connect(self.accept) button_box.rejected.connect(self.reject) layout.addWidget(button_box) self.setLayout(layout) def accept(self) -> None: """Handle OK button - save configuration changes to file.""" try: config_data = self.get_config_data() from webdrop_bridge.config import URLMapping url_mappings = [ URLMapping(url_prefix=m["url_prefix"], local_path=m["local_path"]) for m in config_data["url_mappings"] ] old_log_level = self.config.log_level self.config.language = config_data["language"] self.config.log_level = config_data["log_level"] self.config.log_file = ( Path(config_data["log_file"]) if config_data["log_file"] else None ) self.config.allowed_roots = [Path(r).resolve() for r in config_data["allowed_roots"]] self.config.allowed_urls = config_data["allowed_urls"] self.config.webapp_url = config_data["webapp_url"] self.config.url_mappings = url_mappings self.config.window_width = config_data["window_width"] self.config.window_height = config_data["window_height"] config_path = Config.get_default_config_path() self.config.to_file(config_path) logger.info(f"Configuration saved to {config_path}") logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})") logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}") if old_log_level != self.config.log_level: reconfigure_logging( logger_name="webdrop_bridge", level=self.config.log_level, log_file=self.config.log_file, ) logger.info(f"Log level updated to {self.config.log_level}") super().accept() except ConfigurationError as e: logger.error(f"Configuration error: {e}") self._show_error(f"Configuration Error:\n\n{e}") except Exception as e: logger.error(f"Failed to save configuration: {e}", exc_info=True) self._show_error(f"Failed to save configuration:\n\n{e}") def _create_general_tab(self) -> QWidget: """Create general settings tab with language selector.""" widget = QWidget() layout = QVBoxLayout() lang_layout = QHBoxLayout() lang_layout.addWidget(QLabel(tr("settings.general.language_label"))) self.language_combo = QComboBox() self.language_combo.addItem(tr("settings.general.language_auto"), "auto") available = get_available_languages() current_lang = self.config.language for code, name in available.items(): self.language_combo.addItem(name, code) idx = self.language_combo.findData(current_lang) if idx >= 0: self.language_combo.setCurrentIndex(idx) lang_layout.addWidget(self.language_combo) lang_layout.addStretch() layout.addLayout(lang_layout) note = QLabel(tr("settings.general.language_restart_note")) note.setStyleSheet("color: gray; font-size: 11px;") layout.addWidget(note) layout.addStretch() widget.setLayout(layout) return widget def _create_web_source_tab(self) -> QWidget: """Create web source configuration tab.""" widget = QWidget() layout = QVBoxLayout() layout.addWidget(QLabel(tr("settings.web_source.url_label"))) url_layout = QHBoxLayout() self.webapp_url_input = QLineEdit() self.webapp_url_input.setText(self.config.webapp_url) self.webapp_url_input.setPlaceholderText( "e.g., http://localhost:8080 or file:///./webapp/index.html" ) url_layout.addWidget(self.webapp_url_input) open_btn = QPushButton(tr("settings.web_source.open_btn")) open_btn.clicked.connect(self._open_webapp_url) url_layout.addWidget(open_btn) layout.addLayout(url_layout) layout.addWidget(QLabel(tr("settings.web_source.url_mappings_label"))) self.url_mappings_table = QTableWidget() self.url_mappings_table.setColumnCount(2) self.url_mappings_table.setHorizontalHeaderLabels( [ tr("settings.web_source.col_url_prefix"), tr("settings.web_source.col_local_path"), ] ) self.url_mappings_table.horizontalHeader().setStretchLastSection(True) for mapping in self.config.url_mappings: row = self.url_mappings_table.rowCount() self.url_mappings_table.insertRow(row) self.url_mappings_table.setItem(row, 0, QTableWidgetItem(mapping.url_prefix)) self.url_mappings_table.setItem(row, 1, QTableWidgetItem(mapping.local_path)) layout.addWidget(self.url_mappings_table) button_layout = QHBoxLayout() add_mapping_btn = QPushButton(tr("settings.web_source.add_mapping_btn")) add_mapping_btn.clicked.connect(self._add_url_mapping) button_layout.addWidget(add_mapping_btn) edit_mapping_btn = QPushButton(tr("settings.web_source.edit_mapping_btn")) edit_mapping_btn.clicked.connect(self._edit_url_mapping) button_layout.addWidget(edit_mapping_btn) remove_mapping_btn = QPushButton(tr("settings.web_source.remove_mapping_btn")) remove_mapping_btn.clicked.connect(self._remove_url_mapping) button_layout.addWidget(remove_mapping_btn) layout.addLayout(button_layout) layout.addStretch() widget.setLayout(layout) return widget def _open_webapp_url(self) -> None: """Open the webapp URL in the default browser.""" import webbrowser url = self.webapp_url_input.text().strip() if not url: return try: webbrowser.open(url) except Exception as e: logger.error(f"Failed to open URL: {e}") self._show_error(f"Failed to open URL:\n\n{e}") def _add_url_mapping(self) -> None: """Add new URL mapping.""" from PySide6.QtWidgets import QInputDialog url_prefix, ok1 = QInputDialog.getText( self, tr("settings.web_source.add_mapping_title"), tr("settings.web_source.add_mapping_url_prompt"), ) if ok1 and url_prefix: local_path, ok2 = QInputDialog.getText( self, tr("settings.web_source.add_mapping_title"), tr("settings.web_source.add_mapping_path_prompt"), ) if ok2 and local_path: row = self.url_mappings_table.rowCount() self.url_mappings_table.insertRow(row) self.url_mappings_table.setItem(row, 0, QTableWidgetItem(url_prefix)) self.url_mappings_table.setItem(row, 1, QTableWidgetItem(local_path)) def _edit_url_mapping(self) -> None: """Edit selected URL mapping.""" from PySide6.QtWidgets import QInputDialog current_row = self.url_mappings_table.currentRow() if current_row < 0: self._show_error(tr("settings.web_source.select_mapping_to_edit")) return url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore new_url_prefix, ok1 = QInputDialog.getText( self, tr("settings.web_source.edit_mapping_title"), tr("settings.web_source.edit_mapping_url_prompt"), text=url_prefix, ) if ok1 and new_url_prefix: new_local_path, ok2 = QInputDialog.getText( self, tr("settings.web_source.edit_mapping_title"), tr("settings.web_source.edit_mapping_path_prompt"), text=local_path, ) if ok2 and new_local_path: self.url_mappings_table.setItem(current_row, 0, QTableWidgetItem(new_url_prefix)) self.url_mappings_table.setItem(current_row, 1, QTableWidgetItem(new_local_path)) def _remove_url_mapping(self) -> None: """Remove selected URL mapping.""" current_row = self.url_mappings_table.currentRow() if current_row >= 0: self.url_mappings_table.removeRow(current_row) def _create_paths_tab(self) -> QWidget: """Create paths configuration tab.""" widget = QWidget() layout = QVBoxLayout() layout.addWidget(QLabel(tr("settings.paths.label"))) self.paths_list = QListWidget() for path in self.config.allowed_roots: self.paths_list.addItem(str(path)) layout.addWidget(self.paths_list) button_layout = QHBoxLayout() add_path_btn = QPushButton(tr("settings.paths.add_btn")) add_path_btn.clicked.connect(self._add_path) button_layout.addWidget(add_path_btn) remove_path_btn = QPushButton(tr("settings.paths.remove_btn")) remove_path_btn.clicked.connect(self._remove_path) button_layout.addWidget(remove_path_btn) layout.addLayout(button_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_urls_tab(self) -> QWidget: """Create URLs configuration tab.""" widget = QWidget() layout = QVBoxLayout() layout.addWidget(QLabel(tr("settings.urls.label"))) self.urls_list = QListWidget() for url in self.config.allowed_urls: self.urls_list.addItem(url) layout.addWidget(self.urls_list) button_layout = QHBoxLayout() add_url_btn = QPushButton(tr("settings.urls.add_btn")) add_url_btn.clicked.connect(self._add_url) button_layout.addWidget(add_url_btn) remove_url_btn = QPushButton(tr("settings.urls.remove_btn")) remove_url_btn.clicked.connect(self._remove_url) button_layout.addWidget(remove_url_btn) layout.addLayout(button_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_logging_tab(self) -> QWidget: """Create logging configuration tab.""" widget = QWidget() layout = QVBoxLayout() layout.addWidget(QLabel(tr("settings.logging.level_label"))) self.log_level_combo: QComboBox = self._create_log_level_widget() layout.addWidget(self.log_level_combo) layout.addWidget(QLabel(tr("settings.logging.file_label"))) log_file_layout = QHBoxLayout() self.log_file_input = QLineEdit() self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "") log_file_layout.addWidget(self.log_file_input) browse_btn = QPushButton(tr("settings.logging.browse_btn")) browse_btn.clicked.connect(self._browse_log_file) log_file_layout.addWidget(browse_btn) layout.addLayout(log_file_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_window_tab(self) -> QWidget: """Create window settings tab.""" widget = QWidget() layout = QVBoxLayout() width_layout = QHBoxLayout() width_layout.addWidget(QLabel(tr("settings.window.width_label"))) self.width_spin = QSpinBox() self.width_spin.setMinimum(400) self.width_spin.setMaximum(5000) self.width_spin.setValue(self.config.window_width) width_layout.addWidget(self.width_spin) width_layout.addStretch() layout.addLayout(width_layout) height_layout = QHBoxLayout() height_layout.addWidget(QLabel(tr("settings.window.height_label"))) self.height_spin = QSpinBox() self.height_spin.setMinimum(300) self.height_spin.setMaximum(5000) self.height_spin.setValue(self.config.window_height) height_layout.addWidget(self.height_spin) height_layout.addStretch() layout.addLayout(height_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_profiles_tab(self) -> QWidget: """Create profiles management tab.""" widget = QWidget() layout = QVBoxLayout() layout.addWidget(QLabel(tr("settings.profiles.label"))) self.profiles_list = QListWidget() self._refresh_profiles_list() layout.addWidget(self.profiles_list) button_layout = QHBoxLayout() save_profile_btn = QPushButton(tr("settings.profiles.save_btn")) save_profile_btn.clicked.connect(self._save_profile) button_layout.addWidget(save_profile_btn) load_profile_btn = QPushButton(tr("settings.profiles.load_btn")) load_profile_btn.clicked.connect(self._load_profile) button_layout.addWidget(load_profile_btn) delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn")) delete_profile_btn.clicked.connect(self._delete_profile) button_layout.addWidget(delete_profile_btn) layout.addLayout(button_layout) export_layout = QHBoxLayout() export_btn = QPushButton(tr("settings.profiles.export_btn")) export_btn.clicked.connect(self._export_config) export_layout.addWidget(export_btn) import_btn = QPushButton(tr("settings.profiles.import_btn")) import_btn.clicked.connect(self._import_config) export_layout.addWidget(import_btn) layout.addLayout(export_layout) layout.addStretch() widget.setLayout(layout) return widget def _create_log_level_widget(self) -> QComboBox: """Create log level selection widget.""" combo = QComboBox() levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] combo.addItems(levels) combo.setCurrentText(self.config.log_level) return combo def _add_path(self) -> None: """Add a new allowed path.""" path = QFileDialog.getExistingDirectory(self, tr("settings.paths.select_dir_title")) if path: self.paths_list.addItem(path) def _remove_path(self) -> None: """Remove selected path.""" if self.paths_list.currentItem(): self.paths_list.takeItem(self.paths_list.row(self.paths_list.currentItem())) def _add_url(self) -> None: """Add a new allowed URL.""" from PySide6.QtWidgets import QInputDialog url, ok = QInputDialog.getText( self, tr("settings.urls.add_title"), tr("settings.urls.add_prompt") ) if ok and url: self.urls_list.addItem(url) def _remove_url(self) -> None: """Remove selected URL.""" if self.urls_list.currentItem(): self.urls_list.takeItem(self.urls_list.row(self.urls_list.currentItem())) def _browse_log_file(self) -> None: """Browse for log file location.""" file_path, _ = QFileDialog.getSaveFileName( self, tr("settings.logging.select_file_title"), str(Path.home()), "Log Files (*.log);;All Files (*)", ) if file_path: self.log_file_input.setText(file_path) def _refresh_profiles_list(self) -> None: """Refresh the list of available profiles.""" self.profiles_list.clear() for profile_name in self.profile_manager.list_profiles(): self.profiles_list.addItem(profile_name) def _save_profile(self) -> None: """Save current configuration as a profile.""" from PySide6.QtWidgets import QInputDialog profile_name, ok = QInputDialog.getText( self, tr("settings.profiles.save_title"), tr("settings.profiles.save_prompt") ) if ok and profile_name: try: self.profile_manager.save_profile(profile_name, self.config) self._refresh_profiles_list() except ConfigurationError as e: self._show_error(f"Failed to save profile: {e}") def _load_profile(self) -> None: """Load a saved profile.""" current_item = self.profiles_list.currentItem() if not current_item: self._show_error(tr("settings.profiles.select_to_load")) return profile_name = current_item.text() try: config_data = self.profile_manager.load_profile(profile_name) self._apply_config_data(config_data) except ConfigurationError as e: self._show_error(f"Failed to load profile: {e}") def _delete_profile(self) -> None: """Delete a saved profile.""" current_item = self.profiles_list.currentItem() if not current_item: self._show_error(tr("settings.profiles.select_to_delete")) return profile_name = current_item.text() try: self.profile_manager.delete_profile(profile_name) self._refresh_profiles_list() except ConfigurationError as e: self._show_error(f"Failed to delete profile: {e}") def _export_config(self) -> None: """Export configuration to file.""" file_path, _ = QFileDialog.getSaveFileName( self, tr("settings.profiles.export_title"), str(Path.home()), "JSON Files (*.json);;All Files (*)", ) if file_path: try: ConfigExporter.export_to_json(self.config, Path(file_path)) except ConfigurationError as e: self._show_error(f"Failed to export configuration: {e}") def _import_config(self) -> None: """Import configuration from file.""" file_path, _ = QFileDialog.getOpenFileName( self, tr("settings.profiles.import_title"), str(Path.home()), "JSON Files (*.json);;All Files (*)", ) if file_path: try: config_data = ConfigExporter.import_from_json(Path(file_path)) self._apply_config_data(config_data) except ConfigurationError as e: self._show_error(f"Failed to import configuration: {e}") def _apply_config_data(self, config_data: Dict[str, Any]) -> None: """Apply imported configuration data to UI. Args: config_data: Configuration dictionary """ self.paths_list.clear() for path in config_data.get("allowed_roots", []): self.paths_list.addItem(str(path)) self.urls_list.clear() for url in config_data.get("allowed_urls", []): self.urls_list.addItem(url) self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO")) log_file = config_data.get("log_file") self.log_file_input.setText(str(log_file) if log_file else "") self.width_spin.setValue(config_data.get("window_width", 800)) self.height_spin.setValue(config_data.get("window_height", 600)) language = config_data.get("language", "auto") idx = self.language_combo.findData(language) if idx >= 0: self.language_combo.setCurrentIndex(idx) def get_config_data(self) -> Dict[str, Any]: """Get updated configuration data from dialog. Returns: Configuration dictionary Raises: ConfigurationError: If configuration is invalid """ url_mappings_table_count = ( self.url_mappings_table.rowCount() if self.url_mappings_table else 0 ) config_data = { "app_name": self.config.app_name, "app_version": self.config.app_version, "language": self.language_combo.currentData(), "log_level": self.log_level_combo.currentText(), "log_file": self.log_file_input.text() or None, "allowed_roots": [ self.paths_list.item(i).text() for i in range(self.paths_list.count()) ], "allowed_urls": [self.urls_list.item(i).text() for i in range(self.urls_list.count())], "webapp_url": self.webapp_url_input.text().strip(), "url_mappings": [ { "url_prefix": ( self.url_mappings_table.item(i, 0).text() # type: ignore if self.url_mappings_table.item(i, 0) else "" ), "local_path": ( self.url_mappings_table.item(i, 1).text() # type: ignore if self.url_mappings_table.item(i, 1) else "" ), } for i in range(url_mappings_table_count) ], "window_width": self.width_spin.value(), "window_height": self.height_spin.value(), "enable_logging": self.config.enable_logging, } ConfigValidator.validate_or_raise(config_data) return config_data def _show_error(self, message: str) -> None: """Show error message to user. Args: message: Error message """ from PySide6.QtWidgets import QMessageBox QMessageBox.critical(self, tr("dialog.error.title"), message)