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

@ -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: