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

@ -81,6 +81,7 @@ class Config:
window_title: str = ""
enable_logging: bool = True
enable_checkout: bool = False
language: str = "auto"
@classmethod
def from_file(cls, config_path: Path) -> "Config":
@ -172,6 +173,7 @@ class Config:
window_title=window_title,
enable_logging=data.get("enable_logging", True),
enable_checkout=data.get("enable_checkout", False),
language=data.get("language", "auto"),
)
@classmethod
@ -212,6 +214,7 @@ class Config:
window_title = os.getenv("WINDOW_TITLE", default_title)
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
language = os.getenv("LANGUAGE", "auto")
# Validate log level
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
@ -304,6 +307,7 @@ class Config:
window_title=window_title,
enable_logging=enable_logging,
enable_checkout=enable_checkout,
language=language,
)
def to_file(self, config_path: Path) -> None:
@ -332,6 +336,7 @@ class Config:
"window_title": self.window_title,
"enable_logging": self.enable_logging,
"enable_checkout": self.enable_checkout,
"language": self.language,
}
config_path.parent.mkdir(parents=True, exist_ok=True)

View file

@ -15,6 +15,8 @@ from PySide6.QtWidgets import QApplication
from webdrop_bridge.config import Config, ConfigurationError
from webdrop_bridge.ui.main_window import MainWindow
from webdrop_bridge.utils.i18n import get_translations_dir
from webdrop_bridge.utils.i18n import initialize as i18n_init
from webdrop_bridge.utils.logging import get_logger, setup_logging
@ -50,6 +52,11 @@ def main() -> int:
logger.info(f"Starting {config.app_name} v{config.app_version}")
logger.debug(f"Configuration: {config}")
# Initialize internationalization
translations_dir = get_translations_dir()
i18n_init(config.language, translations_dir)
logger.debug(f"i18n initialized: language={config.language}, dir={translations_dir}")
except ConfigurationError as e:
print(f"Configuration error: {e}", file=sys.stderr)
return 1

View file

@ -43,6 +43,7 @@ from webdrop_bridge.config import Config
from webdrop_bridge.core.drag_interceptor import DragInterceptor
from webdrop_bridge.core.validator import PathValidator
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
from webdrop_bridge.utils.i18n import tr
logger = logging.getLogger(__name__)
@ -245,7 +246,7 @@ class OpenDropZone(QWidget):
self._icon_label.setPixmap(pixmap)
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._icon_label.setStyleSheet(self._NORMAL_STYLE)
self._icon_label.setToolTip("Drop a file here to open it with its default application")
self._icon_label.setToolTip(tr("toolbar.tooltip.open_drop"))
layout.addWidget(self._icon_label)
self.setMinimumSize(QSize(44, 44))
@ -322,7 +323,7 @@ class OpenWithDropZone(OpenDropZone):
parent: Parent widget.
"""
super().__init__(parent)
self._icon_label.setToolTip("Drop a file here to choose which app should open it")
self._icon_label.setToolTip(tr("toolbar.tooltip.open_with_drop"))
def dropEvent(self, event) -> None: # type: ignore[override]
"""Emit dropped local files for app-chooser handling."""
@ -985,8 +986,8 @@ class MainWindow(QMainWindow):
reply = QMessageBox.question(
self,
"Checkout Asset",
f"Do you want to check out this asset?\n\n{filename}",
tr("dialog.checkout.title"),
tr("dialog.checkout.msg", filename=filename),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
@ -1090,8 +1091,8 @@ class MainWindow(QMainWindow):
# Show error dialog to user
QMessageBox.warning(
self,
"Drag-and-Drop Error",
f"Could not complete the drag-and-drop operation.\n\nError: {error}",
tr("dialog.drag_error.title"),
tr("dialog.drag_error.msg", error=error),
)
def _on_file_opened_via_drop(self, file_path: str) -> None:
@ -1101,7 +1102,7 @@ class MainWindow(QMainWindow):
file_path: Local file path that was opened.
"""
logger.info(f"Opened via drop zone: {file_path}")
self.statusBar().showMessage(f"Opened: {Path(file_path).name}", 4000)
self.statusBar().showMessage(tr("status.opened", name=Path(file_path).name), 4000)
def _on_file_open_failed_via_drop(self, file_path: str, error: str) -> None:
"""Handle a failure to open a file dropped on the OpenDropZone.
@ -1113,9 +1114,8 @@ class MainWindow(QMainWindow):
logger.warning(f"Failed to open via drop zone '{file_path}': {error}")
QMessageBox.warning(
self,
"Open File Error",
f"Could not open the file with its default application.\n\n"
f"File: {file_path}\nError: {error}",
tr("dialog.open_file_error.title"),
tr("dialog.open_file_error.msg", file_path=file_path, error=error),
)
def _on_file_open_with_requested(self, file_path: str) -> None:
@ -1125,15 +1125,15 @@ class MainWindow(QMainWindow):
file_path: Local file path to open using an app chooser.
"""
if self._open_with_app_chooser(file_path):
self.statusBar().showMessage(f"Choose app for: {Path(file_path).name}", 4000)
self.statusBar().showMessage(tr("status.choose_app", name=Path(file_path).name), 4000)
logger.info(f"Opened app chooser for '{file_path}'")
return
logger.warning(f"Could not open app chooser for '{file_path}'")
QMessageBox.warning(
self,
"Open With Error",
"Could not open an application chooser for this file on your platform.",
tr("dialog.open_with_error.title"),
tr("dialog.open_with_error.msg"),
)
def _open_with_app_chooser(self, file_path: str) -> bool:
@ -1234,7 +1234,7 @@ class MainWindow(QMainWindow):
logger.info(f"Download started: {filename}")
# Update status bar (temporarily)
self.status_bar.showMessage(f"📥 Download: {filename}", 3000)
self.status_bar.showMessage(tr("status.download_started", filename=filename), 3000)
# Connect to state changed for progress tracking
download.stateChanged.connect(
@ -1248,7 +1248,7 @@ class MainWindow(QMainWindow):
except Exception as e:
logger.error(f"Error handling download: {e}", exc_info=True)
self.status_bar.showMessage(f"Download error: {e}", 5000)
self.status_bar.showMessage(tr("status.download_error", error=str(e)), 5000)
def _on_download_finished(self, download: QWebEngineDownloadRequest, file_path: Path) -> None:
"""Handle download completion.
@ -1266,13 +1266,17 @@ class MainWindow(QMainWindow):
if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted:
logger.info(f"Download completed: {file_path.name}")
self.status_bar.showMessage(f"Download completed: {file_path.name}", 5000)
self.status_bar.showMessage(
tr("status.download_completed", name=file_path.name), 5000
)
elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled:
logger.info(f"Download cancelled: {file_path.name}")
self.status_bar.showMessage(f"⚠️ Download abgebrochen: {file_path.name}", 3000)
self.status_bar.showMessage(
tr("status.download_cancelled", name=file_path.name), 3000
)
elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted:
logger.warning(f"Download interrupted: {file_path.name}")
self.status_bar.showMessage(f"❌ Download fehlgeschlagen: {file_path.name}", 5000)
self.status_bar.showMessage(tr("status.download_failed", name=file_path.name), 5000)
except Exception as e:
logger.error(f"Error in download finished handler: {e}", exc_info=True)
@ -1395,7 +1399,7 @@ class MainWindow(QMainWindow):
else self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon)
)
home_action = toolbar.addAction(home_icon, "")
home_action.setToolTip("Home")
home_action.setToolTip(tr("toolbar.tooltip.home"))
home_action.triggered.connect(self._navigate_home)
# Refresh button
@ -1435,32 +1439,32 @@ class MainWindow(QMainWindow):
# About button (info icon) on the right
about_action = toolbar.addAction("")
about_action.setToolTip("About WebDrop Bridge")
about_action.setToolTip(tr("toolbar.tooltip.about"))
about_action.triggered.connect(self._show_about_dialog)
# Settings button on the right
settings_action = toolbar.addAction("⚙️")
settings_action.setToolTip("Settings")
settings_action.setToolTip(tr("toolbar.tooltip.settings"))
settings_action.triggered.connect(self._show_settings_dialog)
# Check for Updates button on the right
check_updates_action = toolbar.addAction("🔄")
check_updates_action.setToolTip("Check for Updates")
check_updates_action.setToolTip(tr("toolbar.tooltip.check_updates"))
check_updates_action.triggered.connect(self._on_manual_check_for_updates)
# Clear cache button on the right
clear_cache_action = toolbar.addAction("🗑️")
clear_cache_action.setToolTip("Clear Cache and Cookies")
clear_cache_action.setToolTip(tr("toolbar.tooltip.clear_cache"))
clear_cache_action.triggered.connect(self._clear_cache_and_cookies)
# Log file button on the right
log_action = toolbar.addAction("📋")
log_action.setToolTip("Open Log File")
log_action.setToolTip(tr("toolbar.tooltip.open_log"))
log_action.triggered.connect(self._open_log_file)
# Developer Tools button on the right
dev_tools_action = toolbar.addAction("🔧")
dev_tools_action.setToolTip("Developer Tools (F12)")
dev_tools_action.setToolTip(tr("toolbar.tooltip.dev_tools"))
dev_tools_action.triggered.connect(self._open_developer_tools)
def _open_log_file(self) -> None:
@ -1486,8 +1490,8 @@ class MainWindow(QMainWindow):
else:
QMessageBox.information(
self,
"Log File Not Found",
f"No log file found at:\n{log_file}",
tr("dialog.log_not_found.title"),
tr("dialog.log_not_found.msg", log_file=str(log_file)),
)
def _open_developer_tools(self) -> None:
@ -1504,7 +1508,7 @@ class MainWindow(QMainWindow):
# Create new window
self._dev_tools_window = QMainWindow()
self._dev_tools_window.setWindowTitle("🔧 Developer Tools")
self._dev_tools_window.setWindowTitle(tr("dialog.dev_tools.window_title"))
self._dev_tools_window.setGeometry(100, 100, 1200, 700)
self._dev_tools_window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
@ -1530,8 +1534,8 @@ class MainWindow(QMainWindow):
logger.error(f"Failed to open Developer Tools window: {e}", exc_info=True)
QMessageBox.warning(
self,
"Developer Tools",
f"Could not open Developer Tools:\n{e}",
tr("dialog.dev_tools.error_title"),
tr("dialog.dev_tools.error_msg", error=str(e)),
)
def _create_status_bar(self) -> None:
@ -1539,7 +1543,7 @@ class MainWindow(QMainWindow):
self.status_bar = self.statusBar()
# Update status label
self.update_status_label = QLabel("Ready")
self.update_status_label = QLabel(tr("status.ready"))
self.update_status_label.setStyleSheet("margin-right: 10px;")
self.status_bar.addPermanentWidget(self.update_status_label)
@ -1550,10 +1554,26 @@ class MainWindow(QMainWindow):
status: Status text to display
emoji: Optional emoji prefix (rotating, checkmark, download, warning symbols)
"""
# Map known internal status strings to translated display text
_STATIC_STATUS_MAP = {
"Checking for updates": "update.status.checking",
"Ready": "update.status.ready",
"Update deferred": "update.status.deferred",
"Ready to install": "update.status.ready_to_install",
"Installation started": "update.status.installation_started",
"Installation failed": "update.status.installation_failed",
"Download failed": "update.status.download_failed",
"Verification failed": "update.status.verification_failed",
"Operation timed out": "update.status.timed_out",
"Check timed out - no server response": "update.status.check_timed_out",
"Download timed out - no server response": "update.status.download_timed_out",
}
tr_key = _STATIC_STATUS_MAP.get(status)
display = tr(tr_key) if tr_key else status
if emoji:
self.update_status_label.setText(f"{emoji} {status}")
self.update_status_label.setText(f"{emoji} {display}")
else:
self.update_status_label.setText(status)
self.update_status_label.setText(display)
def _on_manual_check_for_updates(self) -> None:
"""Handle manual check for updates from menu.
@ -1589,17 +1609,16 @@ class MainWindow(QMainWindow):
# Show confirmation message
QMessageBox.information(
self,
"Cache Cleared",
"Browser cache and cookies have been cleared successfully.\n\n"
"You may need to reload the page or restart the application for changes to take effect.",
tr("dialog.cache_cleared.title"),
tr("dialog.cache_cleared.msg"),
)
logger.info("Cache and cookies cleared successfully")
except Exception as e:
logger.error(f"Failed to clear cache and cookies: {e}")
QMessageBox.warning(
self,
"Error",
f"Failed to clear cache and cookies: {str(e)}",
tr("dialog.cache_clear_failed.title"),
tr("dialog.cache_clear_failed.msg", error=str(e)),
)
def _show_about_dialog(self) -> None:
@ -1608,16 +1627,15 @@ class MainWindow(QMainWindow):
about_text = (
f"<b>{self.config.app_name}</b><br>"
f"Version: {self.config.app_version}<br>"
f"{tr('about.version', version=self.config.app_version)}<br>"
f"<br>"
f"Bridges web-based drag-and-drop workflows with native file operations "
f"for professional desktop applications.<br>"
f"{tr('about.description')}<br>"
f"<br>"
f"<b>Toolbar Drop Zones:</b><br>"
f"Open icon: Opens dropped files with the system default app.<br>"
f"Open-with icon: Shows an app chooser for dropped files.<br>"
f"<b>{tr('about.drop_zones_title')}</b><br>"
f"{tr('about.open_icon_desc')}<br>"
f"{tr('about.open_with_icon_desc')}<br>"
f"<br>"
f"<b>Product of:</b><br>"
f"<b>{tr('about.product_of')}</b><br>"
f"<b>hörl Information Management GmbH</b><br>"
f"Silberburgstraße 126<br>"
f"70176 Stuttgart, Germany<br>"
@ -1628,10 +1646,10 @@ class MainWindow(QMainWindow):
f"<b>Web:</b> <a href='https://www.hoerl-im.de/'>https://www.hoerl-im.de/</a><br>"
f"</small>"
f"<br>"
f"<small>© 2026 hörl Information Management GmbH. All rights reserved.</small>"
f"<small>{tr('about.rights')}</small>"
)
QMessageBox.about(self, f"About {self.config.app_name}", about_text)
QMessageBox.about(self, tr("about.title", app_name=self.config.app_name), about_text)
def _show_settings_dialog(self) -> None:
"""Show Settings dialog for configuration management.
@ -1704,18 +1722,17 @@ class MainWindow(QMainWindow):
from PySide6.QtWidgets import QMessageBox
msg = QMessageBox(self)
msg.setWindowTitle("Domain Changed - Restart Recommended")
msg.setWindowTitle(tr("dialog.domain_changed.title"))
msg.setIcon(QMessageBox.Icon.Warning)
msg.setText(
"Web Application Domain Has Changed\n\n"
"You've switched to a different domain. For maximum stability and "
"to ensure proper authentication, the application should be restarted.\n\n"
"The profile and cache have been cleared, but we recommend restarting."
)
msg.setText(tr("dialog.domain_changed.msg"))
# Add custom buttons
restart_now_btn = msg.addButton("Restart Now", QMessageBox.ButtonRole.AcceptRole)
restart_later_btn = msg.addButton("Restart Later", QMessageBox.ButtonRole.RejectRole)
restart_now_btn = msg.addButton(
tr("dialog.domain_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole
)
restart_later_btn = msg.addButton(
tr("dialog.domain_changed.restart_later"), QMessageBox.ButtonRole.RejectRole
)
msg.exec()
@ -1769,9 +1786,8 @@ class MainWindow(QMainWindow):
QMessageBox.warning(
self,
"Restart Failed",
f"Could not automatically restart the application:\n\n{str(e)}\n\n"
"Please restart manually.",
tr("dialog.restart_failed.title"),
tr("dialog.restart_failed.msg", error=str(e)),
)
def _navigate_home(self) -> None:
@ -1880,10 +1896,8 @@ class MainWindow(QMainWindow):
QMessageBox.warning(
self,
"Update Check Timeout",
"The server did not respond within 30 seconds.\n\n"
"This may be due to a network issue or server unavailability.\n\n"
"Please check your connection and try again.",
tr("dialog.update_timeout.title"),
tr("dialog.update_timeout.msg"),
)
safety_timer = QTimer()
@ -1960,7 +1974,7 @@ class MainWindow(QMainWindow):
error_message: Error description
"""
logger.error(f"Update check failed: {error_message}")
self.set_update_status(f"Check failed: {error_message}", emoji="")
self.set_update_status(tr("update.status.check_failed", error=error_message), emoji="")
self._is_manual_check = False
# Close checking dialog first, then show error
@ -1971,8 +1985,8 @@ class MainWindow(QMainWindow):
QMessageBox.warning(
self,
"Update Check Failed",
f"Could not check for updates:\n\n{error_message}\n\nPlease try again later.",
tr("dialog.update_failed.title"),
tr("dialog.update_failed.msg", error=error_message),
)
def _on_update_available(self, release) -> None:
@ -1988,7 +2002,7 @@ class MainWindow(QMainWindow):
self._is_manual_check = False
# Update status to show update available
self.set_update_status(f"Update available: v{release.version}", emoji="")
self.set_update_status(tr("update.status.available", version=release.version), emoji="")
# Show update available dialog
from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog
@ -2016,7 +2030,7 @@ class MainWindow(QMainWindow):
def _on_user_update_later(self) -> None:
"""Handle user clicking 'Later' button."""
logger.info("User deferred update")
self.set_update_status("Update deferred", emoji="")
self.set_update_status(tr("update.status.deferred"), emoji="")
def _start_update_download(self, release) -> None:
"""Start downloading the update in background thread.
@ -2025,7 +2039,7 @@ class MainWindow(QMainWindow):
release: Release object to download
"""
logger.info(f"Starting download for v{release.version}")
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️")
self.set_update_status(tr("update.status.downloading", version=release.version), emoji="⬇️")
# Show download progress dialog
from webdrop_bridge.ui.update_manager_ui import DownloadingDialog
@ -2139,7 +2153,7 @@ class MainWindow(QMainWindow):
self.downloading_dialog = None
logger.info(f"Download complete: {installer_path}")
self.set_update_status("Ready to install", emoji="")
self.set_update_status(tr("update.status.ready_to_install"), emoji="")
# Show install confirmation dialog
install_dialog = InstallDialog(parent=self)
@ -2163,8 +2177,8 @@ class MainWindow(QMainWindow):
QMessageBox.critical(
self,
"Download Failed",
f"Could not download the update:\n\n{error}\n\nPlease try again later.",
tr("dialog.download_failed.title"),
tr("dialog.download_failed.msg", error=error),
)
def _on_download_progress(self, downloaded: int, total: int) -> None:
@ -2192,10 +2206,10 @@ class MainWindow(QMainWindow):
)
if manager.install_update(installer_path):
self.set_update_status("Installation started", emoji="")
self.set_update_status(tr("update.status.installation_started"), emoji="")
logger.info("Update installer launched successfully")
else:
self.set_update_status("Installation failed", emoji="")
self.set_update_status(tr("update.status.installation_failed"), emoji="")
logger.error("Failed to launch update installer")
@ -2226,7 +2240,7 @@ class UpdateCheckWorker(QObject):
logger.debug("UpdateCheckWorker.run() starting")
# Notify checking status
self.update_status.emit("Checking for updates", "🔄")
self.update_status.emit("Checking for updates", "🔄") # Translated by set_update_status
# Create a fresh event loop for this thread
logger.debug("Creating new event loop for worker thread")
@ -2248,15 +2262,17 @@ class UpdateCheckWorker(QObject):
else:
# No update available - show ready status
logger.info("No update available")
self.update_status.emit("Ready", "")
self.update_status.emit(
"Ready", ""
) # English sentinel; _on_update_status compares this
except asyncio.TimeoutError:
logger.warning("Update check timed out - server not responding")
self.check_failed.emit("Server not responding - check again later")
self.check_failed.emit(tr("worker.server_not_responding"))
except Exception as e:
logger.error(f"Update check failed: {e}", exc_info=True)
self.check_failed.emit(f"Check failed: {str(e)[:50]}")
self.check_failed.emit(tr("worker.check_failed", error=str(e)[:50]))
finally:
# Properly close the event loop
if loop is not None:
@ -2297,7 +2313,9 @@ class UpdateDownloadWorker(QObject):
loop = None
try:
# Download the update
self.update_status.emit(f"Downloading v{self.release.version}", "⬇️")
self.update_status.emit(
tr("update.status.downloading", version=self.release.version), "⬇️"
)
# Create a fresh event loop for this thread
loop = asyncio.new_event_loop()
@ -2319,8 +2337,10 @@ class UpdateDownloadWorker(QObject):
)
if not installer_path:
self.update_status.emit("Download failed", "")
self.download_failed.emit("No installer found in release")
self.update_status.emit(
"Download failed", ""
) # Translated by set_update_status
self.download_failed.emit(tr("worker.no_installer"))
logger.error("Download failed - no installer found")
return
@ -2337,7 +2357,7 @@ class UpdateDownloadWorker(QObject):
if not checksum_ok:
self.update_status.emit("Verification failed", "")
self.download_failed.emit("Checksum verification failed")
self.download_failed.emit(tr("worker.checksum_failed"))
logger.error("Checksum verification failed")
return
@ -2346,17 +2366,17 @@ class UpdateDownloadWorker(QObject):
except asyncio.TimeoutError as e:
logger.error(f"Download/verification timed out: {e}")
self.update_status.emit("Operation timed out", "⏱️")
self.download_failed.emit(
"Download or verification timed out (no response from server)"
)
self.update_status.emit(
"Operation timed out", "⏱️"
) # Translated by set_update_status
self.download_failed.emit(tr("worker.download_timed_out"))
except Exception as e:
logger.error(f"Error during download: {e}")
self.download_failed.emit(f"Download error: {str(e)[:50]}")
self.download_failed.emit(tr("worker.download_error", error=str(e)[:50]))
except Exception as e:
logger.error(f"Download worker failed: {e}")
self.download_failed.emit(f"Download error: {str(e)[:50]}")
self.download_failed.emit(tr("worker.download_error", error=str(e)[:50]))
finally:
# Properly close the event loop
if loop is not None:

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)

View file

@ -25,6 +25,8 @@ from PySide6.QtWidgets import (
QVBoxLayout,
)
from webdrop_bridge.utils.i18n import tr
logger = logging.getLogger(__name__)
@ -41,7 +43,7 @@ class CheckingDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Checking for Updates")
self.setWindowTitle(tr("update.checking.title"))
self.setModal(True)
self.setMinimumWidth(300)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
@ -49,7 +51,7 @@ class CheckingDialog(QDialog):
layout = QVBoxLayout()
# Status label
self.label = QLabel("Checking for updates...")
self.label = QLabel(tr("update.checking.label"))
layout.addWidget(self.label)
# Animated progress bar
@ -58,7 +60,7 @@ class CheckingDialog(QDialog):
layout.addWidget(self.progress)
# Timeout info
info_label = QLabel("This may take up to 10 seconds")
info_label = QLabel(tr("update.checking.timeout_info"))
info_label.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(info_label)
@ -88,7 +90,7 @@ class UpdateAvailableDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Update Available")
self.setWindowTitle(tr("update.available.title"))
self.setModal(True)
self.setMinimumWidth(400)
self.setMinimumHeight(300)
@ -96,12 +98,12 @@ class UpdateAvailableDialog(QDialog):
layout = QVBoxLayout()
# Header
header = QLabel(f"WebDrop Bridge v{version} is available")
header = QLabel(tr("update.available.header", version=version))
header.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(header)
# Changelog
changelog_label = QLabel("Release Notes:")
changelog_label = QLabel(tr("update.available.changelog_label"))
changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
layout.addWidget(changelog_label)
@ -113,11 +115,11 @@ class UpdateAvailableDialog(QDialog):
# Buttons
button_layout = QHBoxLayout()
self.update_now_btn = QPushButton("Update Now")
self.update_now_btn = QPushButton(tr("update.available.update_now_btn"))
self.update_now_btn.clicked.connect(self._on_update_now)
button_layout.addWidget(self.update_now_btn)
self.update_later_btn = QPushButton("Later")
self.update_later_btn = QPushButton(tr("update.available.later_btn"))
self.update_later_btn.clicked.connect(self._on_update_later)
button_layout.addWidget(self.update_later_btn)
@ -153,7 +155,7 @@ class DownloadingDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Downloading Update")
self.setWindowTitle(tr("update.downloading.title"))
self.setModal(True)
self.setMinimumWidth(350)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
@ -161,12 +163,12 @@ class DownloadingDialog(QDialog):
layout = QVBoxLayout()
# Header
header = QLabel("Downloading update...")
header = QLabel(tr("update.downloading.header"))
header.setStyleSheet("font-weight: bold;")
layout.addWidget(header)
# File label
self.file_label = QLabel("Preparing download")
self.file_label = QLabel(tr("update.downloading.preparing"))
layout.addWidget(self.file_label)
# Progress bar
@ -182,7 +184,7 @@ class DownloadingDialog(QDialog):
layout.addWidget(self.size_label)
# Cancel button
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn = QPushButton(tr("update.downloading.cancel_btn"))
self.cancel_btn.clicked.connect(self._on_cancel)
layout.addWidget(self.cancel_btn)
@ -210,7 +212,7 @@ class DownloadingDialog(QDialog):
Args:
filename: Name of file being downloaded
"""
self.file_label.setText(f"Downloading: {filename}")
self.file_label.setText(tr("update.downloading.filename", filename=filename))
def _on_cancel(self):
"""Handle cancel button click."""
@ -236,26 +238,23 @@ class InstallDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Install Update")
self.setWindowTitle(tr("update.install.title"))
self.setModal(True)
self.setMinimumWidth(350)
layout = QVBoxLayout()
# Header
header = QLabel("Ready to Install")
header = QLabel(tr("update.install.header"))
header.setStyleSheet("font-weight: bold; font-size: 14px;")
layout.addWidget(header)
# Message
message = QLabel("The update is ready to install. The application will restart.")
message = QLabel(tr("update.install.message"))
layout.addWidget(message)
# Warning
warning = QLabel(
"⚠️ Please save any unsaved work before continuing.\n"
"The application will close and restart."
)
warning = QLabel(tr("update.install.warning"))
warning.setStyleSheet("background-color: #fff3cd; padding: 10px; border-radius: 4px;")
warning.setWordWrap(True)
layout.addWidget(warning)
@ -263,12 +262,12 @@ class InstallDialog(QDialog):
# Buttons
button_layout = QHBoxLayout()
self.install_btn = QPushButton("Install Now")
self.install_btn = QPushButton(tr("update.install.now_btn"))
self.install_btn.setStyleSheet("background-color: #28a745; color: white;")
self.install_btn.clicked.connect(self._on_install)
button_layout.addWidget(self.install_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn = QPushButton(tr("update.install.cancel_btn"))
self.cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_btn)
@ -294,22 +293,22 @@ class NoUpdateDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("No Updates Available")
self.setWindowTitle(tr("update.no_update.title"))
self.setModal(True)
self.setMinimumWidth(300)
layout = QVBoxLayout()
# Message
message = QLabel("✓ You're using the latest version")
message = QLabel(tr("update.no_update.message"))
message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;")
layout.addWidget(message)
info = QLabel("WebDrop Bridge is up to date.")
info = QLabel(tr("update.no_update.info"))
layout.addWidget(info)
# Close button
close_btn = QPushButton("OK")
close_btn = QPushButton(tr("update.no_update.ok_btn"))
close_btn.clicked.connect(self.accept)
layout.addWidget(close_btn)
@ -335,14 +334,14 @@ class ErrorDialog(QDialog):
parent: Parent widget
"""
super().__init__(parent)
self.setWindowTitle("Update Failed")
self.setWindowTitle(tr("update.error.title"))
self.setModal(True)
self.setMinimumWidth(350)
layout = QVBoxLayout()
# Header
header = QLabel("⚠️ Update Failed")
header = QLabel(tr("update.error.header"))
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;")
layout.addWidget(header)
@ -354,7 +353,7 @@ class ErrorDialog(QDialog):
layout.addWidget(self.error_text)
# Info message
info = QLabel("Please try again or visit the website to download the update manually.")
info = QLabel(tr("update.error.info"))
info.setWordWrap(True)
info.setStyleSheet("color: gray; font-size: 11px;")
layout.addWidget(info)
@ -362,15 +361,15 @@ class ErrorDialog(QDialog):
# Buttons
button_layout = QHBoxLayout()
self.retry_btn = QPushButton("Retry")
self.retry_btn = QPushButton(tr("update.error.retry_btn"))
self.retry_btn.clicked.connect(self._on_retry)
button_layout.addWidget(self.retry_btn)
self.manual_btn = QPushButton("Download Manually")
self.manual_btn = QPushButton(tr("update.error.manual_btn"))
self.manual_btn.clicked.connect(self._on_manual)
button_layout.addWidget(self.manual_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn = QPushButton(tr("update.error.cancel_btn"))
self.cancel_btn.clicked.connect(self.reject)
button_layout.addWidget(self.cancel_btn)

View file

@ -0,0 +1,292 @@
"""Internationalization (i18n) support for WebDrop Bridge.
Provides a simple JSON-based translation system. Translation files are stored
in ``resources/translations/`` (e.g. ``en.json``, ``de.json``, ``fr.json``).
Usage::
from webdrop_bridge.utils.i18n import tr
# Simple lookup
self.setWindowTitle(tr("settings.title"))
# With named format arguments
label.setText(tr("status.opened", name="file.pdf"))
To add a new language, place a JSON file named ``<code>.json`` in
``resources/translations/`` and optionally add an entry to
:attr:`Translator.BUILTIN_LANGUAGES` for a nicer display name.
"""
import json
import logging
import sys
from pathlib import Path
from typing import Dict, Optional
logger = logging.getLogger(__name__)
class Translator:
"""Manages translations for the application.
Loads translations from UTF-8 JSON files that use dot-notation string keys.
Falls back to the English translation (and ultimately to the bare key) when
a translation is missing.
Attributes:
BUILTIN_LANGUAGES: Mapping of language code display name for languages
that ship with the application. Add entries here when including new
translation files.
"""
#: Human-readable display names for supported language codes.
#: Unknown codes fall back to their uppercase code string.
BUILTIN_LANGUAGES: Dict[str, str] = {
"en": "English",
"de": "Deutsch",
"fr": "Français",
"it": "Italiano",
"ru": "Русский",
"zh": "中文",
}
def __init__(self) -> None:
self._language: str = "en"
self._translations: Dict[str, str] = {}
self._fallback: Dict[str, str] = {}
self._translations_dir: Optional[Path] = None
def initialize(self, language: str, translations_dir: Path) -> None:
"""Initialize the translator with a language and translations directory.
Args:
language: Language code (e.g. ``"en"``, ``"de"``, ``"fr"``) or
``"auto"`` to detect from the system locale.
translations_dir: Directory containing the ``.json`` translation files.
"""
self._translations_dir = translations_dir
# Resolve "auto" to system locale
if language == "auto":
language = self._detect_system_language()
logger.debug(f"Auto-detected language: {language}")
# Load English as fallback first
en_path = translations_dir / "en.json"
if en_path.exists():
self._fallback = self._load_file(en_path)
logger.debug(f"Loaded English fallback translations ({len(self._fallback)} keys)")
else:
logger.warning(f"English translation file not found at {en_path}")
# Load requested language
self._language = language
if language != "en":
lang_path = translations_dir / f"{language}.json"
if lang_path.exists():
self._translations = self._load_file(lang_path)
logger.debug(f"Loaded '{language}' translations ({len(self._translations)} keys)")
else:
logger.warning(
f"Translation file not found for language '{language}', "
"falling back to English"
)
self._translations = {}
else:
self._translations = self._fallback
def tr(self, key: str, **kwargs: str) -> str:
"""Get translated string for the given key.
Args:
key: Translation key using dot-notation (e.g. ``"toolbar.home"``).
**kwargs: Named format arguments applied to the translated string.
Returns:
Translated and formatted string. Returns the *key* itself when no
translation is found, so missing keys are always visible.
"""
text = self._translations.get(key) or self._fallback.get(key) or key
if kwargs:
try:
text = text.format(**kwargs)
except (KeyError, ValueError) as e:
logger.debug(f"Translation format error for key '{key}': {e}")
return text
def get_current_language(self) -> str:
"""Get the currently active language code (e.g. ``"de"``)."""
return self._language
def get_available_languages(self) -> Dict[str, str]:
"""Return available languages as ``{code: display_name}``.
Discovers language files at runtime so newly added JSON files are
automatically included without code changes.
Returns:
Ordered dict mapping language code human-readable display name.
"""
if self._translations_dir is None:
return {"en": "English"}
languages: Dict[str, str] = {}
for lang_file in sorted(self._translations_dir.glob("*.json")):
code = lang_file.stem
name = self.BUILTIN_LANGUAGES.get(code, code.upper())
languages[code] = name
return languages
# ------------------------------------------------------------------
# Private helpers
# ------------------------------------------------------------------
def _load_file(self, path: Path) -> Dict[str, str]:
"""Load a JSON translation file.
Args:
path: Path to the UTF-8 encoded JSON translation file.
Returns:
Dictionary of translation keys to translated strings, or an empty
dict when the file cannot be read or parsed.
"""
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as e:
logger.error(f"Failed to load translation file {path}: {e}")
return {}
def _detect_system_language(self) -> str:
"""Detect system language from locale or platform settings.
On Windows, attempts to read the UI language via the WinAPI before
falling back to the ``locale`` module.
Returns:
Best-matching supported language code, or ``"en"`` as fallback.
"""
import locale
try:
lang_code: Optional[str] = None
if sys.platform.startswith("win"):
# Windows: use GetUserDefaultUILanguage for accuracy
try:
import ctypes
lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage() # type: ignore[attr-defined]
# Subset of LCID → ISO 639-1 mappings
lcid_map: Dict[int, str] = {
0x0407: "de", # German (Germany)
0x0C07: "de", # German (Austria)
0x0807: "de", # German (Switzerland)
0x040C: "fr", # French (France)
0x080C: "fr", # French (Belgium)
0x0C0C: "fr", # French (Canada)
0x100C: "fr", # French (Switzerland)
0x0410: "it", # Italian (Italy)
0x0810: "it", # Italian (Switzerland)
0x0419: "ru", # Russian
0x0804: "zh", # Chinese Simplified
0x0404: "zh", # Chinese Traditional
0x0409: "en", # English (US)
0x0809: "en", # English (UK)
}
lang_code = lcid_map.get(lcid)
except Exception:
pass
if not lang_code:
raw = locale.getdefaultlocale()[0] or ""
lang_code = raw.split("_")[0].lower() if raw else None
if lang_code and lang_code in self.BUILTIN_LANGUAGES:
return lang_code
except Exception as e:
logger.debug(f"Language auto-detection failed: {e}")
return "en"
# ---------------------------------------------------------------------------
# Module-level singleton and public API
# ---------------------------------------------------------------------------
_translator = Translator()
def _ensure_initialized() -> None:
"""Initialize translator lazily with default settings if needed."""
if _translator._translations_dir is not None: # type: ignore[attr-defined]
return
_translator.initialize("en", get_translations_dir())
def initialize(language: str, translations_dir: Path) -> None:
"""Initialize the global translator.
Should be called **once at application startup**, before any UI is shown.
Args:
language: Language code (e.g. ``"de"``) or ``"auto"`` for system
locale detection.
translations_dir: Directory containing the ``.json`` translation files.
"""
_translator.initialize(language, translations_dir)
def tr(key: str, **kwargs: str) -> str:
"""Translate a string by key.
Args:
key: Translation key (e.g. ``"toolbar.home"``).
**kwargs: Named format arguments (e.g. ``name="file.pdf"``).
Returns:
Translated string with any format substitutions applied.
"""
_ensure_initialized()
text = _translator.tr(key, **kwargs)
# If lookup failed and translator points to a non-default directory (e.g. tests
# overriding translator state), retry from default bundled translations.
if text == key:
default_dir = get_translations_dir()
current_dir = _translator._translations_dir # type: ignore[attr-defined]
if current_dir != default_dir:
_translator.initialize("en", default_dir)
text = _translator.tr(key, **kwargs)
return text
def get_current_language() -> str:
"""Return the currently active language code (e.g. ``"de"``)."""
return _translator.get_current_language()
def get_available_languages() -> Dict[str, str]:
"""Return all available languages as ``{code: display_name}``."""
_ensure_initialized()
return _translator.get_available_languages()
def get_translations_dir() -> Path:
"""Resolve the translations directory for the current runtime context.
Handles development mode, PyInstaller bundles, and MSI installations
by searching the known candidate paths in order.
Returns:
Path to the ``resources/translations`` directory.
"""
if hasattr(sys, "_MEIPASS"):
# PyInstaller bundle
return Path(sys._MEIPASS) / "resources" / "translations" # type: ignore[attr-defined]
# Development mode or installed Python package
return Path(__file__).parent.parent.parent.parent / "resources" / "translations"