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

@ -0,0 +1,167 @@
{
"toolbar.tooltip.open_drop": "Datei hier ablegen, um sie mit der Standardanwendung zu \u00f6ffnen",
"toolbar.tooltip.open_with_drop": "Datei hier ablegen, um die \u00d6ffnen-mit-App auszuw\u00e4hlen",
"toolbar.tooltip.home": "Startseite",
"toolbar.tooltip.about": "\u00dcber WebDrop Bridge",
"toolbar.tooltip.settings": "Einstellungen",
"toolbar.tooltip.check_updates": "Nach Updates suchen",
"toolbar.tooltip.clear_cache": "Cache und Cookies l\u00f6schen",
"toolbar.tooltip.open_log": "Protokolldatei \u00f6ffnen",
"toolbar.tooltip.dev_tools": "Entwicklerwerkzeuge (F12)",
"status.ready": "Bereit",
"status.opened": "Ge\u00f6ffnet: {name}",
"status.choose_app": "App ausw\u00e4hlen f\u00fcr: {name}",
"status.download_started": "\ud83d\udce5 Download: {filename}",
"status.download_completed": "Download abgeschlossen: {name}",
"status.download_cancelled": "\u26a0\ufe0f Download abgebrochen: {name}",
"status.download_failed": "\u274c Download fehlgeschlagen: {name}",
"status.download_error": "Downloadfehler: {error}",
"update.status.checking": "Suche nach Updates",
"update.status.ready": "Bereit",
"update.status.available": "Update verf\u00fcgbar: v{version}",
"update.status.deferred": "Update verschoben",
"update.status.downloading": "Lade v{version} herunter",
"update.status.verifying": "Pr\u00fcfe Download",
"update.status.download_failed": "Download fehlgeschlagen",
"update.status.verification_failed": "Pr\u00fcfung fehlgeschlagen",
"update.status.timed_out": "Zeitüberschreitung",
"update.status.ready_to_install": "Bereit zur Installation",
"update.status.installation_started": "Installation gestartet",
"update.status.installation_failed": "Installation fehlgeschlagen",
"update.status.check_timed_out": "Zeitüberschreitung \u2013 keine Serverantwort",
"update.status.check_failed": "Fehler: {error}",
"update.status.download_timed_out": "Zeitüberschreitung beim Download",
"dialog.error.title": "Fehler",
"dialog.log_not_found.title": "Protokolldatei nicht gefunden",
"dialog.log_not_found.msg": "Keine Protokolldatei gefunden unter:\n{log_file}",
"dialog.cache_cleared.title": "Cache geleert",
"dialog.cache_cleared.msg": "Browser-Cache und Cookies wurden erfolgreich geleert.\n\nBitte laden Sie die Seite neu oder starten Sie die Anwendung neu, damit die \u00c4nderungen wirksam werden.",
"dialog.cache_clear_failed.title": "Fehler",
"dialog.cache_clear_failed.msg": "Fehler beim Leeren von Cache und Cookies: {error}",
"dialog.drag_error.title": "Drag-and-Drop-Fehler",
"dialog.drag_error.msg": "Der Drag-and-Drop-Vorgang konnte nicht abgeschlossen werden.\n\nFehler: {error}",
"dialog.open_file_error.title": "Fehler beim \u00d6ffnen",
"dialog.open_file_error.msg": "Die Datei konnte nicht mit der Standardanwendung ge\u00f6ffnet werden.\n\nDatei: {file_path}\nFehler: {error}",
"dialog.open_with_error.title": "\u00d6ffnen mit \u2013 Fehler",
"dialog.open_with_error.msg": "Auf dieser Plattform konnte kein Anwendungsauswahldialog ge\u00f6ffnet werden.",
"dialog.dev_tools.window_title": "\ud83d\udd27 Entwicklerwerkzeuge",
"dialog.dev_tools.error_title": "Entwicklerwerkzeuge",
"dialog.dev_tools.error_msg": "Entwicklerwerkzeuge konnten nicht ge\u00f6ffnet werden:\n{error}",
"dialog.domain_changed.title": "Domain ge\u00e4ndert \u2013 Neustart empfohlen",
"dialog.domain_changed.msg": "Die Web-Anwendungs-Domain wurde ge\u00e4ndert\n\nSie haben zu einer anderen Domain gewechselt. F\u00fcr maximale Stabilit\u00e4t und korrekte Authentifizierung sollte die Anwendung neu gestartet werden.\n\nProfil und Cache wurden geleert, aber ein Neustart wird empfohlen.",
"dialog.domain_changed.restart_now": "Jetzt neu starten",
"dialog.domain_changed.restart_later": "Sp\u00e4ter neu starten",
"dialog.restart_failed.title": "Neustart fehlgeschlagen",
"dialog.restart_failed.msg": "Die Anwendung konnte nicht automatisch neu gestartet werden:\n\n{error}\n\nBitte starten Sie manuell neu.",
"dialog.update_timeout.title": "Zeitüberschreitung bei der Update-Pr\u00fcfung",
"dialog.update_timeout.msg": "Der Server hat nicht innerhalb von 30 Sekunden geantwortet.\n\nM\u00f6glicherweise liegt ein Netzwerkproblem oder eine Serverunavailability vor.\n\nBitte \u00fcberpr\u00fcfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"dialog.update_failed.title": "Update-Pr\u00fcfung fehlgeschlagen",
"dialog.update_failed.msg": "Updates konnten nicht gepr\u00fcft werden:\n\n{error}\n\nBitte versuchen Sie es sp\u00e4ter erneut.",
"dialog.download_failed.title": "Download fehlgeschlagen",
"dialog.download_failed.msg": "Das Update konnte nicht heruntergeladen werden:\n\n{error}\n\nBitte versuchen Sie es sp\u00e4ter erneut.",
"dialog.checkout.title": "Asset auschecken",
"dialog.checkout.msg": "M\u00f6chten Sie dieses Asset auschecken?\n\n{filename}",
"about.title": "\u00dcber {app_name}",
"about.version": "Version: {version}",
"about.description": "Verbindet webbasierte Drag-and-Drop-Workflows mit nativen Dateioperationen f\u00fcr professionelle Desktop-Anwendungen.",
"about.drop_zones_title": "Toolbar-Ablagezonen:",
"about.open_icon_desc": "\u00d6ffnen-Symbol: \u00d6ffnet abgelegte Dateien mit der Standard-App.",
"about.open_with_icon_desc": "\u00d6ffnen-mit-Symbol: Zeigt einen App-Auswahldialog f\u00fcr abgelegte Dateien.",
"about.product_of": "Ein Produkt von:",
"about.rights": "\u00a9 2026 h\u00f6rl Information Management GmbH. Alle Rechte vorbehalten.",
"settings.title": "Einstellungen",
"settings.tab.web_source": "Web-Quelle",
"settings.tab.paths": "Pfade",
"settings.tab.urls": "URLs",
"settings.tab.logging": "Protokollierung",
"settings.tab.window": "Fenster",
"settings.tab.profiles": "Profile",
"settings.tab.general": "Allgemein",
"settings.web_url.label": "Web-Anwendungs-URL:",
"settings.web_url.placeholder": "z.B. http://localhost:8080 oder file:///./webapp/index.html",
"settings.web_url.open_btn": "\u00d6ffnen",
"settings.url_mappings.label": "URL-Zuordnungen (Azure Blob Storage \u2192 Lokale Pfade):",
"settings.url_mappings.col_prefix": "URL-Pr\u00e4fix",
"settings.url_mappings.col_path": "Lokaler Pfad",
"settings.url_mappings.add_btn": "Zuordnung hinzuf\u00fcgen",
"settings.url_mappings.edit_btn": "Auswahl bearbeiten",
"settings.url_mappings.remove_btn": "Auswahl entfernen",
"settings.paths.label": "Erlaubte Stammverzeichnisse f\u00fcr den Dateizugriff:",
"settings.paths.add_btn": "Pfad hinzuf\u00fcgen",
"settings.paths.remove_btn": "Auswahl entfernen",
"settings.urls.label": "Erlaubte Web-URLs (unterst\u00fctzt Platzhalter wie http://*.example.com):",
"settings.urls.add_btn": "URL hinzuf\u00fcgen",
"settings.urls.remove_btn": "Auswahl entfernen",
"settings.log_level.label": "Protokollstufe:",
"settings.log_file.label": "Protokolldatei (optional):",
"settings.log_file.browse_btn": "Durchsuchen...",
"settings.window.width_label": "Fensterbreite:",
"settings.window.height_label": "Fensterh\u00f6he:",
"settings.profiles.label": "Gespeicherte Konfigurationsprofile:",
"settings.profiles.save_btn": "Als Profil speichern",
"settings.profiles.load_btn": "Profil laden",
"settings.profiles.delete_btn": "Profil l\u00f6schen",
"settings.profiles.export_btn": "Konfiguration exportieren",
"settings.profiles.import_btn": "Konfiguration importieren",
"settings.general.language_label": "Sprache:",
"settings.general.language_restart_note": "Sprach\u00e4nderung wirksam nach Neustart.",
"settings.add_mapping.url_title": "URL-Zuordnung hinzuf\u00fcgen",
"settings.add_mapping.url_prompt": "Azure Blob Storage URL-Pr\u00e4fix eingeben:\n(z.B. https://myblob.blob.core.windows.net/container/)",
"settings.add_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:\n(z.B. C:\\Freigabe oder /mnt/share)",
"settings.edit_mapping.title": "URL-Zuordnung bearbeiten",
"settings.edit_mapping.url_prompt": "Azure Blob Storage URL-Pr\u00e4fix eingeben:",
"settings.edit_mapping.path_prompt": "Lokalen Dateisystempfad eingeben:",
"settings.add_url.title": "URL hinzuf\u00fcgen",
"settings.add_url.prompt": "URL-Muster eingeben (z.B. http://example.com oder http://*.example.com):",
"settings.profile.save.title": "Profil speichern",
"settings.profile.save.prompt": "Profilnamen eingeben (z.B. Arbeit, Privat):",
"settings.select_directory.title": "Verzeichnis ausw\u00e4hlen",
"settings.select_log_file.title": "Protokolldatei ausw\u00e4hlen",
"settings.export_config.title": "Konfiguration exportieren",
"settings.import_config.title": "Konfiguration importieren",
"settings.error.select_mapping": "Bitte w\u00e4hlen Sie eine Zuordnung zur Bearbeitung aus",
"settings.error.select_profile_load": "Bitte w\u00e4hlen Sie ein Profil zum Laden aus",
"settings.error.select_profile_delete": "Bitte w\u00e4hlen Sie ein Profil zum L\u00f6schen aus",
"update.checking.title": "Update-Pr\u00fcfung",
"update.checking.label": "Suche nach Updates...",
"update.checking.timeout_info": "Dies kann bis zu 10 Sekunden dauern",
"update.available.title": "Update verf\u00fcgbar",
"update.available.header": "WebDrop Bridge v{version} ist verf\u00fcgbar",
"update.available.changelog_label": "Versionshinweise:",
"update.available.update_now_btn": "Jetzt aktualisieren",
"update.available.later_btn": "Sp\u00e4ter",
"update.downloading.title": "Update wird heruntergeladen",
"update.downloading.header": "Update wird heruntergeladen...",
"update.downloading.preparing": "Download wird vorbereitet",
"update.downloading.filename": "Lade herunter: {filename}",
"update.downloading.cancel_btn": "Abbrechen",
"update.install.title": "Update installieren",
"update.install.header": "Bereit zur Installation",
"update.install.message": "Das Update ist zur Installation bereit. Die Anwendung wird neu gestartet.",
"update.install.warning": "\u26a0\ufe0f Bitte speichern Sie alle nicht gespeicherten Arbeiten vor dem Fortfahren.\nDie Anwendung wird geschlossen und neu gestartet.",
"update.install.now_btn": "Jetzt installieren",
"update.install.cancel_btn": "Abbrechen",
"update.no_update.title": "Keine Updates verf\u00fcgbar",
"update.no_update.message": "\u2713 Sie verwenden die neueste Version",
"update.no_update.info": "WebDrop Bridge ist auf dem neuesten Stand.",
"update.no_update.ok_btn": "OK",
"update.error.title": "Update fehlgeschlagen",
"update.error.header": "\u26a0\ufe0f Update fehlgeschlagen",
"update.error.info": "Bitte versuchen Sie es erneut oder besuchen Sie die Website, um das Update manuell herunterzuladen.",
"update.error.retry_btn": "Wiederholen",
"update.error.manual_btn": "Manuell herunterladen",
"update.error.cancel_btn": "Abbrechen",
"worker.server_not_responding": "Server antwortet nicht \u2013 bitte sp\u00e4ter erneut pr\u00fcfen",
"worker.no_installer": "Kein Installationspaket in der Version gefunden",
"worker.checksum_failed": "Pr\u00fcfsummenverifizierung fehlgeschlagen",
"worker.download_timed_out": "Zeitüberschreitung beim Download oder der Verifizierung",
"worker.download_error": "Downloadfehler: {error}",
"worker.check_failed": "Pr\u00fcfung fehlgeschlagen: {error}"
}

View file

@ -0,0 +1,167 @@
{
"toolbar.tooltip.open_drop": "Drop a file here to open it with its default application",
"toolbar.tooltip.open_with_drop": "Drop a file here to choose which app should open it",
"toolbar.tooltip.home": "Home",
"toolbar.tooltip.about": "About WebDrop Bridge",
"toolbar.tooltip.settings": "Settings",
"toolbar.tooltip.check_updates": "Check for Updates",
"toolbar.tooltip.clear_cache": "Clear Cache and Cookies",
"toolbar.tooltip.open_log": "Open Log File",
"toolbar.tooltip.dev_tools": "Developer Tools (F12)",
"status.ready": "Ready",
"status.opened": "Opened: {name}",
"status.choose_app": "Choose app for: {name}",
"status.download_started": "\ud83d\udce5 Download: {filename}",
"status.download_completed": "Download completed: {name}",
"status.download_cancelled": "\u26a0\ufe0f Download cancelled: {name}",
"status.download_failed": "\u274c Download failed: {name}",
"status.download_error": "Download error: {error}",
"update.status.checking": "Checking for updates",
"update.status.ready": "Ready",
"update.status.available": "Update available: v{version}",
"update.status.deferred": "Update deferred",
"update.status.downloading": "Downloading v{version}",
"update.status.verifying": "Verifying download",
"update.status.download_failed": "Download failed",
"update.status.verification_failed": "Verification failed",
"update.status.timed_out": "Operation timed out",
"update.status.ready_to_install": "Ready to install",
"update.status.installation_started": "Installation started",
"update.status.installation_failed": "Installation failed",
"update.status.check_timed_out": "Check timed out - no server response",
"update.status.check_failed": "Check failed: {error}",
"update.status.download_timed_out": "Download timed out - no server response",
"dialog.error.title": "Error",
"dialog.log_not_found.title": "Log File Not Found",
"dialog.log_not_found.msg": "No log file found at:\n{log_file}",
"dialog.cache_cleared.title": "Cache Cleared",
"dialog.cache_cleared.msg": "Browser cache and cookies have been cleared successfully.\n\nYou may need to reload the page or restart the application for changes to take effect.",
"dialog.cache_clear_failed.title": "Error",
"dialog.cache_clear_failed.msg": "Failed to clear cache and cookies: {error}",
"dialog.drag_error.title": "Drag-and-Drop Error",
"dialog.drag_error.msg": "Could not complete the drag-and-drop operation.\n\nError: {error}",
"dialog.open_file_error.title": "Open File Error",
"dialog.open_file_error.msg": "Could not open the file with its default application.\n\nFile: {file_path}\nError: {error}",
"dialog.open_with_error.title": "Open With Error",
"dialog.open_with_error.msg": "Could not open an application chooser for this file on your platform.",
"dialog.dev_tools.window_title": "\ud83d\udd27 Developer Tools",
"dialog.dev_tools.error_title": "Developer Tools",
"dialog.dev_tools.error_msg": "Could not open Developer Tools:\n{error}",
"dialog.domain_changed.title": "Domain Changed - Restart Recommended",
"dialog.domain_changed.msg": "Web Application Domain Has Changed\n\nYou've switched to a different domain. For maximum stability and to ensure proper authentication, the application should be restarted.\n\nThe profile and cache have been cleared, but we recommend restarting.",
"dialog.domain_changed.restart_now": "Restart Now",
"dialog.domain_changed.restart_later": "Restart Later",
"dialog.restart_failed.title": "Restart Failed",
"dialog.restart_failed.msg": "Could not automatically restart the application:\n\n{error}\n\nPlease restart manually.",
"dialog.update_timeout.title": "Update Check Timeout",
"dialog.update_timeout.msg": "The server did not respond within 30 seconds.\n\nThis may be due to a network issue or server unavailability.\n\nPlease check your connection and try again.",
"dialog.update_failed.title": "Update Check Failed",
"dialog.update_failed.msg": "Could not check for updates:\n\n{error}\n\nPlease try again later.",
"dialog.download_failed.title": "Download Failed",
"dialog.download_failed.msg": "Could not download the update:\n\n{error}\n\nPlease try again later.",
"dialog.checkout.title": "Checkout Asset",
"dialog.checkout.msg": "Do you want to check out this asset?\n\n{filename}",
"about.title": "About {app_name}",
"about.version": "Version: {version}",
"about.description": "Bridges web-based drag-and-drop workflows with native file operations for professional desktop applications.",
"about.drop_zones_title": "Toolbar Drop Zones:",
"about.open_icon_desc": "Open icon: Opens dropped files with the system default app.",
"about.open_with_icon_desc": "Open-with icon: Shows an app chooser for dropped files.",
"about.product_of": "Product of:",
"about.rights": "\u00a9 2026 h\u00f6rl Information Management GmbH. All rights reserved.",
"settings.title": "Settings",
"settings.tab.web_source": "Web Source",
"settings.tab.paths": "Paths",
"settings.tab.urls": "URLs",
"settings.tab.logging": "Logging",
"settings.tab.window": "Window",
"settings.tab.profiles": "Profiles",
"settings.tab.general": "General",
"settings.web_url.label": "Web Application URL:",
"settings.web_url.placeholder": "e.g., http://localhost:8080 or file:///./webapp/index.html",
"settings.web_url.open_btn": "Open",
"settings.url_mappings.label": "URL Mappings (Azure Blob Storage \u2192 Local Paths):",
"settings.url_mappings.col_prefix": "URL Prefix",
"settings.url_mappings.col_path": "Local Path",
"settings.url_mappings.add_btn": "Add Mapping",
"settings.url_mappings.edit_btn": "Edit Selected",
"settings.url_mappings.remove_btn": "Remove Selected",
"settings.paths.label": "Allowed root directories for file access:",
"settings.paths.add_btn": "Add Path",
"settings.paths.remove_btn": "Remove Selected",
"settings.urls.label": "Allowed web URLs (supports wildcards like http://*.example.com):",
"settings.urls.add_btn": "Add URL",
"settings.urls.remove_btn": "Remove Selected",
"settings.log_level.label": "Log Level:",
"settings.log_file.label": "Log File (optional):",
"settings.log_file.browse_btn": "Browse...",
"settings.window.width_label": "Window Width:",
"settings.window.height_label": "Window Height:",
"settings.profiles.label": "Saved Configuration Profiles:",
"settings.profiles.save_btn": "Save as Profile",
"settings.profiles.load_btn": "Load Profile",
"settings.profiles.delete_btn": "Delete Profile",
"settings.profiles.export_btn": "Export Configuration",
"settings.profiles.import_btn": "Import Configuration",
"settings.general.language_label": "Language:",
"settings.general.language_restart_note": "Language change takes effect after restart.",
"settings.add_mapping.url_title": "Add URL Mapping",
"settings.add_mapping.url_prompt": "Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)",
"settings.add_mapping.path_prompt": "Enter local file system path:\n(e.g., C:\\Share or /mnt/share)",
"settings.edit_mapping.title": "Edit URL Mapping",
"settings.edit_mapping.url_prompt": "Enter Azure Blob Storage URL prefix:",
"settings.edit_mapping.path_prompt": "Enter local file system path:",
"settings.add_url.title": "Add URL",
"settings.add_url.prompt": "Enter URL pattern (e.g., http://example.com or http://*.example.com):",
"settings.profile.save.title": "Save Profile",
"settings.profile.save.prompt": "Enter profile name (e.g., work, personal):",
"settings.select_directory.title": "Select Directory to Allow",
"settings.select_log_file.title": "Select Log File",
"settings.export_config.title": "Export Configuration",
"settings.import_config.title": "Import Configuration",
"settings.error.select_mapping": "Please select a mapping to edit",
"settings.error.select_profile_load": "Please select a profile to load",
"settings.error.select_profile_delete": "Please select a profile to delete",
"update.checking.title": "Checking for Updates",
"update.checking.label": "Checking for updates...",
"update.checking.timeout_info": "This may take up to 10 seconds",
"update.available.title": "Update Available",
"update.available.header": "WebDrop Bridge v{version} is available",
"update.available.changelog_label": "Release Notes:",
"update.available.update_now_btn": "Update Now",
"update.available.later_btn": "Later",
"update.downloading.title": "Downloading Update",
"update.downloading.header": "Downloading update...",
"update.downloading.preparing": "Preparing download",
"update.downloading.filename": "Downloading: {filename}",
"update.downloading.cancel_btn": "Cancel",
"update.install.title": "Install Update",
"update.install.header": "Ready to Install",
"update.install.message": "The update is ready to install. The application will restart.",
"update.install.warning": "\u26a0\ufe0f Please save any unsaved work before continuing.\nThe application will close and restart.",
"update.install.now_btn": "Install Now",
"update.install.cancel_btn": "Cancel",
"update.no_update.title": "No Updates Available",
"update.no_update.message": "\u2713 You're using the latest version",
"update.no_update.info": "WebDrop Bridge is up to date.",
"update.no_update.ok_btn": "OK",
"update.error.title": "Update Failed",
"update.error.header": "\u26a0\ufe0f Update Failed",
"update.error.info": "Please try again or visit the website to download the update manually.",
"update.error.retry_btn": "Retry",
"update.error.manual_btn": "Download Manually",
"update.error.cancel_btn": "Cancel",
"worker.server_not_responding": "Server not responding - check again later",
"worker.no_installer": "No installer found in release",
"worker.checksum_failed": "Checksum verification failed",
"worker.download_timed_out": "Download or verification timed out (no response from server)",
"worker.download_error": "Download error: {error}",
"worker.check_failed": "Check failed: {error}"
}

View file

@ -0,0 +1,167 @@
{
"toolbar.tooltip.open_drop": "D\u00e9posez un fichier ici pour l'ouvrir avec son application par d\u00e9faut",
"toolbar.tooltip.open_with_drop": "D\u00e9posez un fichier ici pour choisir l'application qui doit l'ouvrir",
"toolbar.tooltip.home": "Accueil",
"toolbar.tooltip.about": "\u00c0 propos de WebDrop Bridge",
"toolbar.tooltip.settings": "Param\u00e8tres",
"toolbar.tooltip.check_updates": "Rechercher des mises \u00e0 jour",
"toolbar.tooltip.clear_cache": "Vider le cache et les cookies",
"toolbar.tooltip.open_log": "Ouvrir le fichier journal",
"toolbar.tooltip.dev_tools": "Outils de d\u00e9veloppement (F12)",
"status.ready": "Pr\u00eat",
"status.opened": "Ouvert\u00a0: {name}",
"status.choose_app": "Choisir une app pour\u00a0: {name}",
"status.download_started": "\ud83d\udce5 T\u00e9l\u00e9chargement\u00a0: {filename}",
"status.download_completed": "T\u00e9l\u00e9chargement termin\u00e9\u00a0: {name}",
"status.download_cancelled": "\u26a0\ufe0f T\u00e9l\u00e9chargement annul\u00e9\u00a0: {name}",
"status.download_failed": "\u274c T\u00e9l\u00e9chargement \u00e9chou\u00e9\u00a0: {name}",
"status.download_error": "Erreur de t\u00e9l\u00e9chargement\u00a0: {error}",
"update.status.checking": "Recherche de mises \u00e0 jour",
"update.status.ready": "Pr\u00eat",
"update.status.available": "Mise \u00e0 jour disponible\u00a0: v{version}",
"update.status.deferred": "Mise \u00e0 jour diff\u00e9r\u00e9e",
"update.status.downloading": "T\u00e9l\u00e9chargement de v{version}",
"update.status.verifying": "V\u00e9rification du t\u00e9l\u00e9chargement",
"update.status.download_failed": "\u00c9chec du t\u00e9l\u00e9chargement",
"update.status.verification_failed": "\u00c9chec de la v\u00e9rification",
"update.status.timed_out": "D\u00e9lai d'attente d\u00e9pass\u00e9",
"update.status.ready_to_install": "Pr\u00eat \u00e0 installer",
"update.status.installation_started": "Installation d\u00e9marr\u00e9e",
"update.status.installation_failed": "\u00c9chec de l'installation",
"update.status.check_timed_out": "D\u00e9lai d\u00e9pass\u00e9 \u2013 aucune r\u00e9ponse du serveur",
"update.status.check_failed": "\u00c9chec\u00a0: {error}",
"update.status.download_timed_out": "D\u00e9lai d\u00e9pass\u00e9 lors du t\u00e9l\u00e9chargement",
"dialog.error.title": "Erreur",
"dialog.log_not_found.title": "Fichier journal introuvable",
"dialog.log_not_found.msg": "Aucun fichier journal trouv\u00e9 \u00e0\u00a0:\n{log_file}",
"dialog.cache_cleared.title": "Cache vid\u00e9",
"dialog.cache_cleared.msg": "Le cache et les cookies du navigateur ont \u00e9t\u00e9 vid\u00e9s avec succ\u00e8s.\n\nVous devrez peut-\u00eatre recharger la page ou red\u00e9marrer l'application pour que les modifications prennent effet.",
"dialog.cache_clear_failed.title": "Erreur",
"dialog.cache_clear_failed.msg": "Impossible de vider le cache et les cookies\u00a0: {error}",
"dialog.drag_error.title": "Erreur de glisser-d\u00e9poser",
"dialog.drag_error.msg": "Impossible de terminer l'op\u00e9ration de glisser-d\u00e9poser.\n\nErreur\u00a0: {error}",
"dialog.open_file_error.title": "Erreur d'ouverture",
"dialog.open_file_error.msg": "Impossible d'ouvrir le fichier avec son application par d\u00e9faut.\n\nFichier\u00a0: {file_path}\nErreur\u00a0: {error}",
"dialog.open_with_error.title": "Erreur Ouvrir avec",
"dialog.open_with_error.msg": "Impossible d'ouvrir un s\u00e9lecteur d'application sur cette plate-forme.",
"dialog.dev_tools.window_title": "\ud83d\udd27 Outils de d\u00e9veloppement",
"dialog.dev_tools.error_title": "Outils de d\u00e9veloppement",
"dialog.dev_tools.error_msg": "Impossible d'ouvrir les outils de d\u00e9veloppement\u00a0:\n{error}",
"dialog.domain_changed.title": "Domaine modifi\u00e9 \u2013 Red\u00e9marrage recommand\u00e9",
"dialog.domain_changed.msg": "Le domaine de l'application web a chang\u00e9\n\nVous avez chang\u00e9 de domaine. Pour une stabilit\u00e9 maximale et une authentification correcte, il est recommand\u00e9 de red\u00e9marrer l'application.\n\nLe profil et le cache ont \u00e9t\u00e9 vid\u00e9s, mais un red\u00e9marrage est recommand\u00e9.",
"dialog.domain_changed.restart_now": "Red\u00e9marrer maintenant",
"dialog.domain_changed.restart_later": "Red\u00e9marrer plus tard",
"dialog.restart_failed.title": "\u00c9chec du red\u00e9marrage",
"dialog.restart_failed.msg": "Impossible de red\u00e9marrer automatiquement l'application\u00a0:\n\n{error}\n\nVeuillez red\u00e9marrer manuellement.",
"dialog.update_timeout.title": "D\u00e9lai de v\u00e9rification des mises \u00e0 jour d\u00e9pass\u00e9",
"dialog.update_timeout.msg": "Le serveur n'a pas r\u00e9pondu dans les 30 secondes.\n\nCela peut \u00eatre d\u00fb \u00e0 un probl\u00e8me r\u00e9seau ou \u00e0 une indisponibilit\u00e9 du serveur.\n\nV\u00e9rifiez votre connexion et r\u00e9essayez.",
"dialog.update_failed.title": "\u00c9chec de la v\u00e9rification des mises \u00e0 jour",
"dialog.update_failed.msg": "Impossible de v\u00e9rifier les mises \u00e0 jour\u00a0:\n\n{error}\n\nVeuillez r\u00e9essayer plus tard.",
"dialog.download_failed.title": "\u00c9chec du t\u00e9l\u00e9chargement",
"dialog.download_failed.msg": "Impossible de t\u00e9l\u00e9charger la mise \u00e0 jour\u00a0:\n\n{error}\n\nVeuillez r\u00e9essayer plus tard.",
"dialog.checkout.title": "Extraire l'actif",
"dialog.checkout.msg": "Voulez-vous extraire cet actif\u00a0?\n\n{filename}",
"about.title": "\u00c0 propos de {app_name}",
"about.version": "Version\u00a0: {version}",
"about.description": "Connecte les flux de travail de glisser-d\u00e9poser web aux op\u00e9rations de fichiers natives pour les applications de bureau professionnelles.",
"about.drop_zones_title": "Zones de d\u00e9p\u00f4t de la barre d'outils\u00a0:",
"about.open_icon_desc": "Ic\u00f4ne Ouvrir\u00a0: ouvre les fichiers d\u00e9pos\u00e9s avec l'application par d\u00e9faut.",
"about.open_with_icon_desc": "Ic\u00f4ne Ouvrir avec\u00a0: affiche un s\u00e9lecteur d'application pour les fichiers d\u00e9pos\u00e9s.",
"about.product_of": "Un produit de\u00a0:",
"about.rights": "\u00a9 2026 h\u00f6rl Information Management GmbH. Tous droits r\u00e9serv\u00e9s.",
"settings.title": "Param\u00e8tres",
"settings.tab.web_source": "Source web",
"settings.tab.paths": "Chemins",
"settings.tab.urls": "URLs",
"settings.tab.logging": "Journalisation",
"settings.tab.window": "Fen\u00eatre",
"settings.tab.profiles": "Profils",
"settings.tab.general": "G\u00e9n\u00e9ral",
"settings.web_url.label": "URL de l'application web\u00a0:",
"settings.web_url.placeholder": "p.ex. http://localhost:8080 ou file:///./webapp/index.html",
"settings.web_url.open_btn": "Ouvrir",
"settings.url_mappings.label": "Mappages d'URL (Azure Blob Storage \u2192 Chemins locaux)\u00a0:",
"settings.url_mappings.col_prefix": "Pr\u00e9fixe URL",
"settings.url_mappings.col_path": "Chemin local",
"settings.url_mappings.add_btn": "Ajouter un mappage",
"settings.url_mappings.edit_btn": "Modifier la s\u00e9lection",
"settings.url_mappings.remove_btn": "Supprimer la s\u00e9lection",
"settings.paths.label": "R\u00e9pertoires racines autoris\u00e9s pour l'acc\u00e8s aux fichiers\u00a0:",
"settings.paths.add_btn": "Ajouter un chemin",
"settings.paths.remove_btn": "Supprimer la s\u00e9lection",
"settings.urls.label": "URLs web autoris\u00e9es (prise en charge des caract\u00e8res g\u00e9n\u00e9riques comme http://*.example.com)\u00a0:",
"settings.urls.add_btn": "Ajouter une URL",
"settings.urls.remove_btn": "Supprimer la s\u00e9lection",
"settings.log_level.label": "Niveau de journalisation\u00a0:",
"settings.log_file.label": "Fichier journal (facultatif)\u00a0:",
"settings.log_file.browse_btn": "Parcourir...",
"settings.window.width_label": "Largeur de la fen\u00eatre\u00a0:",
"settings.window.height_label": "Hauteur de la fen\u00eatre\u00a0:",
"settings.profiles.label": "Profils de configuration enregistr\u00e9s\u00a0:",
"settings.profiles.save_btn": "Enregistrer comme profil",
"settings.profiles.load_btn": "Charger le profil",
"settings.profiles.delete_btn": "Supprimer le profil",
"settings.profiles.export_btn": "Exporter la configuration",
"settings.profiles.import_btn": "Importer la configuration",
"settings.general.language_label": "Langue\u00a0:",
"settings.general.language_restart_note": "Le changement de langue prend effet apr\u00e8s red\u00e9marrage.",
"settings.add_mapping.url_title": "Ajouter un mappage d'URL",
"settings.add_mapping.url_prompt": "Entrez le pr\u00e9fixe URL Azure Blob Storage\u00a0:\n(p.ex. https://myblob.blob.core.windows.net/container/)",
"settings.add_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\u00a0:\n(p.ex. C:\\Partage ou /mnt/partage)",
"settings.edit_mapping.title": "Modifier le mappage d'URL",
"settings.edit_mapping.url_prompt": "Entrez le pr\u00e9fixe URL Azure Blob Storage\u00a0:",
"settings.edit_mapping.path_prompt": "Entrez le chemin du syst\u00e8me de fichiers local\u00a0:",
"settings.add_url.title": "Ajouter une URL",
"settings.add_url.prompt": "Entrez le mod\u00e8le d'URL (p.ex. http://example.com ou http://*.example.com)\u00a0:",
"settings.profile.save.title": "Enregistrer le profil",
"settings.profile.save.prompt": "Entrez le nom du profil (p.ex. travail, personnel)\u00a0:",
"settings.select_directory.title": "S\u00e9lectionner un r\u00e9pertoire autoris\u00e9",
"settings.select_log_file.title": "S\u00e9lectionner le fichier journal",
"settings.export_config.title": "Exporter la configuration",
"settings.import_config.title": "Importer la configuration",
"settings.error.select_mapping": "Veuillez s\u00e9lectionner un mappage \u00e0 modifier",
"settings.error.select_profile_load": "Veuillez s\u00e9lectionner un profil \u00e0 charger",
"settings.error.select_profile_delete": "Veuillez s\u00e9lectionner un profil \u00e0 supprimer",
"update.checking.title": "V\u00e9rification des mises \u00e0 jour",
"update.checking.label": "Recherche de mises \u00e0 jour...",
"update.checking.timeout_info": "Cela peut prendre jusqu'\u00e0 10 secondes",
"update.available.title": "Mise \u00e0 jour disponible",
"update.available.header": "WebDrop Bridge v{version} est disponible",
"update.available.changelog_label": "Notes de version\u00a0:",
"update.available.update_now_btn": "Mettre \u00e0 jour maintenant",
"update.available.later_btn": "Plus tard",
"update.downloading.title": "T\u00e9l\u00e9chargement de la mise \u00e0 jour",
"update.downloading.header": "T\u00e9l\u00e9chargement en cours...",
"update.downloading.preparing": "Pr\u00e9paration du t\u00e9l\u00e9chargement",
"update.downloading.filename": "T\u00e9l\u00e9chargement\u00a0: {filename}",
"update.downloading.cancel_btn": "Annuler",
"update.install.title": "Installer la mise \u00e0 jour",
"update.install.header": "Pr\u00eat \u00e0 installer",
"update.install.message": "La mise \u00e0 jour est pr\u00eate \u00e0 \u00eatre install\u00e9e. L'application va red\u00e9marrer.",
"update.install.warning": "\u26a0\ufe0f Veuillez enregistrer tout travail non sauvegard\u00e9 avant de continuer.\nL'application va se fermer et red\u00e9marrer.",
"update.install.now_btn": "Installer maintenant",
"update.install.cancel_btn": "Annuler",
"update.no_update.title": "Aucune mise \u00e0 jour disponible",
"update.no_update.message": "\u2713 Vous utilisez la derni\u00e8re version",
"update.no_update.info": "WebDrop Bridge est \u00e0 jour.",
"update.no_update.ok_btn": "OK",
"update.error.title": "\u00c9chec de la mise \u00e0 jour",
"update.error.header": "\u26a0\ufe0f \u00c9chec de la mise \u00e0 jour",
"update.error.info": "Veuillez r\u00e9essayer ou visiter le site web pour t\u00e9l\u00e9charger la mise \u00e0 jour manuellement.",
"update.error.retry_btn": "R\u00e9essayer",
"update.error.manual_btn": "T\u00e9l\u00e9charger manuellement",
"update.error.cancel_btn": "Annuler",
"worker.server_not_responding": "Le serveur ne r\u00e9pond pas \u2013 v\u00e9rifiez plus tard",
"worker.no_installer": "Aucun programme d'installation trouv\u00e9 dans la version",
"worker.checksum_failed": "\u00c9chec de la v\u00e9rification de la somme de contr\u00f4le",
"worker.download_timed_out": "D\u00e9lai d\u00e9pass\u00e9 lors du t\u00e9l\u00e9chargement ou de la v\u00e9rification",
"worker.download_error": "Erreur de t\u00e9l\u00e9chargement\u00a0: {error}",
"worker.check_failed": "\u00c9chec de la v\u00e9rification\u00a0: {error}"
}

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"

60
tests/unit/test_i18n.py Normal file
View file

@ -0,0 +1,60 @@
"""Unit tests for i18n translation helper."""
import json
from pathlib import Path
from webdrop_bridge.utils import i18n
class TestI18n:
"""Tests for translation lookup and fallback behavior."""
def test_tr_lazy_initialization_uses_english_defaults(self):
"""Translator should lazily initialize and resolve known keys."""
# Force a fresh singleton state for this test.
i18n._translator = i18n.Translator() # type: ignore[attr-defined]
assert i18n.tr("settings.title") == "Settings"
def test_initialize_with_language_falls_back_to_english(self, tmp_path: Path):
"""Missing keys in selected language should fall back to English."""
translations = tmp_path / "translations"
translations.mkdir(parents=True, exist_ok=True)
(translations / "en.json").write_text(
json.dumps(
{
"greeting": "Hello {name}",
"settings.title": "Settings",
}
),
encoding="utf-8",
)
(translations / "de.json").write_text(
json.dumps(
{
"settings.title": "Einstellungen",
}
),
encoding="utf-8",
)
i18n._translator = i18n.Translator() # type: ignore[attr-defined]
i18n.initialize("de", translations)
assert i18n.tr("settings.title") == "Einstellungen"
assert i18n.tr("greeting", name="Alex") == "Hello Alex"
def test_get_available_languages_reads_translation_files(self, tmp_path: Path):
"""Available languages should be discovered from JSON files."""
translations = tmp_path / "translations"
translations.mkdir(parents=True, exist_ok=True)
(translations / "en.json").write_text("{}", encoding="utf-8")
(translations / "fr.json").write_text("{}", encoding="utf-8")
i18n._translator = i18n.Translator() # type: ignore[attr-defined]
i18n.initialize("en", translations)
available = i18n.get_available_languages()
assert "en" in available
assert "fr" in available

View file

@ -34,7 +34,7 @@ class TestSettingsDialogInitialization:
"""Test dialog can be created."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog is not None
assert dialog.windowTitle() == "Settings"
@ -42,51 +42,58 @@ class TestSettingsDialogInitialization:
"""Test dialog has all required tabs."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs is not None
assert dialog.tabs.count() == 6 # Web Source, Paths, URLs, Logging, Window, Profiles
assert dialog.tabs.count() == 7 # General + previous 6 tabs
def test_dialog_has_general_tab(self, qtbot, sample_config):
"""Test General tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(0) == "General"
def test_dialog_has_web_source_tab(self, qtbot, sample_config):
"""Test Web Source tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(0) == "Web Source"
assert dialog.tabs.tabText(1) == "Web Source"
def test_dialog_has_paths_tab(self, qtbot, sample_config):
"""Test Paths tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(1) == "Paths"
assert dialog.tabs.tabText(2) == "Paths"
def test_dialog_has_urls_tab(self, qtbot, sample_config):
"""Test URLs tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(2) == "URLs"
assert dialog.tabs.tabText(3) == "URLs"
def test_dialog_has_logging_tab(self, qtbot, sample_config):
"""Test Logging tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(3) == "Logging"
assert dialog.tabs.tabText(4) == "Logging"
def test_dialog_has_window_tab(self, qtbot, sample_config):
"""Test Window tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(4) == "Window"
assert dialog.tabs.tabText(5) == "Window"
def test_dialog_has_profiles_tab(self, qtbot, sample_config):
"""Test Profiles tab exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.tabText(5) == "Profiles"
assert dialog.tabs.tabText(6) == "Profiles"
class TestPathsTab:
@ -96,7 +103,7 @@ class TestPathsTab:
"""Test paths are loaded from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())]
assert len(items) == 2
# Paths are normalized (backslashes on Windows)
@ -107,7 +114,7 @@ class TestPathsTab:
"""Test Add Path button exists."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.tabs.currentWidget() is not None
@ -118,7 +125,7 @@ class TestURLsTab:
"""Test URLs are loaded from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())]
assert len(items) == 2
assert "http://example.com" in items
@ -132,14 +139,14 @@ class TestLoggingTab:
"""Test log level is set from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.log_level_combo.currentText() == "INFO"
def test_log_levels_available(self, qtbot, sample_config):
"""Test all log levels are available."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
levels = [dialog.log_level_combo.itemText(i) for i in range(dialog.log_level_combo.count())]
assert "DEBUG" in levels
assert "INFO" in levels
@ -155,21 +162,21 @@ class TestWindowTab:
"""Test window width is set from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.width_spin.value() == 800
def test_window_height_set_from_config(self, qtbot, sample_config):
"""Test window height is set from configuration."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.height_spin.value() == 600
def test_window_width_has_min_max(self, qtbot, sample_config):
"""Test window width spinbox has min/max."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.width_spin.minimum() == 400
assert dialog.width_spin.maximum() == 5000
@ -177,7 +184,7 @@ class TestWindowTab:
"""Test window height spinbox has min/max."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.height_spin.minimum() == 300
assert dialog.height_spin.maximum() == 5000
@ -189,7 +196,7 @@ class TestProfilesTab:
"""Test profiles list is initialized."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
assert dialog.profiles_list is not None
@ -200,9 +207,9 @@ class TestConfigDataRetrieval:
"""Test retrieving configuration data from dialog."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
config_data = dialog.get_config_data()
assert config_data["app_name"] == "WebDrop Bridge"
assert config_data["log_level"] == "INFO"
assert config_data["window_width"] == 800
@ -212,7 +219,7 @@ class TestConfigDataRetrieval:
"""Test get_config_data returns valid configuration data."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
# All default values are valid
config_data = dialog.get_config_data()
assert config_data is not None
@ -222,14 +229,14 @@ class TestConfigDataRetrieval:
"""Test get_config_data returns modified values."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
# Modify values
dialog.width_spin.setValue(1024)
dialog.height_spin.setValue(768)
dialog.log_level_combo.setCurrentText("DEBUG")
config_data = dialog.get_config_data()
assert config_data["window_width"] == 1024
assert config_data["window_height"] == 768
assert config_data["log_level"] == "DEBUG"
@ -242,7 +249,7 @@ class TestApplyConfigData:
"""Test applying config data updates paths."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
new_config = {
"app_name": "Test",
"app_version": "1.0.0",
@ -255,9 +262,9 @@ class TestApplyConfigData:
"window_height": 600,
"enable_logging": True,
}
dialog._apply_config_data(new_config)
items = [dialog.paths_list.item(i).text() for i in range(dialog.paths_list.count())]
assert "/new/path" in items
assert "/another/path" in items
@ -266,7 +273,7 @@ class TestApplyConfigData:
"""Test applying config data updates URLs."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
new_config = {
"app_name": "Test",
"app_version": "1.0.0",
@ -279,9 +286,9 @@ class TestApplyConfigData:
"window_height": 600,
"enable_logging": True,
}
dialog._apply_config_data(new_config)
items = [dialog.urls_list.item(i).text() for i in range(dialog.urls_list.count())]
assert "http://new.com" in items
assert "http://test.org" in items
@ -290,7 +297,7 @@ class TestApplyConfigData:
"""Test applying config data updates window size."""
dialog = SettingsDialog(sample_config)
qtbot.addWidget(dialog)
new_config = {
"app_name": "Test",
"app_version": "1.0.0",
@ -303,8 +310,8 @@ class TestApplyConfigData:
"window_height": 1024,
"enable_logging": True,
}
dialog._apply_config_data(new_config)
assert dialog.width_spin.value() == 1280
assert dialog.height_spin.value() == 1024