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:
parent
fd0482ed2d
commit
7daec731ca
11 changed files with 1184 additions and 280 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
292
src/webdrop_bridge/utils/i18n.py
Normal file
292
src/webdrop_bridge/utils/i18n.py
Normal 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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue