Add internationalization support with English and French translations

- Introduced a new i18n module for managing translations using JSON files.
- Added English (en.json) and French (fr.json) translation files for UI elements.
- Implemented lazy initialization of the translator to load translations on demand.
- Added unit tests for translation lookup, fallback behavior, and available languages detection.
This commit is contained in:
claudi 2026-03-10 14:32:38 +01:00
parent fd0482ed2d
commit 7daec731ca
11 changed files with 1184 additions and 280 deletions

View file

@ -2,9 +2,8 @@
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
@ -14,7 +13,6 @@ from PySide6.QtWidgets import (
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QPushButton,
QSpinBox,
QTableWidget,
@ -26,21 +24,14 @@ from PySide6.QtWidgets import (
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.
Provides tabs for:
- Paths: Manage allowed root directories
- URLs: Manage allowed web URLs
- Logging: Configure logging settings
- Window: Manage window size and behavior
- Profiles: Save/load/delete configuration profiles
"""
"""Dialog for managing application settings and configuration."""
def __init__(self, config: Config, parent: Optional[QWidget] = None):
"""Initialize the settings dialog.
@ -52,7 +43,7 @@ class SettingsDialog(QDialog):
super().__init__(parent)
self.config = config
self.profile_manager = ConfigProfile()
self.setWindowTitle("Settings")
self.setWindowTitle(tr("settings.title"))
self.setGeometry(100, 100, 600, 500)
self.setup_ui()
@ -61,20 +52,16 @@ class SettingsDialog(QDialog):
"""Set up the dialog UI with tabs."""
layout = QVBoxLayout()
# Create tab widget
self.tabs = QTabWidget()
# Add tabs
self.tabs.addTab(self._create_web_source_tab(), "Web Source")
self.tabs.addTab(self._create_paths_tab(), "Paths")
self.tabs.addTab(self._create_urls_tab(), "URLs")
self.tabs.addTab(self._create_logging_tab(), "Logging")
self.tabs.addTab(self._create_window_tab(), "Window")
self.tabs.addTab(self._create_profiles_tab(), "Profiles")
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)
# Add buttons
button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
@ -85,17 +72,10 @@ class SettingsDialog(QDialog):
self.setLayout(layout)
def accept(self) -> None:
"""Handle OK button - save configuration changes to file.
Validates configuration and saves to the default config path.
Applies log level changes immediately in the running application.
If validation or save fails, shows error and stays in dialog.
"""
"""Handle OK button - save configuration changes to file."""
try:
# Get updated configuration data from UI
config_data = self.get_config_data()
# Convert URL mappings from dict to URLMapping objects
from webdrop_bridge.config import URLMapping
url_mappings = [
@ -103,8 +83,8 @@ class SettingsDialog(QDialog):
for m in config_data["url_mappings"]
]
# Update the config object with new values
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
@ -116,7 +96,6 @@ class SettingsDialog(QDialog):
self.config.window_width = config_data["window_width"]
self.config.window_height = config_data["window_height"]
# Save to file (creates parent dirs if needed)
config_path = Config.get_default_config_path()
self.config.to_file(config_path)
@ -124,17 +103,14 @@ class SettingsDialog(QDialog):
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}")
# Apply log level change immediately to running application
if old_log_level != self.config.log_level:
logger.info(f"🔄 Updating log level: {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}")
logger.info(f"Log level updated to {self.config.log_level}")
# Call parent accept to close dialog
super().accept()
except ConfigurationError as e:
@ -144,15 +120,42 @@ class SettingsDialog(QDialog):
logger.error(f"Failed to save configuration: {e}", exc_info=True)
self._show_error(f"Failed to save configuration:\n\n{e}")
def _create_web_source_tab(self) -> QWidget:
"""Create web source configuration tab."""
from PySide6.QtWidgets import QTableWidget, QTableWidgetItem
def _create_general_tab(self) -> QWidget:
"""Create general settings tab with language selector."""
widget = QWidget()
layout = QVBoxLayout()
# Webapp URL configuration
layout.addWidget(QLabel("Web Application URL:"))
lang_layout = QHBoxLayout()
lang_layout.addWidget(QLabel(tr("settings.general.language_label")))
self.language_combo = QComboBox()
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()
@ -162,22 +165,24 @@ class SettingsDialog(QDialog):
)
url_layout.addWidget(self.webapp_url_input)
open_btn = QPushButton("Open")
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)
# URL Mappings (Azure Blob URL → Local Path)
layout.addWidget(QLabel("URL Mappings (Azure Blob Storage → Local Paths):"))
layout.addWidget(QLabel(tr("settings.web_source.url_mappings_label")))
# Create table for URL mappings
self.url_mappings_table = QTableWidget()
self.url_mappings_table.setColumnCount(2)
self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"])
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)
# Populate from config
for mapping in self.config.url_mappings:
row = self.url_mappings_table.rowCount()
self.url_mappings_table.insertRow(row)
@ -186,18 +191,17 @@ class SettingsDialog(QDialog):
layout.addWidget(self.url_mappings_table)
# Buttons for URL mapping management
button_layout = QHBoxLayout()
add_mapping_btn = QPushButton("Add Mapping")
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("Edit Selected")
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("Remove Selected")
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)
@ -212,13 +216,13 @@ class SettingsDialog(QDialog):
import webbrowser
url = self.webapp_url_input.text().strip()
if url:
# Handle file:// URLs
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}")
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."""
@ -226,15 +230,15 @@ class SettingsDialog(QDialog):
url_prefix, ok1 = QInputDialog.getText(
self,
"Add URL Mapping",
"Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)",
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,
"Add URL Mapping",
"Enter local file system path:\n(e.g., C:\\Share or /mnt/share)",
tr("settings.web_source.add_mapping_title"),
tr("settings.web_source.add_mapping_path_prompt"),
)
if ok2 and local_path:
@ -249,19 +253,25 @@ class SettingsDialog(QDialog):
current_row = self.url_mappings_table.currentRow()
if current_row < 0:
self._show_error("Please select a mapping to edit")
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, "Edit URL Mapping", "Enter Azure Blob Storage URL prefix:", text=url_prefix
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, "Edit URL Mapping", "Enter local file system path:", text=local_path
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:
@ -279,22 +289,20 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed root directories for file access:"))
layout.addWidget(QLabel(tr("settings.paths.label")))
# List widget for paths
self.paths_list = QListWidget()
for path in self.config.allowed_roots:
self.paths_list.addItem(str(path))
layout.addWidget(self.paths_list)
# Buttons for path management
button_layout = QHBoxLayout()
add_path_btn = QPushButton("Add Path")
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("Remove Selected")
remove_path_btn = QPushButton(tr("settings.paths.remove_btn"))
remove_path_btn.clicked.connect(self._remove_path)
button_layout.addWidget(remove_path_btn)
@ -309,22 +317,20 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):"))
layout.addWidget(QLabel(tr("settings.urls.label")))
# List widget for URLs
self.urls_list = QListWidget()
for url in self.config.allowed_urls:
self.urls_list.addItem(url)
layout.addWidget(self.urls_list)
# Buttons for URL management
button_layout = QHBoxLayout()
add_url_btn = QPushButton("Add URL")
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("Remove Selected")
remove_url_btn = QPushButton(tr("settings.urls.remove_btn"))
remove_url_btn.clicked.connect(self._remove_url)
button_layout.addWidget(remove_url_btn)
@ -339,27 +345,22 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
# Log level selection
layout.addWidget(QLabel("Log Level:"))
from PySide6.QtWidgets import QComboBox
layout.addWidget(QLabel(tr("settings.logging.level_label")))
self.log_level_combo: QComboBox = self._create_log_level_widget()
layout.addWidget(self.log_level_combo)
# Log file path
layout.addWidget(QLabel("Log File (optional):"))
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("Browse...")
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
@ -369,9 +370,8 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
# Window width
width_layout = QHBoxLayout()
width_layout.addWidget(QLabel("Window Width:"))
width_layout.addWidget(QLabel(tr("settings.window.width_label")))
self.width_spin = QSpinBox()
self.width_spin.setMinimum(400)
self.width_spin.setMaximum(5000)
@ -380,9 +380,8 @@ class SettingsDialog(QDialog):
width_layout.addStretch()
layout.addLayout(width_layout)
# Window height
height_layout = QHBoxLayout()
height_layout.addWidget(QLabel("Window Height:"))
height_layout.addWidget(QLabel(tr("settings.window.height_label")))
self.height_spin = QSpinBox()
self.height_spin.setMinimum(300)
self.height_spin.setMaximum(5000)
@ -400,38 +399,35 @@ class SettingsDialog(QDialog):
widget = QWidget()
layout = QVBoxLayout()
layout.addWidget(QLabel("Saved Configuration Profiles:"))
layout.addWidget(QLabel(tr("settings.profiles.label")))
# List of profiles
self.profiles_list = QListWidget()
self._refresh_profiles_list()
layout.addWidget(self.profiles_list)
# Profile management buttons
button_layout = QHBoxLayout()
save_profile_btn = QPushButton("Save as Profile")
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("Load Profile")
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("Delete Profile")
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/Import buttons
export_layout = QHBoxLayout()
export_btn = QPushButton("Export Configuration")
export_btn = QPushButton(tr("settings.profiles.export_btn"))
export_btn.clicked.connect(self._export_config)
export_layout.addWidget(export_btn)
import_btn = QPushButton("Import Configuration")
import_btn = QPushButton(tr("settings.profiles.import_btn"))
import_btn.clicked.connect(self._import_config)
export_layout.addWidget(import_btn)
@ -451,7 +447,7 @@ class SettingsDialog(QDialog):
def _add_path(self) -> None:
"""Add a new allowed path."""
path = QFileDialog.getExistingDirectory(self, "Select Directory to Allow")
path = QFileDialog.getExistingDirectory(self, tr("settings.paths.select_dir_title"))
if path:
self.paths_list.addItem(path)
@ -465,7 +461,7 @@ class SettingsDialog(QDialog):
from PySide6.QtWidgets import QInputDialog
url, ok = QInputDialog.getText(
self, "Add URL", "Enter URL pattern (e.g., http://example.com or http://*.example.com):"
self, tr("settings.urls.add_title"), tr("settings.urls.add_prompt")
)
if ok and url:
self.urls_list.addItem(url)
@ -478,7 +474,10 @@ class SettingsDialog(QDialog):
def _browse_log_file(self) -> None:
"""Browse for log file location."""
file_path, _ = QFileDialog.getSaveFileName(
self, "Select Log File", str(Path.home()), "Log Files (*.log);;All Files (*)"
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)
@ -494,7 +493,7 @@ class SettingsDialog(QDialog):
from PySide6.QtWidgets import QInputDialog
profile_name, ok = QInputDialog.getText(
self, "Save Profile", "Enter profile name (e.g., work, personal):"
self, tr("settings.profiles.save_title"), tr("settings.profiles.save_prompt")
)
if ok and profile_name:
@ -508,7 +507,7 @@ class SettingsDialog(QDialog):
"""Load a saved profile."""
current_item = self.profiles_list.currentItem()
if not current_item:
self._show_error("Please select a profile to load")
self._show_error(tr("settings.profiles.select_to_load"))
return
profile_name = current_item.text()
@ -522,7 +521,7 @@ class SettingsDialog(QDialog):
"""Delete a saved profile."""
current_item = self.profiles_list.currentItem()
if not current_item:
self._show_error("Please select a profile to delete")
self._show_error(tr("settings.profiles.select_to_delete"))
return
profile_name = current_item.text()
@ -535,7 +534,10 @@ class SettingsDialog(QDialog):
def _export_config(self) -> None:
"""Export configuration to file."""
file_path, _ = QFileDialog.getSaveFileName(
self, "Export Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)"
self,
tr("settings.profiles.export_title"),
str(Path.home()),
"JSON Files (*.json);;All Files (*)",
)
if file_path:
@ -547,7 +549,10 @@ class SettingsDialog(QDialog):
def _import_config(self) -> None:
"""Import configuration from file."""
file_path, _ = QFileDialog.getOpenFileName(
self, "Import Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)"
self,
tr("settings.profiles.import_title"),
str(Path.home()),
"JSON Files (*.json);;All Files (*)",
)
if file_path:
@ -563,25 +568,26 @@ class SettingsDialog(QDialog):
Args:
config_data: Configuration dictionary
"""
# Apply paths
self.paths_list.clear()
for path in config_data.get("allowed_roots", []):
self.paths_list.addItem(str(path))
# Apply URLs
self.urls_list.clear()
for url in config_data.get("allowed_urls", []):
self.urls_list.addItem(url)
# Apply logging settings
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 "")
# Apply window settings
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.
@ -591,13 +597,14 @@ class SettingsDialog(QDialog):
Raises:
ConfigurationError: If configuration is invalid
"""
if self.url_mappings_table:
url_mappings_table_count = self.url_mappings_table.rowCount() or 0
else:
url_mappings_table_count = 0
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": [
@ -607,8 +614,16 @@ class SettingsDialog(QDialog):
"webapp_url": self.webapp_url_input.text().strip(),
"url_mappings": [
{
"url_prefix": self.url_mappings_table.item(i, 0).text() if self.url_mappings_table.item(i, 0) else "", # type: ignore
"local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore
"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)
],
@ -617,9 +632,7 @@ class SettingsDialog(QDialog):
"enable_logging": self.config.enable_logging,
}
# Validate
ConfigValidator.validate_or_raise(config_data)
return config_data
def _show_error(self, message: str) -> None:
@ -630,4 +643,4 @@ class SettingsDialog(QDialog):
"""
from PySide6.QtWidgets import QMessageBox
QMessageBox.critical(self, "Error", message)
QMessageBox.critical(self, tr("dialog.error.title"), message)