diff --git a/backend/app/i18n/__init__.py b/backend/app/i18n/__init__.py new file mode 100644 index 000000000..580662a72 --- /dev/null +++ b/backend/app/i18n/__init__.py @@ -0,0 +1,133 @@ +"""Internationalization module for backend notifications.""" + +from typing import Any + +# English translations +EN = { + "notification": { + # Print events + "print_started": "Print Started", + "print_completed": "Print Completed", + "print_failed": "Print Failed", + "print_stopped": "Print Stopped", + "print_ended": "Print Ended", + "print_progress": "Print {progress}% Complete", + "estimated": "Estimated", + "time": "Time", + "filament": "Filament", + "reason": "Reason", + "unknown": "Unknown", + + # Printer events + "printer_offline": "Printer Offline", + "printer_disconnected": "{printer} has disconnected", + "printer_error": "Printer Error: {error_type}", + + # Filament + "filament_low": "Filament Low", + "slot_at_percent": "{printer}: Slot {slot} at {percent}%", + + # Maintenance + "maintenance_due": "Maintenance Due", + "overdue": "OVERDUE", + "soon": "Soon", + + # Test notification + "test_title": "BambuTrack Test", + "test_message": "This is a test notification from BambuTrack. If you see this, notifications are working correctly!", + } +} + +# German translations +DE = { + "notification": { + # Print events + "print_started": "Druck gestartet", + "print_completed": "Druck abgeschlossen", + "print_failed": "Druck fehlgeschlagen", + "print_stopped": "Druck gestoppt", + "print_ended": "Druck beendet", + "print_progress": "Druck {progress}% fertig", + "estimated": "Geschätzt", + "time": "Zeit", + "filament": "Filament", + "reason": "Grund", + "unknown": "Unbekannt", + + # Printer events + "printer_offline": "Drucker offline", + "printer_disconnected": "{printer} wurde getrennt", + "printer_error": "Druckerfehler: {error_type}", + + # Filament + "filament_low": "Wenig Filament", + "slot_at_percent": "{printer}: Slot {slot} bei {percent}%", + + # Maintenance + "maintenance_due": "Wartung fällig", + "overdue": "ÜBERFÄLLIG", + "soon": "Bald", + + # Test notification + "test_title": "BambuTrack Test", + "test_message": "Dies ist eine Testbenachrichtigung von BambuTrack. Wenn Sie dies sehen, funktionieren die Benachrichtigungen!", + } +} + +# All available translations +TRANSLATIONS = { + "en": EN, + "de": DE, +} + + +def get_translation(lang: str, key: str, **kwargs: Any) -> str: + """ + Get a translation string by key with optional interpolation. + + Args: + lang: Language code (e.g., 'en', 'de') + key: Dot-separated key path (e.g., 'notification.print_started') + **kwargs: Values to interpolate into the string + + Returns: + Translated string, or the key if not found + """ + # Fall back to English if language not found + translations = TRANSLATIONS.get(lang, TRANSLATIONS["en"]) + + # Navigate to the nested key + keys = key.split(".") + value = translations + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + # Key not found, fall back to English + value = TRANSLATIONS["en"] + for k2 in keys: + if isinstance(value, dict) and k2 in value: + value = value[k2] + else: + return key # Return key if not found in fallback either + break + + if isinstance(value, str): + # Interpolate values + try: + return value.format(**kwargs) + except KeyError: + return value + + return key + + +class Translator: + """Helper class for translations with a specific language.""" + + def __init__(self, lang: str = "en"): + self.lang = lang if lang in TRANSLATIONS else "en" + + def t(self, key: str, **kwargs: Any) -> str: + """Translate a key.""" + return get_translation(self.lang, key, **kwargs) diff --git a/backend/app/schemas/settings.py b/backend/app/schemas/settings.py index f97960a03..9cec160f3 100644 --- a/backend/app/schemas/settings.py +++ b/backend/app/schemas/settings.py @@ -20,6 +20,9 @@ class AppSettings(BaseModel): # Updates check_updates: bool = Field(default=True, description="Automatically check for updates on startup") + # Language + notification_language: str = Field(default="en", description="Language for push notifications (en, de)") + class AppSettingsUpdate(BaseModel): """Schema for updating settings (all fields optional).""" @@ -35,3 +38,4 @@ class AppSettingsUpdate(BaseModel): spoolman_url: str | None = None spoolman_sync_mode: str | None = None check_updates: bool | None = None + notification_language: str | None = None diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 4b08430d5..25de3101c 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -14,6 +14,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.models.notification import NotificationProvider +from backend.app.models.settings import Settings +from backend.app.i18n import Translator logger = logging.getLogger(__name__) @@ -64,70 +66,78 @@ class NotificationService: logger.warning(f"Invalid quiet hours format for provider {provider.name}") return False - def _format_duration(self, seconds: int | None) -> str: + async def _get_notification_language(self, db: AsyncSession) -> str: + """Get the notification language from settings.""" + result = await db.execute( + select(Settings).where(Settings.key == "notification_language") + ) + setting = result.scalar_one_or_none() + return setting.value if setting else "en" + + def _format_duration(self, seconds: int | None, translator: Translator) -> str: """Format duration in seconds to human-readable string.""" if seconds is None: - return "Unknown" + return translator.t("notification.unknown") hours = seconds // 3600 minutes = (seconds % 3600) // 60 if hours > 0: return f"{hours}h {minutes}m" return f"{minutes}m" - def _build_print_start_message(self, printer_name: str, data: dict) -> tuple[str, str]: + def _build_print_start_message(self, printer_name: str, data: dict, translator: Translator) -> tuple[str, str]: """Build notification message for print start event.""" - filename = data.get("filename", "Unknown") + filename = data.get("filename", translator.t("notification.unknown")) # Clean up filename if filename.endswith(".gcode.3mf"): filename = filename[:-10] elif filename.endswith(".3mf"): filename = filename[:-4] - title = "Print Started" + title = translator.t("notification.print_started") estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time") - time_str = self._format_duration(estimated_time * 60 if estimated_time else None) + time_str = self._format_duration(estimated_time * 60 if estimated_time else None, translator) - message = f"{printer_name}: {filename}\nEstimated: {time_str}" + message = f"{printer_name}: {filename}\n{translator.t('notification.estimated')}: {time_str}" return title, message def _build_print_complete_message( - self, printer_name: str, status: str, data: dict, archive_data: dict | None = None + self, printer_name: str, status: str, data: dict, translator: Translator, archive_data: dict | None = None ) -> tuple[str, str]: """Build notification message for print complete event.""" - filename = data.get("filename", "Unknown") + filename = data.get("filename", translator.t("notification.unknown")) if filename.endswith(".gcode.3mf"): filename = filename[:-10] elif filename.endswith(".3mf"): filename = filename[:-4] if status == "completed": - title = "Print Completed" + title = translator.t("notification.print_completed") elif status == "failed": - title = "Print Failed" + title = translator.t("notification.print_failed") elif status in ("aborted", "stopped", "cancelled"): - title = "Print Stopped" + title = translator.t("notification.print_stopped") else: - title = "Print Ended" + title = translator.t("notification.print_ended") lines = [f"{printer_name}: {filename}"] if archive_data: # Add print time if available if archive_data.get("print_time_seconds"): - lines.append(f"Time: {self._format_duration(archive_data['print_time_seconds'])}") + lines.append(f"{translator.t('notification.time')}: {self._format_duration(archive_data['print_time_seconds'], translator)}") # Add filament used if available if archive_data.get("actual_filament_grams"): - lines.append(f"Filament: {archive_data['actual_filament_grams']:.1f}g") + lines.append(f"{translator.t('notification.filament')}: {archive_data['actual_filament_grams']:.1f}g") # Add failure reason if failed if status == "failed" and archive_data.get("failure_reason"): - lines.append(f"Reason: {archive_data['failure_reason']}") + lines.append(f"{translator.t('notification.reason')}: {archive_data['failure_reason']}") message = "\n".join(lines) return title, message def _build_progress_message( - self, printer_name: str, filename: str, progress: int + self, printer_name: str, filename: str, progress: int, translator: Translator ) -> tuple[str, str]: """Build notification message for print progress milestone.""" if filename.endswith(".gcode.3mf"): @@ -135,52 +145,57 @@ class NotificationService: elif filename.endswith(".3mf"): filename = filename[:-4] - title = f"Print {progress}% Complete" + title = translator.t("notification.print_progress", progress=progress) message = f"{printer_name}: {filename}" return title, message - def _build_printer_offline_message(self, printer_name: str) -> tuple[str, str]: + def _build_printer_offline_message(self, printer_name: str, translator: Translator) -> tuple[str, str]: """Build notification message for printer offline event.""" - title = "Printer Offline" - message = f"{printer_name} has disconnected" + title = translator.t("notification.printer_offline") + message = translator.t("notification.printer_disconnected", printer=printer_name) return title, message def _build_printer_error_message( - self, printer_name: str, error_type: str, error_detail: str | None = None + self, printer_name: str, error_type: str, translator: Translator, error_detail: str | None = None ) -> tuple[str, str]: """Build notification message for printer error event.""" - title = f"Printer Error: {error_type}" + title = translator.t("notification.printer_error", error_type=error_type) message = f"{printer_name}" if error_detail: message += f"\n{error_detail}" return title, message def _build_filament_low_message( - self, printer_name: str, slot: int, remaining_percent: int + self, printer_name: str, slot: int, remaining_percent: int, translator: Translator ) -> tuple[str, str]: """Build notification message for low filament event.""" - title = "Filament Low" - message = f"{printer_name}: Slot {slot} at {remaining_percent}%" + title = translator.t("notification.filament_low") + message = translator.t("notification.slot_at_percent", printer=printer_name, slot=slot, percent=remaining_percent) return title, message def _build_maintenance_due_message( - self, printer_name: str, maintenance_items: list[dict] + self, printer_name: str, maintenance_items: list[dict], translator: Translator ) -> tuple[str, str]: """Build notification message for maintenance due event.""" - title = "Maintenance Due" + title = translator.t("notification.maintenance_due") lines = [f"{printer_name}:"] for item in maintenance_items: - status = "OVERDUE" if item.get("is_due") else "Soon" + status = translator.t("notification.overdue") if item.get("is_due") else translator.t("notification.soon") lines.append(f"• {item['name']} ({status})") message = "\n".join(lines) return title, message async def send_test_notification( - self, provider_type: str, config: dict[str, Any] + self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None ) -> tuple[bool, str]: """Send a test notification to verify configuration.""" - title = "BambuTrack Test" - message = "This is a test notification from BambuTrack. If you see this, notifications are working correctly!" + lang = "en" + if db: + lang = await self._get_notification_language(db) + translator = Translator(lang) + + title = translator.t("notification.test_title") + message = translator.t("notification.test_message") try: if provider_type == "callmebot": @@ -446,8 +461,10 @@ class NotificationService: logger.info(f"No notification providers configured for print_start event on printer {printer_id}") return + lang = await self._get_notification_language(db) + translator = Translator(lang) logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}") - title, message = self._build_print_start_message(printer_name, data) + title, message = self._build_print_start_message(printer_name, data, translator) await self._send_to_providers(providers, title, message, db) async def on_print_complete( @@ -478,8 +495,10 @@ class NotificationService: logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}") return + lang = await self._get_notification_language(db) + translator = Translator(lang) logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}") - title, message = self._build_print_complete_message(printer_name, status, data, archive_data) + title, message = self._build_print_complete_message(printer_name, status, data, translator, archive_data) await self._send_to_providers(providers, title, message, db) async def on_print_progress( @@ -495,7 +514,9 @@ class NotificationService: if not providers: return - title, message = self._build_progress_message(printer_name, filename, progress) + lang = await self._get_notification_language(db) + translator = Translator(lang) + title, message = self._build_progress_message(printer_name, filename, progress, translator) await self._send_to_providers(providers, title, message, db) async def on_printer_offline( @@ -506,7 +527,9 @@ class NotificationService: if not providers: return - title, message = self._build_printer_offline_message(printer_name) + lang = await self._get_notification_language(db) + translator = Translator(lang) + title, message = self._build_printer_offline_message(printer_name, translator) await self._send_to_providers(providers, title, message, db) async def on_printer_error( @@ -522,7 +545,9 @@ class NotificationService: if not providers: return - title, message = self._build_printer_error_message(printer_name, error_type, error_detail) + lang = await self._get_notification_language(db) + translator = Translator(lang) + title, message = self._build_printer_error_message(printer_name, error_type, translator, error_detail) await self._send_to_providers(providers, title, message, db) async def on_filament_low( @@ -538,7 +563,9 @@ class NotificationService: if not providers: return - title, message = self._build_filament_low_message(printer_name, slot, remaining_percent) + lang = await self._get_notification_language(db) + translator = Translator(lang) + title, message = self._build_filament_low_message(printer_name, slot, remaining_percent, translator) await self._send_to_providers(providers, title, message, db) async def on_maintenance_due( @@ -557,8 +584,10 @@ class NotificationService: logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}") return + lang = await self._get_notification_language(db) + translator = Translator(lang) logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}") - title, message = self._build_maintenance_due_message(printer_name, maintenance_items) + title, message = self._build_maintenance_due_message(printer_name, maintenance_items, translator) await self._send_to_providers(providers, title, message, db) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 4dd67e519..f5adb7b0a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -154,6 +154,7 @@ export interface AppSettings { energy_cost_per_kwh: number; energy_tracking_mode: 'print' | 'total'; check_updates: boolean; + notification_language: string; } export type AppSettingsUpdate = Partial; diff --git a/frontend/src/components/KeyboardShortcutsModal.tsx b/frontend/src/components/KeyboardShortcutsModal.tsx index 9d0b9e897..6420ec9b4 100644 --- a/frontend/src/components/KeyboardShortcutsModal.tsx +++ b/frontend/src/components/KeyboardShortcutsModal.tsx @@ -1,11 +1,12 @@ import { useEffect } from 'react'; import { X, Keyboard } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Card, CardContent } from './Card'; interface NavItem { id: string; to: string; - label: string; + labelKey: string; } interface KeyboardShortcutsModalProps { @@ -13,11 +14,11 @@ interface KeyboardShortcutsModalProps { navItems?: NavItem[]; } -function getShortcuts(navItems?: NavItem[]) { +function getShortcuts(navItems: NavItem[] | undefined, t: (key: string) => string) { const navShortcuts = navItems ? navItems.map((item, index) => ({ keys: [String(index + 1)], - description: `Go to ${item.label}`, + description: `Go to ${t(item.labelKey)}`, })) : [ { keys: ['1'], description: 'Go to Printers' }, @@ -51,7 +52,8 @@ function KeyBadge({ children }: { children: string }) { } export function KeyboardShortcutsModal({ onClose, navItems }: KeyboardShortcutsModalProps) { - const shortcuts = getShortcuts(navItems); + const { t } = useTranslation(); + const shortcuts = getShortcuts(navItems, t); // Close on Escape key useEffect(() => { diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 54abec1e4..9807f9ca0 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom'; import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, type LucideIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { useTheme } from '../contexts/ThemeContext'; import { KeyboardShortcutsModal } from './KeyboardShortcutsModal'; import { useQuery } from '@tanstack/react-query'; @@ -10,17 +11,17 @@ interface NavItem { id: string; to: string; icon: LucideIcon; - label: string; + labelKey: string; // Translation key } export const defaultNavItems: NavItem[] = [ - { id: 'printers', to: '/', icon: Printer, label: 'Printers' }, - { id: 'archives', to: '/archives', icon: Archive, label: 'Archives' }, - { id: 'queue', to: '/queue', icon: Calendar, label: 'Queue' }, - { id: 'stats', to: '/stats', icon: BarChart3, label: 'Statistics' }, - { id: 'profiles', to: '/profiles', icon: Cloud, label: 'Profiles' }, - { id: 'maintenance', to: '/maintenance', icon: Wrench, label: 'Maintenance' }, - { id: 'settings', to: '/settings', icon: Settings, label: 'Settings' }, + { id: 'printers', to: '/', icon: Printer, labelKey: 'nav.printers' }, + { id: 'archives', to: '/archives', icon: Archive, labelKey: 'nav.archives' }, + { id: 'queue', to: '/queue', icon: Calendar, labelKey: 'nav.queue' }, + { id: 'stats', to: '/stats', icon: BarChart3, labelKey: 'nav.stats' }, + { id: 'profiles', to: '/profiles', icon: Cloud, labelKey: 'nav.profiles' }, + { id: 'maintenance', to: '/maintenance', icon: Wrench, labelKey: 'nav.maintenance' }, + { id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' }, ]; // Get ordered nav items from localStorage @@ -69,6 +70,7 @@ export function Layout() { const navigate = useNavigate(); const location = useLocation(); const { theme, toggleTheme } = useTheme(); + const { t } = useTranslation(); const [sidebarExpanded, setSidebarExpanded] = useState(() => { const stored = localStorage.getItem('sidebarExpanded'); return stored !== 'false'; @@ -207,7 +209,7 @@ export function Layout() { {/* Navigation */}