Add internationalization support with English and French translations
- Introduced a new i18n module for managing translations using JSON files. - Added English (en.json) and French (fr.json) translation files for UI elements. - Implemented lazy initialization of the translator to load translations on demand. - Added unit tests for translation lookup, fallback behavior, and available languages detection.
This commit is contained in:
parent
fd0482ed2d
commit
7daec731ca
11 changed files with 1184 additions and 280 deletions
167
resources/translations/de.json
Normal file
167
resources/translations/de.json
Normal 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}"
|
||||
}
|
||||
167
resources/translations/en.json
Normal file
167
resources/translations/en.json
Normal 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}"
|
||||
}
|
||||
167
resources/translations/fr.json
Normal file
167
resources/translations/fr.json
Normal 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}"
|
||||
}
|
||||
|
|
@ -81,6 +81,7 @@ class Config:
|
|||
window_title: str = ""
|
||||
enable_logging: bool = True
|
||||
enable_checkout: bool = False
|
||||
language: str = "auto"
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, config_path: Path) -> "Config":
|
||||
|
|
@ -172,6 +173,7 @@ class Config:
|
|||
window_title=window_title,
|
||||
enable_logging=data.get("enable_logging", True),
|
||||
enable_checkout=data.get("enable_checkout", False),
|
||||
language=data.get("language", "auto"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -212,6 +214,7 @@ class Config:
|
|||
window_title = os.getenv("WINDOW_TITLE", default_title)
|
||||
enable_logging = os.getenv("ENABLE_LOGGING", "true").lower() == "true"
|
||||
enable_checkout = os.getenv("ENABLE_CHECKOUT", "false").lower() == "true"
|
||||
language = os.getenv("LANGUAGE", "auto")
|
||||
|
||||
# Validate log level
|
||||
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
|
|
@ -304,6 +307,7 @@ class Config:
|
|||
window_title=window_title,
|
||||
enable_logging=enable_logging,
|
||||
enable_checkout=enable_checkout,
|
||||
language=language,
|
||||
)
|
||||
|
||||
def to_file(self, config_path: Path) -> None:
|
||||
|
|
@ -332,6 +336,7 @@ class Config:
|
|||
"window_title": self.window_title,
|
||||
"enable_logging": self.enable_logging,
|
||||
"enable_checkout": self.enable_checkout,
|
||||
"language": self.language,
|
||||
}
|
||||
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from PySide6.QtWidgets import QApplication
|
|||
|
||||
from webdrop_bridge.config import Config, ConfigurationError
|
||||
from webdrop_bridge.ui.main_window import MainWindow
|
||||
from webdrop_bridge.utils.i18n import get_translations_dir
|
||||
from webdrop_bridge.utils.i18n import initialize as i18n_init
|
||||
from webdrop_bridge.utils.logging import get_logger, setup_logging
|
||||
|
||||
|
||||
|
|
@ -50,6 +52,11 @@ def main() -> int:
|
|||
logger.info(f"Starting {config.app_name} v{config.app_version}")
|
||||
logger.debug(f"Configuration: {config}")
|
||||
|
||||
# Initialize internationalization
|
||||
translations_dir = get_translations_dir()
|
||||
i18n_init(config.language, translations_dir)
|
||||
logger.debug(f"i18n initialized: language={config.language}, dir={translations_dir}")
|
||||
|
||||
except ConfigurationError as e:
|
||||
print(f"Configuration error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ from webdrop_bridge.config import Config
|
|||
from webdrop_bridge.core.drag_interceptor import DragInterceptor
|
||||
from webdrop_bridge.core.validator import PathValidator
|
||||
from webdrop_bridge.ui.restricted_web_view import RestrictedWebEngineView
|
||||
from webdrop_bridge.utils.i18n import tr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -245,7 +246,7 @@ class OpenDropZone(QWidget):
|
|||
self._icon_label.setPixmap(pixmap)
|
||||
self._icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self._icon_label.setStyleSheet(self._NORMAL_STYLE)
|
||||
self._icon_label.setToolTip("Drop a file here to open it with its default application")
|
||||
self._icon_label.setToolTip(tr("toolbar.tooltip.open_drop"))
|
||||
layout.addWidget(self._icon_label)
|
||||
|
||||
self.setMinimumSize(QSize(44, 44))
|
||||
|
|
@ -322,7 +323,7 @@ class OpenWithDropZone(OpenDropZone):
|
|||
parent: Parent widget.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self._icon_label.setToolTip("Drop a file here to choose which app should open it")
|
||||
self._icon_label.setToolTip(tr("toolbar.tooltip.open_with_drop"))
|
||||
|
||||
def dropEvent(self, event) -> None: # type: ignore[override]
|
||||
"""Emit dropped local files for app-chooser handling."""
|
||||
|
|
@ -985,8 +986,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Checkout Asset",
|
||||
f"Do you want to check out this asset?\n\n{filename}",
|
||||
tr("dialog.checkout.title"),
|
||||
tr("dialog.checkout.msg", filename=filename),
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.Yes,
|
||||
)
|
||||
|
|
@ -1090,8 +1091,8 @@ class MainWindow(QMainWindow):
|
|||
# Show error dialog to user
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Drag-and-Drop Error",
|
||||
f"Could not complete the drag-and-drop operation.\n\nError: {error}",
|
||||
tr("dialog.drag_error.title"),
|
||||
tr("dialog.drag_error.msg", error=error),
|
||||
)
|
||||
|
||||
def _on_file_opened_via_drop(self, file_path: str) -> None:
|
||||
|
|
@ -1101,7 +1102,7 @@ class MainWindow(QMainWindow):
|
|||
file_path: Local file path that was opened.
|
||||
"""
|
||||
logger.info(f"Opened via drop zone: {file_path}")
|
||||
self.statusBar().showMessage(f"Opened: {Path(file_path).name}", 4000)
|
||||
self.statusBar().showMessage(tr("status.opened", name=Path(file_path).name), 4000)
|
||||
|
||||
def _on_file_open_failed_via_drop(self, file_path: str, error: str) -> None:
|
||||
"""Handle a failure to open a file dropped on the OpenDropZone.
|
||||
|
|
@ -1113,9 +1114,8 @@ class MainWindow(QMainWindow):
|
|||
logger.warning(f"Failed to open via drop zone '{file_path}': {error}")
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Open File Error",
|
||||
f"Could not open the file with its default application.\n\n"
|
||||
f"File: {file_path}\nError: {error}",
|
||||
tr("dialog.open_file_error.title"),
|
||||
tr("dialog.open_file_error.msg", file_path=file_path, error=error),
|
||||
)
|
||||
|
||||
def _on_file_open_with_requested(self, file_path: str) -> None:
|
||||
|
|
@ -1125,15 +1125,15 @@ class MainWindow(QMainWindow):
|
|||
file_path: Local file path to open using an app chooser.
|
||||
"""
|
||||
if self._open_with_app_chooser(file_path):
|
||||
self.statusBar().showMessage(f"Choose app for: {Path(file_path).name}", 4000)
|
||||
self.statusBar().showMessage(tr("status.choose_app", name=Path(file_path).name), 4000)
|
||||
logger.info(f"Opened app chooser for '{file_path}'")
|
||||
return
|
||||
|
||||
logger.warning(f"Could not open app chooser for '{file_path}'")
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Open With Error",
|
||||
"Could not open an application chooser for this file on your platform.",
|
||||
tr("dialog.open_with_error.title"),
|
||||
tr("dialog.open_with_error.msg"),
|
||||
)
|
||||
|
||||
def _open_with_app_chooser(self, file_path: str) -> bool:
|
||||
|
|
@ -1234,7 +1234,7 @@ class MainWindow(QMainWindow):
|
|||
logger.info(f"Download started: {filename}")
|
||||
|
||||
# Update status bar (temporarily)
|
||||
self.status_bar.showMessage(f"📥 Download: {filename}", 3000)
|
||||
self.status_bar.showMessage(tr("status.download_started", filename=filename), 3000)
|
||||
|
||||
# Connect to state changed for progress tracking
|
||||
download.stateChanged.connect(
|
||||
|
|
@ -1248,7 +1248,7 @@ class MainWindow(QMainWindow):
|
|||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling download: {e}", exc_info=True)
|
||||
self.status_bar.showMessage(f"Download error: {e}", 5000)
|
||||
self.status_bar.showMessage(tr("status.download_error", error=str(e)), 5000)
|
||||
|
||||
def _on_download_finished(self, download: QWebEngineDownloadRequest, file_path: Path) -> None:
|
||||
"""Handle download completion.
|
||||
|
|
@ -1266,13 +1266,17 @@ class MainWindow(QMainWindow):
|
|||
|
||||
if state == QWebEngineDownloadRequest.DownloadState.DownloadCompleted:
|
||||
logger.info(f"Download completed: {file_path.name}")
|
||||
self.status_bar.showMessage(f"Download completed: {file_path.name}", 5000)
|
||||
self.status_bar.showMessage(
|
||||
tr("status.download_completed", name=file_path.name), 5000
|
||||
)
|
||||
elif state == QWebEngineDownloadRequest.DownloadState.DownloadCancelled:
|
||||
logger.info(f"Download cancelled: {file_path.name}")
|
||||
self.status_bar.showMessage(f"⚠️ Download abgebrochen: {file_path.name}", 3000)
|
||||
self.status_bar.showMessage(
|
||||
tr("status.download_cancelled", name=file_path.name), 3000
|
||||
)
|
||||
elif state == QWebEngineDownloadRequest.DownloadState.DownloadInterrupted:
|
||||
logger.warning(f"Download interrupted: {file_path.name}")
|
||||
self.status_bar.showMessage(f"❌ Download fehlgeschlagen: {file_path.name}", 5000)
|
||||
self.status_bar.showMessage(tr("status.download_failed", name=file_path.name), 5000)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in download finished handler: {e}", exc_info=True)
|
||||
|
||||
|
|
@ -1395,7 +1399,7 @@ class MainWindow(QMainWindow):
|
|||
else self.style().standardIcon(self.style().StandardPixmap.SP_DirHomeIcon)
|
||||
)
|
||||
home_action = toolbar.addAction(home_icon, "")
|
||||
home_action.setToolTip("Home")
|
||||
home_action.setToolTip(tr("toolbar.tooltip.home"))
|
||||
home_action.triggered.connect(self._navigate_home)
|
||||
|
||||
# Refresh button
|
||||
|
|
@ -1435,32 +1439,32 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# About button (info icon) on the right
|
||||
about_action = toolbar.addAction("ℹ️")
|
||||
about_action.setToolTip("About WebDrop Bridge")
|
||||
about_action.setToolTip(tr("toolbar.tooltip.about"))
|
||||
about_action.triggered.connect(self._show_about_dialog)
|
||||
|
||||
# Settings button on the right
|
||||
settings_action = toolbar.addAction("⚙️")
|
||||
settings_action.setToolTip("Settings")
|
||||
settings_action.setToolTip(tr("toolbar.tooltip.settings"))
|
||||
settings_action.triggered.connect(self._show_settings_dialog)
|
||||
|
||||
# Check for Updates button on the right
|
||||
check_updates_action = toolbar.addAction("🔄")
|
||||
check_updates_action.setToolTip("Check for Updates")
|
||||
check_updates_action.setToolTip(tr("toolbar.tooltip.check_updates"))
|
||||
check_updates_action.triggered.connect(self._on_manual_check_for_updates)
|
||||
|
||||
# Clear cache button on the right
|
||||
clear_cache_action = toolbar.addAction("🗑️")
|
||||
clear_cache_action.setToolTip("Clear Cache and Cookies")
|
||||
clear_cache_action.setToolTip(tr("toolbar.tooltip.clear_cache"))
|
||||
clear_cache_action.triggered.connect(self._clear_cache_and_cookies)
|
||||
|
||||
# Log file button on the right
|
||||
log_action = toolbar.addAction("📋")
|
||||
log_action.setToolTip("Open Log File")
|
||||
log_action.setToolTip(tr("toolbar.tooltip.open_log"))
|
||||
log_action.triggered.connect(self._open_log_file)
|
||||
|
||||
# Developer Tools button on the right
|
||||
dev_tools_action = toolbar.addAction("🔧")
|
||||
dev_tools_action.setToolTip("Developer Tools (F12)")
|
||||
dev_tools_action.setToolTip(tr("toolbar.tooltip.dev_tools"))
|
||||
dev_tools_action.triggered.connect(self._open_developer_tools)
|
||||
|
||||
def _open_log_file(self) -> None:
|
||||
|
|
@ -1486,8 +1490,8 @@ class MainWindow(QMainWindow):
|
|||
else:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Log File Not Found",
|
||||
f"No log file found at:\n{log_file}",
|
||||
tr("dialog.log_not_found.title"),
|
||||
tr("dialog.log_not_found.msg", log_file=str(log_file)),
|
||||
)
|
||||
|
||||
def _open_developer_tools(self) -> None:
|
||||
|
|
@ -1504,7 +1508,7 @@ class MainWindow(QMainWindow):
|
|||
|
||||
# Create new window
|
||||
self._dev_tools_window = QMainWindow()
|
||||
self._dev_tools_window.setWindowTitle("🔧 Developer Tools")
|
||||
self._dev_tools_window.setWindowTitle(tr("dialog.dev_tools.window_title"))
|
||||
self._dev_tools_window.setGeometry(100, 100, 1200, 700)
|
||||
self._dev_tools_window.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
|
||||
|
|
@ -1530,8 +1534,8 @@ class MainWindow(QMainWindow):
|
|||
logger.error(f"Failed to open Developer Tools window: {e}", exc_info=True)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Developer Tools",
|
||||
f"Could not open Developer Tools:\n{e}",
|
||||
tr("dialog.dev_tools.error_title"),
|
||||
tr("dialog.dev_tools.error_msg", error=str(e)),
|
||||
)
|
||||
|
||||
def _create_status_bar(self) -> None:
|
||||
|
|
@ -1539,7 +1543,7 @@ class MainWindow(QMainWindow):
|
|||
self.status_bar = self.statusBar()
|
||||
|
||||
# Update status label
|
||||
self.update_status_label = QLabel("Ready")
|
||||
self.update_status_label = QLabel(tr("status.ready"))
|
||||
self.update_status_label.setStyleSheet("margin-right: 10px;")
|
||||
self.status_bar.addPermanentWidget(self.update_status_label)
|
||||
|
||||
|
|
@ -1550,10 +1554,26 @@ class MainWindow(QMainWindow):
|
|||
status: Status text to display
|
||||
emoji: Optional emoji prefix (rotating, checkmark, download, warning symbols)
|
||||
"""
|
||||
# Map known internal status strings to translated display text
|
||||
_STATIC_STATUS_MAP = {
|
||||
"Checking for updates": "update.status.checking",
|
||||
"Ready": "update.status.ready",
|
||||
"Update deferred": "update.status.deferred",
|
||||
"Ready to install": "update.status.ready_to_install",
|
||||
"Installation started": "update.status.installation_started",
|
||||
"Installation failed": "update.status.installation_failed",
|
||||
"Download failed": "update.status.download_failed",
|
||||
"Verification failed": "update.status.verification_failed",
|
||||
"Operation timed out": "update.status.timed_out",
|
||||
"Check timed out - no server response": "update.status.check_timed_out",
|
||||
"Download timed out - no server response": "update.status.download_timed_out",
|
||||
}
|
||||
tr_key = _STATIC_STATUS_MAP.get(status)
|
||||
display = tr(tr_key) if tr_key else status
|
||||
if emoji:
|
||||
self.update_status_label.setText(f"{emoji} {status}")
|
||||
self.update_status_label.setText(f"{emoji} {display}")
|
||||
else:
|
||||
self.update_status_label.setText(status)
|
||||
self.update_status_label.setText(display)
|
||||
|
||||
def _on_manual_check_for_updates(self) -> None:
|
||||
"""Handle manual check for updates from menu.
|
||||
|
|
@ -1589,17 +1609,16 @@ class MainWindow(QMainWindow):
|
|||
# Show confirmation message
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Cache Cleared",
|
||||
"Browser cache and cookies have been cleared successfully.\n\n"
|
||||
"You may need to reload the page or restart the application for changes to take effect.",
|
||||
tr("dialog.cache_cleared.title"),
|
||||
tr("dialog.cache_cleared.msg"),
|
||||
)
|
||||
logger.info("Cache and cookies cleared successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear cache and cookies: {e}")
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Error",
|
||||
f"Failed to clear cache and cookies: {str(e)}",
|
||||
tr("dialog.cache_clear_failed.title"),
|
||||
tr("dialog.cache_clear_failed.msg", error=str(e)),
|
||||
)
|
||||
|
||||
def _show_about_dialog(self) -> None:
|
||||
|
|
@ -1608,16 +1627,15 @@ class MainWindow(QMainWindow):
|
|||
|
||||
about_text = (
|
||||
f"<b>{self.config.app_name}</b><br>"
|
||||
f"Version: {self.config.app_version}<br>"
|
||||
f"{tr('about.version', version=self.config.app_version)}<br>"
|
||||
f"<br>"
|
||||
f"Bridges web-based drag-and-drop workflows with native file operations "
|
||||
f"for professional desktop applications.<br>"
|
||||
f"{tr('about.description')}<br>"
|
||||
f"<br>"
|
||||
f"<b>Toolbar Drop Zones:</b><br>"
|
||||
f"Open icon: Opens dropped files with the system default app.<br>"
|
||||
f"Open-with icon: Shows an app chooser for dropped files.<br>"
|
||||
f"<b>{tr('about.drop_zones_title')}</b><br>"
|
||||
f"{tr('about.open_icon_desc')}<br>"
|
||||
f"{tr('about.open_with_icon_desc')}<br>"
|
||||
f"<br>"
|
||||
f"<b>Product of:</b><br>"
|
||||
f"<b>{tr('about.product_of')}</b><br>"
|
||||
f"<b>hörl Information Management GmbH</b><br>"
|
||||
f"Silberburgstraße 126<br>"
|
||||
f"70176 Stuttgart, Germany<br>"
|
||||
|
|
@ -1628,10 +1646,10 @@ class MainWindow(QMainWindow):
|
|||
f"<b>Web:</b> <a href='https://www.hoerl-im.de/'>https://www.hoerl-im.de/</a><br>"
|
||||
f"</small>"
|
||||
f"<br>"
|
||||
f"<small>© 2026 hörl Information Management GmbH. All rights reserved.</small>"
|
||||
f"<small>{tr('about.rights')}</small>"
|
||||
)
|
||||
|
||||
QMessageBox.about(self, f"About {self.config.app_name}", about_text)
|
||||
QMessageBox.about(self, tr("about.title", app_name=self.config.app_name), about_text)
|
||||
|
||||
def _show_settings_dialog(self) -> None:
|
||||
"""Show Settings dialog for configuration management.
|
||||
|
|
@ -1704,18 +1722,17 @@ class MainWindow(QMainWindow):
|
|||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
msg = QMessageBox(self)
|
||||
msg.setWindowTitle("Domain Changed - Restart Recommended")
|
||||
msg.setWindowTitle(tr("dialog.domain_changed.title"))
|
||||
msg.setIcon(QMessageBox.Icon.Warning)
|
||||
msg.setText(
|
||||
"Web Application Domain Has Changed\n\n"
|
||||
"You've switched to a different domain. For maximum stability and "
|
||||
"to ensure proper authentication, the application should be restarted.\n\n"
|
||||
"The profile and cache have been cleared, but we recommend restarting."
|
||||
)
|
||||
msg.setText(tr("dialog.domain_changed.msg"))
|
||||
|
||||
# Add custom buttons
|
||||
restart_now_btn = msg.addButton("Restart Now", QMessageBox.ButtonRole.AcceptRole)
|
||||
restart_later_btn = msg.addButton("Restart Later", QMessageBox.ButtonRole.RejectRole)
|
||||
restart_now_btn = msg.addButton(
|
||||
tr("dialog.domain_changed.restart_now"), QMessageBox.ButtonRole.AcceptRole
|
||||
)
|
||||
restart_later_btn = msg.addButton(
|
||||
tr("dialog.domain_changed.restart_later"), QMessageBox.ButtonRole.RejectRole
|
||||
)
|
||||
|
||||
msg.exec()
|
||||
|
||||
|
|
@ -1769,9 +1786,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Restart Failed",
|
||||
f"Could not automatically restart the application:\n\n{str(e)}\n\n"
|
||||
"Please restart manually.",
|
||||
tr("dialog.restart_failed.title"),
|
||||
tr("dialog.restart_failed.msg", error=str(e)),
|
||||
)
|
||||
|
||||
def _navigate_home(self) -> None:
|
||||
|
|
@ -1880,10 +1896,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Update Check Timeout",
|
||||
"The server did not respond within 30 seconds.\n\n"
|
||||
"This may be due to a network issue or server unavailability.\n\n"
|
||||
"Please check your connection and try again.",
|
||||
tr("dialog.update_timeout.title"),
|
||||
tr("dialog.update_timeout.msg"),
|
||||
)
|
||||
|
||||
safety_timer = QTimer()
|
||||
|
|
@ -1960,7 +1974,7 @@ class MainWindow(QMainWindow):
|
|||
error_message: Error description
|
||||
"""
|
||||
logger.error(f"Update check failed: {error_message}")
|
||||
self.set_update_status(f"Check failed: {error_message}", emoji="❌")
|
||||
self.set_update_status(tr("update.status.check_failed", error=error_message), emoji="❌")
|
||||
self._is_manual_check = False
|
||||
|
||||
# Close checking dialog first, then show error
|
||||
|
|
@ -1971,8 +1985,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Update Check Failed",
|
||||
f"Could not check for updates:\n\n{error_message}\n\nPlease try again later.",
|
||||
tr("dialog.update_failed.title"),
|
||||
tr("dialog.update_failed.msg", error=error_message),
|
||||
)
|
||||
|
||||
def _on_update_available(self, release) -> None:
|
||||
|
|
@ -1988,7 +2002,7 @@ class MainWindow(QMainWindow):
|
|||
self._is_manual_check = False
|
||||
|
||||
# Update status to show update available
|
||||
self.set_update_status(f"Update available: v{release.version}", emoji="✅")
|
||||
self.set_update_status(tr("update.status.available", version=release.version), emoji="✅")
|
||||
|
||||
# Show update available dialog
|
||||
from webdrop_bridge.ui.update_manager_ui import UpdateAvailableDialog
|
||||
|
|
@ -2016,7 +2030,7 @@ class MainWindow(QMainWindow):
|
|||
def _on_user_update_later(self) -> None:
|
||||
"""Handle user clicking 'Later' button."""
|
||||
logger.info("User deferred update")
|
||||
self.set_update_status("Update deferred", emoji="")
|
||||
self.set_update_status(tr("update.status.deferred"), emoji="")
|
||||
|
||||
def _start_update_download(self, release) -> None:
|
||||
"""Start downloading the update in background thread.
|
||||
|
|
@ -2025,7 +2039,7 @@ class MainWindow(QMainWindow):
|
|||
release: Release object to download
|
||||
"""
|
||||
logger.info(f"Starting download for v{release.version}")
|
||||
self.set_update_status(f"Downloading v{release.version}", emoji="⬇️")
|
||||
self.set_update_status(tr("update.status.downloading", version=release.version), emoji="⬇️")
|
||||
|
||||
# Show download progress dialog
|
||||
from webdrop_bridge.ui.update_manager_ui import DownloadingDialog
|
||||
|
|
@ -2139,7 +2153,7 @@ class MainWindow(QMainWindow):
|
|||
self.downloading_dialog = None
|
||||
|
||||
logger.info(f"Download complete: {installer_path}")
|
||||
self.set_update_status("Ready to install", emoji="✅")
|
||||
self.set_update_status(tr("update.status.ready_to_install"), emoji="✅")
|
||||
|
||||
# Show install confirmation dialog
|
||||
install_dialog = InstallDialog(parent=self)
|
||||
|
|
@ -2163,8 +2177,8 @@ class MainWindow(QMainWindow):
|
|||
|
||||
QMessageBox.critical(
|
||||
self,
|
||||
"Download Failed",
|
||||
f"Could not download the update:\n\n{error}\n\nPlease try again later.",
|
||||
tr("dialog.download_failed.title"),
|
||||
tr("dialog.download_failed.msg", error=error),
|
||||
)
|
||||
|
||||
def _on_download_progress(self, downloaded: int, total: int) -> None:
|
||||
|
|
@ -2192,10 +2206,10 @@ class MainWindow(QMainWindow):
|
|||
)
|
||||
|
||||
if manager.install_update(installer_path):
|
||||
self.set_update_status("Installation started", emoji="✅")
|
||||
self.set_update_status(tr("update.status.installation_started"), emoji="✅")
|
||||
logger.info("Update installer launched successfully")
|
||||
else:
|
||||
self.set_update_status("Installation failed", emoji="❌")
|
||||
self.set_update_status(tr("update.status.installation_failed"), emoji="❌")
|
||||
logger.error("Failed to launch update installer")
|
||||
|
||||
|
||||
|
|
@ -2226,7 +2240,7 @@ class UpdateCheckWorker(QObject):
|
|||
logger.debug("UpdateCheckWorker.run() starting")
|
||||
|
||||
# Notify checking status
|
||||
self.update_status.emit("Checking for updates", "🔄")
|
||||
self.update_status.emit("Checking for updates", "🔄") # Translated by set_update_status
|
||||
|
||||
# Create a fresh event loop for this thread
|
||||
logger.debug("Creating new event loop for worker thread")
|
||||
|
|
@ -2248,15 +2262,17 @@ class UpdateCheckWorker(QObject):
|
|||
else:
|
||||
# No update available - show ready status
|
||||
logger.info("No update available")
|
||||
self.update_status.emit("Ready", "")
|
||||
self.update_status.emit(
|
||||
"Ready", ""
|
||||
) # English sentinel; _on_update_status compares this
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Update check timed out - server not responding")
|
||||
self.check_failed.emit("Server not responding - check again later")
|
||||
self.check_failed.emit(tr("worker.server_not_responding"))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Update check failed: {e}", exc_info=True)
|
||||
self.check_failed.emit(f"Check failed: {str(e)[:50]}")
|
||||
self.check_failed.emit(tr("worker.check_failed", error=str(e)[:50]))
|
||||
finally:
|
||||
# Properly close the event loop
|
||||
if loop is not None:
|
||||
|
|
@ -2297,7 +2313,9 @@ class UpdateDownloadWorker(QObject):
|
|||
loop = None
|
||||
try:
|
||||
# Download the update
|
||||
self.update_status.emit(f"Downloading v{self.release.version}", "⬇️")
|
||||
self.update_status.emit(
|
||||
tr("update.status.downloading", version=self.release.version), "⬇️"
|
||||
)
|
||||
|
||||
# Create a fresh event loop for this thread
|
||||
loop = asyncio.new_event_loop()
|
||||
|
|
@ -2319,8 +2337,10 @@ class UpdateDownloadWorker(QObject):
|
|||
)
|
||||
|
||||
if not installer_path:
|
||||
self.update_status.emit("Download failed", "❌")
|
||||
self.download_failed.emit("No installer found in release")
|
||||
self.update_status.emit(
|
||||
"Download failed", "❌"
|
||||
) # Translated by set_update_status
|
||||
self.download_failed.emit(tr("worker.no_installer"))
|
||||
logger.error("Download failed - no installer found")
|
||||
return
|
||||
|
||||
|
|
@ -2337,7 +2357,7 @@ class UpdateDownloadWorker(QObject):
|
|||
|
||||
if not checksum_ok:
|
||||
self.update_status.emit("Verification failed", "❌")
|
||||
self.download_failed.emit("Checksum verification failed")
|
||||
self.download_failed.emit(tr("worker.checksum_failed"))
|
||||
logger.error("Checksum verification failed")
|
||||
return
|
||||
|
||||
|
|
@ -2346,17 +2366,17 @@ class UpdateDownloadWorker(QObject):
|
|||
|
||||
except asyncio.TimeoutError as e:
|
||||
logger.error(f"Download/verification timed out: {e}")
|
||||
self.update_status.emit("Operation timed out", "⏱️")
|
||||
self.download_failed.emit(
|
||||
"Download or verification timed out (no response from server)"
|
||||
)
|
||||
self.update_status.emit(
|
||||
"Operation timed out", "⏱️"
|
||||
) # Translated by set_update_status
|
||||
self.download_failed.emit(tr("worker.download_timed_out"))
|
||||
except Exception as e:
|
||||
logger.error(f"Error during download: {e}")
|
||||
self.download_failed.emit(f"Download error: {str(e)[:50]}")
|
||||
self.download_failed.emit(tr("worker.download_error", error=str(e)[:50]))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Download worker failed: {e}")
|
||||
self.download_failed.emit(f"Download error: {str(e)[:50]}")
|
||||
self.download_failed.emit(tr("worker.download_error", error=str(e)[:50]))
|
||||
finally:
|
||||
# Properly close the event loop
|
||||
if loop is not None:
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox,
|
||||
QDialog,
|
||||
|
|
@ -14,7 +13,6 @@ from PySide6.QtWidgets import (
|
|||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QTableWidget,
|
||||
|
|
@ -26,21 +24,14 @@ from PySide6.QtWidgets import (
|
|||
|
||||
from webdrop_bridge.config import Config, ConfigurationError
|
||||
from webdrop_bridge.core.config_manager import ConfigExporter, ConfigProfile, ConfigValidator
|
||||
from webdrop_bridge.utils.i18n import get_available_languages, tr
|
||||
from webdrop_bridge.utils.logging import reconfigure_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
"""Dialog for managing application settings and configuration.
|
||||
|
||||
Provides tabs for:
|
||||
- Paths: Manage allowed root directories
|
||||
- URLs: Manage allowed web URLs
|
||||
- Logging: Configure logging settings
|
||||
- Window: Manage window size and behavior
|
||||
- Profiles: Save/load/delete configuration profiles
|
||||
"""
|
||||
"""Dialog for managing application settings and configuration."""
|
||||
|
||||
def __init__(self, config: Config, parent: Optional[QWidget] = None):
|
||||
"""Initialize the settings dialog.
|
||||
|
|
@ -52,7 +43,7 @@ class SettingsDialog(QDialog):
|
|||
super().__init__(parent)
|
||||
self.config = config
|
||||
self.profile_manager = ConfigProfile()
|
||||
self.setWindowTitle("Settings")
|
||||
self.setWindowTitle(tr("settings.title"))
|
||||
self.setGeometry(100, 100, 600, 500)
|
||||
|
||||
self.setup_ui()
|
||||
|
|
@ -61,20 +52,16 @@ class SettingsDialog(QDialog):
|
|||
"""Set up the dialog UI with tabs."""
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Create tab widget
|
||||
self.tabs = QTabWidget()
|
||||
|
||||
# Add tabs
|
||||
self.tabs.addTab(self._create_web_source_tab(), "Web Source")
|
||||
self.tabs.addTab(self._create_paths_tab(), "Paths")
|
||||
self.tabs.addTab(self._create_urls_tab(), "URLs")
|
||||
self.tabs.addTab(self._create_logging_tab(), "Logging")
|
||||
self.tabs.addTab(self._create_window_tab(), "Window")
|
||||
self.tabs.addTab(self._create_profiles_tab(), "Profiles")
|
||||
|
||||
self.tabs.addTab(self._create_general_tab(), tr("settings.tab.general"))
|
||||
self.tabs.addTab(self._create_web_source_tab(), tr("settings.tab.web_source"))
|
||||
self.tabs.addTab(self._create_paths_tab(), tr("settings.tab.paths"))
|
||||
self.tabs.addTab(self._create_urls_tab(), tr("settings.tab.urls"))
|
||||
self.tabs.addTab(self._create_logging_tab(), tr("settings.tab.logging"))
|
||||
self.tabs.addTab(self._create_window_tab(), tr("settings.tab.window"))
|
||||
self.tabs.addTab(self._create_profiles_tab(), tr("settings.tab.profiles"))
|
||||
layout.addWidget(self.tabs)
|
||||
|
||||
# Add buttons
|
||||
button_box = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
|
|
@ -85,17 +72,10 @@ class SettingsDialog(QDialog):
|
|||
self.setLayout(layout)
|
||||
|
||||
def accept(self) -> None:
|
||||
"""Handle OK button - save configuration changes to file.
|
||||
|
||||
Validates configuration and saves to the default config path.
|
||||
Applies log level changes immediately in the running application.
|
||||
If validation or save fails, shows error and stays in dialog.
|
||||
"""
|
||||
"""Handle OK button - save configuration changes to file."""
|
||||
try:
|
||||
# Get updated configuration data from UI
|
||||
config_data = self.get_config_data()
|
||||
|
||||
# Convert URL mappings from dict to URLMapping objects
|
||||
from webdrop_bridge.config import URLMapping
|
||||
|
||||
url_mappings = [
|
||||
|
|
@ -103,8 +83,8 @@ class SettingsDialog(QDialog):
|
|||
for m in config_data["url_mappings"]
|
||||
]
|
||||
|
||||
# Update the config object with new values
|
||||
old_log_level = self.config.log_level
|
||||
self.config.language = config_data["language"]
|
||||
self.config.log_level = config_data["log_level"]
|
||||
self.config.log_file = (
|
||||
Path(config_data["log_file"]) if config_data["log_file"] else None
|
||||
|
|
@ -116,7 +96,6 @@ class SettingsDialog(QDialog):
|
|||
self.config.window_width = config_data["window_width"]
|
||||
self.config.window_height = config_data["window_height"]
|
||||
|
||||
# Save to file (creates parent dirs if needed)
|
||||
config_path = Config.get_default_config_path()
|
||||
self.config.to_file(config_path)
|
||||
|
||||
|
|
@ -124,17 +103,14 @@ class SettingsDialog(QDialog):
|
|||
logger.info(f" Log level: {self.config.log_level} (was: {old_log_level})")
|
||||
logger.info(f" Window size: {self.config.window_width}x{self.config.window_height}")
|
||||
|
||||
# Apply log level change immediately to running application
|
||||
if old_log_level != self.config.log_level:
|
||||
logger.info(f"🔄 Updating log level: {old_log_level} → {self.config.log_level}")
|
||||
reconfigure_logging(
|
||||
logger_name="webdrop_bridge",
|
||||
level=self.config.log_level,
|
||||
log_file=self.config.log_file,
|
||||
)
|
||||
logger.info(f"✅ Log level updated to {self.config.log_level}")
|
||||
logger.info(f"Log level updated to {self.config.log_level}")
|
||||
|
||||
# Call parent accept to close dialog
|
||||
super().accept()
|
||||
|
||||
except ConfigurationError as e:
|
||||
|
|
@ -144,15 +120,42 @@ class SettingsDialog(QDialog):
|
|||
logger.error(f"Failed to save configuration: {e}", exc_info=True)
|
||||
self._show_error(f"Failed to save configuration:\n\n{e}")
|
||||
|
||||
def _create_web_source_tab(self) -> QWidget:
|
||||
"""Create web source configuration tab."""
|
||||
from PySide6.QtWidgets import QTableWidget, QTableWidgetItem
|
||||
|
||||
def _create_general_tab(self) -> QWidget:
|
||||
"""Create general settings tab with language selector."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Webapp URL configuration
|
||||
layout.addWidget(QLabel("Web Application URL:"))
|
||||
lang_layout = QHBoxLayout()
|
||||
lang_layout.addWidget(QLabel(tr("settings.general.language_label")))
|
||||
|
||||
self.language_combo = QComboBox()
|
||||
available = get_available_languages()
|
||||
current_lang = self.config.language
|
||||
for code, name in available.items():
|
||||
self.language_combo.addItem(name, code)
|
||||
|
||||
idx = self.language_combo.findData(current_lang)
|
||||
if idx >= 0:
|
||||
self.language_combo.setCurrentIndex(idx)
|
||||
|
||||
lang_layout.addWidget(self.language_combo)
|
||||
lang_layout.addStretch()
|
||||
layout.addLayout(lang_layout)
|
||||
|
||||
note = QLabel(tr("settings.general.language_restart_note"))
|
||||
note.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(note)
|
||||
|
||||
layout.addStretch()
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
def _create_web_source_tab(self) -> QWidget:
|
||||
"""Create web source configuration tab."""
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel(tr("settings.web_source.url_label")))
|
||||
url_layout = QHBoxLayout()
|
||||
|
||||
self.webapp_url_input = QLineEdit()
|
||||
|
|
@ -162,22 +165,24 @@ class SettingsDialog(QDialog):
|
|||
)
|
||||
url_layout.addWidget(self.webapp_url_input)
|
||||
|
||||
open_btn = QPushButton("Open")
|
||||
open_btn = QPushButton(tr("settings.web_source.open_btn"))
|
||||
open_btn.clicked.connect(self._open_webapp_url)
|
||||
url_layout.addWidget(open_btn)
|
||||
|
||||
layout.addLayout(url_layout)
|
||||
|
||||
# URL Mappings (Azure Blob URL → Local Path)
|
||||
layout.addWidget(QLabel("URL Mappings (Azure Blob Storage → Local Paths):"))
|
||||
layout.addWidget(QLabel(tr("settings.web_source.url_mappings_label")))
|
||||
|
||||
# Create table for URL mappings
|
||||
self.url_mappings_table = QTableWidget()
|
||||
self.url_mappings_table.setColumnCount(2)
|
||||
self.url_mappings_table.setHorizontalHeaderLabels(["URL Prefix", "Local Path"])
|
||||
self.url_mappings_table.setHorizontalHeaderLabels(
|
||||
[
|
||||
tr("settings.web_source.col_url_prefix"),
|
||||
tr("settings.web_source.col_local_path"),
|
||||
]
|
||||
)
|
||||
self.url_mappings_table.horizontalHeader().setStretchLastSection(True)
|
||||
|
||||
# Populate from config
|
||||
for mapping in self.config.url_mappings:
|
||||
row = self.url_mappings_table.rowCount()
|
||||
self.url_mappings_table.insertRow(row)
|
||||
|
|
@ -186,18 +191,17 @@ class SettingsDialog(QDialog):
|
|||
|
||||
layout.addWidget(self.url_mappings_table)
|
||||
|
||||
# Buttons for URL mapping management
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
add_mapping_btn = QPushButton("Add Mapping")
|
||||
add_mapping_btn = QPushButton(tr("settings.web_source.add_mapping_btn"))
|
||||
add_mapping_btn.clicked.connect(self._add_url_mapping)
|
||||
button_layout.addWidget(add_mapping_btn)
|
||||
|
||||
edit_mapping_btn = QPushButton("Edit Selected")
|
||||
edit_mapping_btn = QPushButton(tr("settings.web_source.edit_mapping_btn"))
|
||||
edit_mapping_btn.clicked.connect(self._edit_url_mapping)
|
||||
button_layout.addWidget(edit_mapping_btn)
|
||||
|
||||
remove_mapping_btn = QPushButton("Remove Selected")
|
||||
remove_mapping_btn = QPushButton(tr("settings.web_source.remove_mapping_btn"))
|
||||
remove_mapping_btn.clicked.connect(self._remove_url_mapping)
|
||||
button_layout.addWidget(remove_mapping_btn)
|
||||
|
||||
|
|
@ -212,13 +216,13 @@ class SettingsDialog(QDialog):
|
|||
import webbrowser
|
||||
|
||||
url = self.webapp_url_input.text().strip()
|
||||
if url:
|
||||
# Handle file:// URLs
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open URL: {e}")
|
||||
self._show_error(f"Failed to open URL:\n\n{e}")
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
webbrowser.open(url)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open URL: {e}")
|
||||
self._show_error(f"Failed to open URL:\n\n{e}")
|
||||
|
||||
def _add_url_mapping(self) -> None:
|
||||
"""Add new URL mapping."""
|
||||
|
|
@ -226,15 +230,15 @@ class SettingsDialog(QDialog):
|
|||
|
||||
url_prefix, ok1 = QInputDialog.getText(
|
||||
self,
|
||||
"Add URL Mapping",
|
||||
"Enter Azure Blob Storage URL prefix:\n(e.g., https://myblob.blob.core.windows.net/container/)",
|
||||
tr("settings.web_source.add_mapping_title"),
|
||||
tr("settings.web_source.add_mapping_url_prompt"),
|
||||
)
|
||||
|
||||
if ok1 and url_prefix:
|
||||
local_path, ok2 = QInputDialog.getText(
|
||||
self,
|
||||
"Add URL Mapping",
|
||||
"Enter local file system path:\n(e.g., C:\\Share or /mnt/share)",
|
||||
tr("settings.web_source.add_mapping_title"),
|
||||
tr("settings.web_source.add_mapping_path_prompt"),
|
||||
)
|
||||
|
||||
if ok2 and local_path:
|
||||
|
|
@ -249,19 +253,25 @@ class SettingsDialog(QDialog):
|
|||
|
||||
current_row = self.url_mappings_table.currentRow()
|
||||
if current_row < 0:
|
||||
self._show_error("Please select a mapping to edit")
|
||||
self._show_error(tr("settings.web_source.select_mapping_to_edit"))
|
||||
return
|
||||
|
||||
url_prefix = self.url_mappings_table.item(current_row, 0).text() # type: ignore
|
||||
local_path = self.url_mappings_table.item(current_row, 1).text() # type: ignore
|
||||
|
||||
new_url_prefix, ok1 = QInputDialog.getText(
|
||||
self, "Edit URL Mapping", "Enter Azure Blob Storage URL prefix:", text=url_prefix
|
||||
self,
|
||||
tr("settings.web_source.edit_mapping_title"),
|
||||
tr("settings.web_source.edit_mapping_url_prompt"),
|
||||
text=url_prefix,
|
||||
)
|
||||
|
||||
if ok1 and new_url_prefix:
|
||||
new_local_path, ok2 = QInputDialog.getText(
|
||||
self, "Edit URL Mapping", "Enter local file system path:", text=local_path
|
||||
self,
|
||||
tr("settings.web_source.edit_mapping_title"),
|
||||
tr("settings.web_source.edit_mapping_path_prompt"),
|
||||
text=local_path,
|
||||
)
|
||||
|
||||
if ok2 and new_local_path:
|
||||
|
|
@ -279,22 +289,20 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel("Allowed root directories for file access:"))
|
||||
layout.addWidget(QLabel(tr("settings.paths.label")))
|
||||
|
||||
# List widget for paths
|
||||
self.paths_list = QListWidget()
|
||||
for path in self.config.allowed_roots:
|
||||
self.paths_list.addItem(str(path))
|
||||
layout.addWidget(self.paths_list)
|
||||
|
||||
# Buttons for path management
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
add_path_btn = QPushButton("Add Path")
|
||||
add_path_btn = QPushButton(tr("settings.paths.add_btn"))
|
||||
add_path_btn.clicked.connect(self._add_path)
|
||||
button_layout.addWidget(add_path_btn)
|
||||
|
||||
remove_path_btn = QPushButton("Remove Selected")
|
||||
remove_path_btn = QPushButton(tr("settings.paths.remove_btn"))
|
||||
remove_path_btn.clicked.connect(self._remove_path)
|
||||
button_layout.addWidget(remove_path_btn)
|
||||
|
||||
|
|
@ -309,22 +317,20 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel("Allowed web URLs (supports wildcards like http://*.example.com):"))
|
||||
layout.addWidget(QLabel(tr("settings.urls.label")))
|
||||
|
||||
# List widget for URLs
|
||||
self.urls_list = QListWidget()
|
||||
for url in self.config.allowed_urls:
|
||||
self.urls_list.addItem(url)
|
||||
layout.addWidget(self.urls_list)
|
||||
|
||||
# Buttons for URL management
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
add_url_btn = QPushButton("Add URL")
|
||||
add_url_btn = QPushButton(tr("settings.urls.add_btn"))
|
||||
add_url_btn.clicked.connect(self._add_url)
|
||||
button_layout.addWidget(add_url_btn)
|
||||
|
||||
remove_url_btn = QPushButton("Remove Selected")
|
||||
remove_url_btn = QPushButton(tr("settings.urls.remove_btn"))
|
||||
remove_url_btn.clicked.connect(self._remove_url)
|
||||
button_layout.addWidget(remove_url_btn)
|
||||
|
||||
|
|
@ -339,27 +345,22 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Log level selection
|
||||
layout.addWidget(QLabel("Log Level:"))
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
|
||||
layout.addWidget(QLabel(tr("settings.logging.level_label")))
|
||||
self.log_level_combo: QComboBox = self._create_log_level_widget()
|
||||
layout.addWidget(self.log_level_combo)
|
||||
|
||||
# Log file path
|
||||
layout.addWidget(QLabel("Log File (optional):"))
|
||||
layout.addWidget(QLabel(tr("settings.logging.file_label")))
|
||||
log_file_layout = QHBoxLayout()
|
||||
|
||||
self.log_file_input = QLineEdit()
|
||||
self.log_file_input.setText(str(self.config.log_file) if self.config.log_file else "")
|
||||
log_file_layout.addWidget(self.log_file_input)
|
||||
|
||||
browse_btn = QPushButton("Browse...")
|
||||
browse_btn = QPushButton(tr("settings.logging.browse_btn"))
|
||||
browse_btn.clicked.connect(self._browse_log_file)
|
||||
log_file_layout.addWidget(browse_btn)
|
||||
|
||||
layout.addLayout(log_file_layout)
|
||||
|
||||
layout.addStretch()
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
|
@ -369,9 +370,8 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Window width
|
||||
width_layout = QHBoxLayout()
|
||||
width_layout.addWidget(QLabel("Window Width:"))
|
||||
width_layout.addWidget(QLabel(tr("settings.window.width_label")))
|
||||
self.width_spin = QSpinBox()
|
||||
self.width_spin.setMinimum(400)
|
||||
self.width_spin.setMaximum(5000)
|
||||
|
|
@ -380,9 +380,8 @@ class SettingsDialog(QDialog):
|
|||
width_layout.addStretch()
|
||||
layout.addLayout(width_layout)
|
||||
|
||||
# Window height
|
||||
height_layout = QHBoxLayout()
|
||||
height_layout.addWidget(QLabel("Window Height:"))
|
||||
height_layout.addWidget(QLabel(tr("settings.window.height_label")))
|
||||
self.height_spin = QSpinBox()
|
||||
self.height_spin.setMinimum(300)
|
||||
self.height_spin.setMaximum(5000)
|
||||
|
|
@ -400,38 +399,35 @@ class SettingsDialog(QDialog):
|
|||
widget = QWidget()
|
||||
layout = QVBoxLayout()
|
||||
|
||||
layout.addWidget(QLabel("Saved Configuration Profiles:"))
|
||||
layout.addWidget(QLabel(tr("settings.profiles.label")))
|
||||
|
||||
# List of profiles
|
||||
self.profiles_list = QListWidget()
|
||||
self._refresh_profiles_list()
|
||||
layout.addWidget(self.profiles_list)
|
||||
|
||||
# Profile management buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
save_profile_btn = QPushButton("Save as Profile")
|
||||
save_profile_btn = QPushButton(tr("settings.profiles.save_btn"))
|
||||
save_profile_btn.clicked.connect(self._save_profile)
|
||||
button_layout.addWidget(save_profile_btn)
|
||||
|
||||
load_profile_btn = QPushButton("Load Profile")
|
||||
load_profile_btn = QPushButton(tr("settings.profiles.load_btn"))
|
||||
load_profile_btn.clicked.connect(self._load_profile)
|
||||
button_layout.addWidget(load_profile_btn)
|
||||
|
||||
delete_profile_btn = QPushButton("Delete Profile")
|
||||
delete_profile_btn = QPushButton(tr("settings.profiles.delete_btn"))
|
||||
delete_profile_btn.clicked.connect(self._delete_profile)
|
||||
button_layout.addWidget(delete_profile_btn)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
# Export/Import buttons
|
||||
export_layout = QHBoxLayout()
|
||||
|
||||
export_btn = QPushButton("Export Configuration")
|
||||
export_btn = QPushButton(tr("settings.profiles.export_btn"))
|
||||
export_btn.clicked.connect(self._export_config)
|
||||
export_layout.addWidget(export_btn)
|
||||
|
||||
import_btn = QPushButton("Import Configuration")
|
||||
import_btn = QPushButton(tr("settings.profiles.import_btn"))
|
||||
import_btn.clicked.connect(self._import_config)
|
||||
export_layout.addWidget(import_btn)
|
||||
|
||||
|
|
@ -451,7 +447,7 @@ class SettingsDialog(QDialog):
|
|||
|
||||
def _add_path(self) -> None:
|
||||
"""Add a new allowed path."""
|
||||
path = QFileDialog.getExistingDirectory(self, "Select Directory to Allow")
|
||||
path = QFileDialog.getExistingDirectory(self, tr("settings.paths.select_dir_title"))
|
||||
if path:
|
||||
self.paths_list.addItem(path)
|
||||
|
||||
|
|
@ -465,7 +461,7 @@ class SettingsDialog(QDialog):
|
|||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
url, ok = QInputDialog.getText(
|
||||
self, "Add URL", "Enter URL pattern (e.g., http://example.com or http://*.example.com):"
|
||||
self, tr("settings.urls.add_title"), tr("settings.urls.add_prompt")
|
||||
)
|
||||
if ok and url:
|
||||
self.urls_list.addItem(url)
|
||||
|
|
@ -478,7 +474,10 @@ class SettingsDialog(QDialog):
|
|||
def _browse_log_file(self) -> None:
|
||||
"""Browse for log file location."""
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Select Log File", str(Path.home()), "Log Files (*.log);;All Files (*)"
|
||||
self,
|
||||
tr("settings.logging.select_file_title"),
|
||||
str(Path.home()),
|
||||
"Log Files (*.log);;All Files (*)",
|
||||
)
|
||||
if file_path:
|
||||
self.log_file_input.setText(file_path)
|
||||
|
|
@ -494,7 +493,7 @@ class SettingsDialog(QDialog):
|
|||
from PySide6.QtWidgets import QInputDialog
|
||||
|
||||
profile_name, ok = QInputDialog.getText(
|
||||
self, "Save Profile", "Enter profile name (e.g., work, personal):"
|
||||
self, tr("settings.profiles.save_title"), tr("settings.profiles.save_prompt")
|
||||
)
|
||||
|
||||
if ok and profile_name:
|
||||
|
|
@ -508,7 +507,7 @@ class SettingsDialog(QDialog):
|
|||
"""Load a saved profile."""
|
||||
current_item = self.profiles_list.currentItem()
|
||||
if not current_item:
|
||||
self._show_error("Please select a profile to load")
|
||||
self._show_error(tr("settings.profiles.select_to_load"))
|
||||
return
|
||||
|
||||
profile_name = current_item.text()
|
||||
|
|
@ -522,7 +521,7 @@ class SettingsDialog(QDialog):
|
|||
"""Delete a saved profile."""
|
||||
current_item = self.profiles_list.currentItem()
|
||||
if not current_item:
|
||||
self._show_error("Please select a profile to delete")
|
||||
self._show_error(tr("settings.profiles.select_to_delete"))
|
||||
return
|
||||
|
||||
profile_name = current_item.text()
|
||||
|
|
@ -535,7 +534,10 @@ class SettingsDialog(QDialog):
|
|||
def _export_config(self) -> None:
|
||||
"""Export configuration to file."""
|
||||
file_path, _ = QFileDialog.getSaveFileName(
|
||||
self, "Export Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)"
|
||||
self,
|
||||
tr("settings.profiles.export_title"),
|
||||
str(Path.home()),
|
||||
"JSON Files (*.json);;All Files (*)",
|
||||
)
|
||||
|
||||
if file_path:
|
||||
|
|
@ -547,7 +549,10 @@ class SettingsDialog(QDialog):
|
|||
def _import_config(self) -> None:
|
||||
"""Import configuration from file."""
|
||||
file_path, _ = QFileDialog.getOpenFileName(
|
||||
self, "Import Configuration", str(Path.home()), "JSON Files (*.json);;All Files (*)"
|
||||
self,
|
||||
tr("settings.profiles.import_title"),
|
||||
str(Path.home()),
|
||||
"JSON Files (*.json);;All Files (*)",
|
||||
)
|
||||
|
||||
if file_path:
|
||||
|
|
@ -563,25 +568,26 @@ class SettingsDialog(QDialog):
|
|||
Args:
|
||||
config_data: Configuration dictionary
|
||||
"""
|
||||
# Apply paths
|
||||
self.paths_list.clear()
|
||||
for path in config_data.get("allowed_roots", []):
|
||||
self.paths_list.addItem(str(path))
|
||||
|
||||
# Apply URLs
|
||||
self.urls_list.clear()
|
||||
for url in config_data.get("allowed_urls", []):
|
||||
self.urls_list.addItem(url)
|
||||
|
||||
# Apply logging settings
|
||||
self.log_level_combo.setCurrentText(config_data.get("log_level", "INFO"))
|
||||
log_file = config_data.get("log_file")
|
||||
self.log_file_input.setText(str(log_file) if log_file else "")
|
||||
|
||||
# Apply window settings
|
||||
self.width_spin.setValue(config_data.get("window_width", 800))
|
||||
self.height_spin.setValue(config_data.get("window_height", 600))
|
||||
|
||||
language = config_data.get("language", "auto")
|
||||
idx = self.language_combo.findData(language)
|
||||
if idx >= 0:
|
||||
self.language_combo.setCurrentIndex(idx)
|
||||
|
||||
def get_config_data(self) -> Dict[str, Any]:
|
||||
"""Get updated configuration data from dialog.
|
||||
|
||||
|
|
@ -591,13 +597,14 @@ class SettingsDialog(QDialog):
|
|||
Raises:
|
||||
ConfigurationError: If configuration is invalid
|
||||
"""
|
||||
if self.url_mappings_table:
|
||||
url_mappings_table_count = self.url_mappings_table.rowCount() or 0
|
||||
else:
|
||||
url_mappings_table_count = 0
|
||||
url_mappings_table_count = (
|
||||
self.url_mappings_table.rowCount() if self.url_mappings_table else 0
|
||||
)
|
||||
|
||||
config_data = {
|
||||
"app_name": self.config.app_name,
|
||||
"app_version": self.config.app_version,
|
||||
"language": self.language_combo.currentData(),
|
||||
"log_level": self.log_level_combo.currentText(),
|
||||
"log_file": self.log_file_input.text() or None,
|
||||
"allowed_roots": [
|
||||
|
|
@ -607,8 +614,16 @@ class SettingsDialog(QDialog):
|
|||
"webapp_url": self.webapp_url_input.text().strip(),
|
||||
"url_mappings": [
|
||||
{
|
||||
"url_prefix": self.url_mappings_table.item(i, 0).text() if self.url_mappings_table.item(i, 0) else "", # type: ignore
|
||||
"local_path": self.url_mappings_table.item(i, 1).text() if self.url_mappings_table.item(i, 1) else "", # type: ignore
|
||||
"url_prefix": (
|
||||
self.url_mappings_table.item(i, 0).text() # type: ignore
|
||||
if self.url_mappings_table.item(i, 0)
|
||||
else ""
|
||||
),
|
||||
"local_path": (
|
||||
self.url_mappings_table.item(i, 1).text() # type: ignore
|
||||
if self.url_mappings_table.item(i, 1)
|
||||
else ""
|
||||
),
|
||||
}
|
||||
for i in range(url_mappings_table_count)
|
||||
],
|
||||
|
|
@ -617,9 +632,7 @@ class SettingsDialog(QDialog):
|
|||
"enable_logging": self.config.enable_logging,
|
||||
}
|
||||
|
||||
# Validate
|
||||
ConfigValidator.validate_or_raise(config_data)
|
||||
|
||||
return config_data
|
||||
|
||||
def _show_error(self, message: str) -> None:
|
||||
|
|
@ -630,4 +643,4 @@ class SettingsDialog(QDialog):
|
|||
"""
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
QMessageBox.critical(self, "Error", message)
|
||||
QMessageBox.critical(self, tr("dialog.error.title"), message)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ from PySide6.QtWidgets import (
|
|||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from webdrop_bridge.utils.i18n import tr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
|
@ -41,7 +43,7 @@ class CheckingDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Checking for Updates")
|
||||
self.setWindowTitle(tr("update.checking.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(300)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
|
|
@ -49,7 +51,7 @@ class CheckingDialog(QDialog):
|
|||
layout = QVBoxLayout()
|
||||
|
||||
# Status label
|
||||
self.label = QLabel("Checking for updates...")
|
||||
self.label = QLabel(tr("update.checking.label"))
|
||||
layout.addWidget(self.label)
|
||||
|
||||
# Animated progress bar
|
||||
|
|
@ -58,7 +60,7 @@ class CheckingDialog(QDialog):
|
|||
layout.addWidget(self.progress)
|
||||
|
||||
# Timeout info
|
||||
info_label = QLabel("This may take up to 10 seconds")
|
||||
info_label = QLabel(tr("update.checking.timeout_info"))
|
||||
info_label.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(info_label)
|
||||
|
||||
|
|
@ -88,7 +90,7 @@ class UpdateAvailableDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Update Available")
|
||||
self.setWindowTitle(tr("update.available.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(400)
|
||||
self.setMinimumHeight(300)
|
||||
|
|
@ -96,12 +98,12 @@ class UpdateAvailableDialog(QDialog):
|
|||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header = QLabel(f"WebDrop Bridge v{version} is available")
|
||||
header = QLabel(tr("update.available.header", version=version))
|
||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
layout.addWidget(header)
|
||||
|
||||
# Changelog
|
||||
changelog_label = QLabel("Release Notes:")
|
||||
changelog_label = QLabel(tr("update.available.changelog_label"))
|
||||
changelog_label.setStyleSheet("font-weight: bold; margin-top: 10px;")
|
||||
layout.addWidget(changelog_label)
|
||||
|
||||
|
|
@ -113,11 +115,11 @@ class UpdateAvailableDialog(QDialog):
|
|||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.update_now_btn = QPushButton("Update Now")
|
||||
self.update_now_btn = QPushButton(tr("update.available.update_now_btn"))
|
||||
self.update_now_btn.clicked.connect(self._on_update_now)
|
||||
button_layout.addWidget(self.update_now_btn)
|
||||
|
||||
self.update_later_btn = QPushButton("Later")
|
||||
self.update_later_btn = QPushButton(tr("update.available.later_btn"))
|
||||
self.update_later_btn.clicked.connect(self._on_update_later)
|
||||
button_layout.addWidget(self.update_later_btn)
|
||||
|
||||
|
|
@ -153,7 +155,7 @@ class DownloadingDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Downloading Update")
|
||||
self.setWindowTitle(tr("update.downloading.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(350)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowCloseButtonHint)
|
||||
|
|
@ -161,12 +163,12 @@ class DownloadingDialog(QDialog):
|
|||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header = QLabel("Downloading update...")
|
||||
header = QLabel(tr("update.downloading.header"))
|
||||
header.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(header)
|
||||
|
||||
# File label
|
||||
self.file_label = QLabel("Preparing download")
|
||||
self.file_label = QLabel(tr("update.downloading.preparing"))
|
||||
layout.addWidget(self.file_label)
|
||||
|
||||
# Progress bar
|
||||
|
|
@ -182,7 +184,7 @@ class DownloadingDialog(QDialog):
|
|||
layout.addWidget(self.size_label)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn = QPushButton(tr("update.downloading.cancel_btn"))
|
||||
self.cancel_btn.clicked.connect(self._on_cancel)
|
||||
layout.addWidget(self.cancel_btn)
|
||||
|
||||
|
|
@ -210,7 +212,7 @@ class DownloadingDialog(QDialog):
|
|||
Args:
|
||||
filename: Name of file being downloaded
|
||||
"""
|
||||
self.file_label.setText(f"Downloading: {filename}")
|
||||
self.file_label.setText(tr("update.downloading.filename", filename=filename))
|
||||
|
||||
def _on_cancel(self):
|
||||
"""Handle cancel button click."""
|
||||
|
|
@ -236,26 +238,23 @@ class InstallDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Install Update")
|
||||
self.setWindowTitle(tr("update.install.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header = QLabel("Ready to Install")
|
||||
header = QLabel(tr("update.install.header"))
|
||||
header.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
layout.addWidget(header)
|
||||
|
||||
# Message
|
||||
message = QLabel("The update is ready to install. The application will restart.")
|
||||
message = QLabel(tr("update.install.message"))
|
||||
layout.addWidget(message)
|
||||
|
||||
# Warning
|
||||
warning = QLabel(
|
||||
"⚠️ Please save any unsaved work before continuing.\n"
|
||||
"The application will close and restart."
|
||||
)
|
||||
warning = QLabel(tr("update.install.warning"))
|
||||
warning.setStyleSheet("background-color: #fff3cd; padding: 10px; border-radius: 4px;")
|
||||
warning.setWordWrap(True)
|
||||
layout.addWidget(warning)
|
||||
|
|
@ -263,12 +262,12 @@ class InstallDialog(QDialog):
|
|||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.install_btn = QPushButton("Install Now")
|
||||
self.install_btn = QPushButton(tr("update.install.now_btn"))
|
||||
self.install_btn.setStyleSheet("background-color: #28a745; color: white;")
|
||||
self.install_btn.clicked.connect(self._on_install)
|
||||
button_layout.addWidget(self.install_btn)
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn = QPushButton(tr("update.install.cancel_btn"))
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
|
|
@ -294,22 +293,22 @@ class NoUpdateDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("No Updates Available")
|
||||
self.setWindowTitle(tr("update.no_update.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(300)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Message
|
||||
message = QLabel("✓ You're using the latest version")
|
||||
message = QLabel(tr("update.no_update.message"))
|
||||
message.setStyleSheet("font-weight: bold; font-size: 12px; color: #28a745;")
|
||||
layout.addWidget(message)
|
||||
|
||||
info = QLabel("WebDrop Bridge is up to date.")
|
||||
info = QLabel(tr("update.no_update.info"))
|
||||
layout.addWidget(info)
|
||||
|
||||
# Close button
|
||||
close_btn = QPushButton("OK")
|
||||
close_btn = QPushButton(tr("update.no_update.ok_btn"))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
|
|
@ -335,14 +334,14 @@ class ErrorDialog(QDialog):
|
|||
parent: Parent widget
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Update Failed")
|
||||
self.setWindowTitle(tr("update.error.title"))
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Header
|
||||
header = QLabel("⚠️ Update Failed")
|
||||
header = QLabel(tr("update.error.header"))
|
||||
header.setStyleSheet("font-weight: bold; font-size: 14px; color: #dc3545;")
|
||||
layout.addWidget(header)
|
||||
|
||||
|
|
@ -354,7 +353,7 @@ class ErrorDialog(QDialog):
|
|||
layout.addWidget(self.error_text)
|
||||
|
||||
# Info message
|
||||
info = QLabel("Please try again or visit the website to download the update manually.")
|
||||
info = QLabel(tr("update.error.info"))
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: gray; font-size: 11px;")
|
||||
layout.addWidget(info)
|
||||
|
|
@ -362,15 +361,15 @@ class ErrorDialog(QDialog):
|
|||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.retry_btn = QPushButton("Retry")
|
||||
self.retry_btn = QPushButton(tr("update.error.retry_btn"))
|
||||
self.retry_btn.clicked.connect(self._on_retry)
|
||||
button_layout.addWidget(self.retry_btn)
|
||||
|
||||
self.manual_btn = QPushButton("Download Manually")
|
||||
self.manual_btn = QPushButton(tr("update.error.manual_btn"))
|
||||
self.manual_btn.clicked.connect(self._on_manual)
|
||||
button_layout.addWidget(self.manual_btn)
|
||||
|
||||
self.cancel_btn = QPushButton("Cancel")
|
||||
self.cancel_btn = QPushButton(tr("update.error.cancel_btn"))
|
||||
self.cancel_btn.clicked.connect(self.reject)
|
||||
button_layout.addWidget(self.cancel_btn)
|
||||
|
||||
|
|
|
|||
292
src/webdrop_bridge/utils/i18n.py
Normal file
292
src/webdrop_bridge/utils/i18n.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""Internationalization (i18n) support for WebDrop Bridge.
|
||||
|
||||
Provides a simple JSON-based translation system. Translation files are stored
|
||||
in ``resources/translations/`` (e.g. ``en.json``, ``de.json``, ``fr.json``).
|
||||
|
||||
Usage::
|
||||
|
||||
from webdrop_bridge.utils.i18n import tr
|
||||
|
||||
# Simple lookup
|
||||
self.setWindowTitle(tr("settings.title"))
|
||||
|
||||
# With named format arguments
|
||||
label.setText(tr("status.opened", name="file.pdf"))
|
||||
|
||||
To add a new language, place a JSON file named ``<code>.json`` in
|
||||
``resources/translations/`` and optionally add an entry to
|
||||
:attr:`Translator.BUILTIN_LANGUAGES` for a nicer display name.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Translator:
|
||||
"""Manages translations for the application.
|
||||
|
||||
Loads translations from UTF-8 JSON files that use dot-notation string keys.
|
||||
Falls back to the English translation (and ultimately to the bare key) when
|
||||
a translation is missing.
|
||||
|
||||
Attributes:
|
||||
BUILTIN_LANGUAGES: Mapping of language code → display name for languages
|
||||
that ship with the application. Add entries here when including new
|
||||
translation files.
|
||||
"""
|
||||
|
||||
#: Human-readable display names for supported language codes.
|
||||
#: Unknown codes fall back to their uppercase code string.
|
||||
BUILTIN_LANGUAGES: Dict[str, str] = {
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"fr": "Français",
|
||||
"it": "Italiano",
|
||||
"ru": "Русский",
|
||||
"zh": "中文",
|
||||
}
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._language: str = "en"
|
||||
self._translations: Dict[str, str] = {}
|
||||
self._fallback: Dict[str, str] = {}
|
||||
self._translations_dir: Optional[Path] = None
|
||||
|
||||
def initialize(self, language: str, translations_dir: Path) -> None:
|
||||
"""Initialize the translator with a language and translations directory.
|
||||
|
||||
Args:
|
||||
language: Language code (e.g. ``"en"``, ``"de"``, ``"fr"``) or
|
||||
``"auto"`` to detect from the system locale.
|
||||
translations_dir: Directory containing the ``.json`` translation files.
|
||||
"""
|
||||
self._translations_dir = translations_dir
|
||||
|
||||
# Resolve "auto" to system locale
|
||||
if language == "auto":
|
||||
language = self._detect_system_language()
|
||||
logger.debug(f"Auto-detected language: {language}")
|
||||
|
||||
# Load English as fallback first
|
||||
en_path = translations_dir / "en.json"
|
||||
if en_path.exists():
|
||||
self._fallback = self._load_file(en_path)
|
||||
logger.debug(f"Loaded English fallback translations ({len(self._fallback)} keys)")
|
||||
else:
|
||||
logger.warning(f"English translation file not found at {en_path}")
|
||||
|
||||
# Load requested language
|
||||
self._language = language
|
||||
if language != "en":
|
||||
lang_path = translations_dir / f"{language}.json"
|
||||
if lang_path.exists():
|
||||
self._translations = self._load_file(lang_path)
|
||||
logger.debug(f"Loaded '{language}' translations ({len(self._translations)} keys)")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Translation file not found for language '{language}', "
|
||||
"falling back to English"
|
||||
)
|
||||
self._translations = {}
|
||||
else:
|
||||
self._translations = self._fallback
|
||||
|
||||
def tr(self, key: str, **kwargs: str) -> str:
|
||||
"""Get translated string for the given key.
|
||||
|
||||
Args:
|
||||
key: Translation key using dot-notation (e.g. ``"toolbar.home"``).
|
||||
**kwargs: Named format arguments applied to the translated string.
|
||||
|
||||
Returns:
|
||||
Translated and formatted string. Returns the *key* itself when no
|
||||
translation is found, so missing keys are always visible.
|
||||
"""
|
||||
text = self._translations.get(key) or self._fallback.get(key) or key
|
||||
if kwargs:
|
||||
try:
|
||||
text = text.format(**kwargs)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.debug(f"Translation format error for key '{key}': {e}")
|
||||
return text
|
||||
|
||||
def get_current_language(self) -> str:
|
||||
"""Get the currently active language code (e.g. ``"de"``)."""
|
||||
return self._language
|
||||
|
||||
def get_available_languages(self) -> Dict[str, str]:
|
||||
"""Return available languages as ``{code: display_name}``.
|
||||
|
||||
Discovers language files at runtime so newly added JSON files are
|
||||
automatically included without code changes.
|
||||
|
||||
Returns:
|
||||
Ordered dict mapping language code → human-readable display name.
|
||||
"""
|
||||
if self._translations_dir is None:
|
||||
return {"en": "English"}
|
||||
|
||||
languages: Dict[str, str] = {}
|
||||
for lang_file in sorted(self._translations_dir.glob("*.json")):
|
||||
code = lang_file.stem
|
||||
name = self.BUILTIN_LANGUAGES.get(code, code.upper())
|
||||
languages[code] = name
|
||||
return languages
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_file(self, path: Path) -> Dict[str, str]:
|
||||
"""Load a JSON translation file.
|
||||
|
||||
Args:
|
||||
path: Path to the UTF-8 encoded JSON translation file.
|
||||
|
||||
Returns:
|
||||
Dictionary of translation keys to translated strings, or an empty
|
||||
dict when the file cannot be read or parsed.
|
||||
"""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.error(f"Failed to load translation file {path}: {e}")
|
||||
return {}
|
||||
|
||||
def _detect_system_language(self) -> str:
|
||||
"""Detect system language from locale or platform settings.
|
||||
|
||||
On Windows, attempts to read the UI language via the WinAPI before
|
||||
falling back to the ``locale`` module.
|
||||
|
||||
Returns:
|
||||
Best-matching supported language code, or ``"en"`` as fallback.
|
||||
"""
|
||||
import locale
|
||||
|
||||
try:
|
||||
lang_code: Optional[str] = None
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
# Windows: use GetUserDefaultUILanguage for accuracy
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
lcid = ctypes.windll.kernel32.GetUserDefaultUILanguage() # type: ignore[attr-defined]
|
||||
# Subset of LCID → ISO 639-1 mappings
|
||||
lcid_map: Dict[int, str] = {
|
||||
0x0407: "de", # German (Germany)
|
||||
0x0C07: "de", # German (Austria)
|
||||
0x0807: "de", # German (Switzerland)
|
||||
0x040C: "fr", # French (France)
|
||||
0x080C: "fr", # French (Belgium)
|
||||
0x0C0C: "fr", # French (Canada)
|
||||
0x100C: "fr", # French (Switzerland)
|
||||
0x0410: "it", # Italian (Italy)
|
||||
0x0810: "it", # Italian (Switzerland)
|
||||
0x0419: "ru", # Russian
|
||||
0x0804: "zh", # Chinese Simplified
|
||||
0x0404: "zh", # Chinese Traditional
|
||||
0x0409: "en", # English (US)
|
||||
0x0809: "en", # English (UK)
|
||||
}
|
||||
lang_code = lcid_map.get(lcid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not lang_code:
|
||||
raw = locale.getdefaultlocale()[0] or ""
|
||||
lang_code = raw.split("_")[0].lower() if raw else None
|
||||
|
||||
if lang_code and lang_code in self.BUILTIN_LANGUAGES:
|
||||
return lang_code
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Language auto-detection failed: {e}")
|
||||
|
||||
return "en"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton and public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_translator = Translator()
|
||||
|
||||
|
||||
def _ensure_initialized() -> None:
|
||||
"""Initialize translator lazily with default settings if needed."""
|
||||
if _translator._translations_dir is not None: # type: ignore[attr-defined]
|
||||
return
|
||||
_translator.initialize("en", get_translations_dir())
|
||||
|
||||
|
||||
def initialize(language: str, translations_dir: Path) -> None:
|
||||
"""Initialize the global translator.
|
||||
|
||||
Should be called **once at application startup**, before any UI is shown.
|
||||
|
||||
Args:
|
||||
language: Language code (e.g. ``"de"``) or ``"auto"`` for system
|
||||
locale detection.
|
||||
translations_dir: Directory containing the ``.json`` translation files.
|
||||
"""
|
||||
_translator.initialize(language, translations_dir)
|
||||
|
||||
|
||||
def tr(key: str, **kwargs: str) -> str:
|
||||
"""Translate a string by key.
|
||||
|
||||
Args:
|
||||
key: Translation key (e.g. ``"toolbar.home"``).
|
||||
**kwargs: Named format arguments (e.g. ``name="file.pdf"``).
|
||||
|
||||
Returns:
|
||||
Translated string with any format substitutions applied.
|
||||
"""
|
||||
_ensure_initialized()
|
||||
text = _translator.tr(key, **kwargs)
|
||||
|
||||
# If lookup failed and translator points to a non-default directory (e.g. tests
|
||||
# overriding translator state), retry from default bundled translations.
|
||||
if text == key:
|
||||
default_dir = get_translations_dir()
|
||||
current_dir = _translator._translations_dir # type: ignore[attr-defined]
|
||||
if current_dir != default_dir:
|
||||
_translator.initialize("en", default_dir)
|
||||
text = _translator.tr(key, **kwargs)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def get_current_language() -> str:
|
||||
"""Return the currently active language code (e.g. ``"de"``)."""
|
||||
return _translator.get_current_language()
|
||||
|
||||
|
||||
def get_available_languages() -> Dict[str, str]:
|
||||
"""Return all available languages as ``{code: display_name}``."""
|
||||
_ensure_initialized()
|
||||
return _translator.get_available_languages()
|
||||
|
||||
|
||||
def get_translations_dir() -> Path:
|
||||
"""Resolve the translations directory for the current runtime context.
|
||||
|
||||
Handles development mode, PyInstaller bundles, and MSI installations
|
||||
by searching the known candidate paths in order.
|
||||
|
||||
Returns:
|
||||
Path to the ``resources/translations`` directory.
|
||||
"""
|
||||
if hasattr(sys, "_MEIPASS"):
|
||||
# PyInstaller bundle
|
||||
return Path(sys._MEIPASS) / "resources" / "translations" # type: ignore[attr-defined]
|
||||
# Development mode or installed Python package
|
||||
return Path(__file__).parent.parent.parent.parent / "resources" / "translations"
|
||||
60
tests/unit/test_i18n.py
Normal file
60
tests/unit/test_i18n.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue