"""Lightweight i18n — French and German translations, browser-language detection.""" from __future__ import annotations from flask import request, session, g # ── Supported languages ─────────────────────────────────────────────────────── LANGUAGES = { "en": "English", "fr": "Français", "de": "Deutsch", } FALLBACK = "en" # ── Translations ────────────────────────────────────────────────────────────── # Keys are English strings; values are dicts of language → translated string. # Only non-English translations are stored; English is the key itself. TRANSLATIONS: dict[str, dict[str, str]] = { # ── base.html ── "Meal Tracker": { "fr": "Suivi des Repas", "de": "Mahlzeiten-Tracker", }, "admin": { "fr": "administrateur", "de": "Administrator", }, "Logout": { "fr": "Déconnexion", "de": "Abmelden", }, # ── login.html ── "Log In": { "fr": "Se connecter", "de": "Anmelden", }, "Username": { "fr": "Nom d'utilisateur", "de": "Benutzername", }, "Password": { "fr": "Mot de passe", "de": "Passwort", }, "Don't have an account?": { "fr": "Pas de compte ?", "de": "Kein Konto?", }, "Register": { "fr": "S'inscrire", "de": "Registrieren", }, # ── register.html ── "Create Account": { "fr": "Créer un compte", "de": "Konto erstellen", }, "Confirm Password": { "fr": "Confirmer le mot de passe", "de": "Passwort bestätigen", }, "Household": { "fr": "Foyer", "de": "Haushalt", }, "Join an existing household": { "fr": "Rejoindre un foyer existant", "de": "Einem bestehenden Haushalt beitreten", }, "— Select —": { "fr": "— Sélectionner —", "de": "— Auswählen —", }, "No households yet. Create one below!": { "fr": "Pas encore de foyer. Créez-en un ci-dessous !", "de": "Noch keine Haushalte. Erstellen Sie unten einen!", }, "Create a new household": { "fr": "Créer un nouveau foyer", "de": "Neuen Haushalt erstellen", }, "Household name (e.g. The Smiths)": { "fr": "Nom du foyer (ex. Les Dupont)", "de": "Haushaltsname (z.B. Familie Müller)", }, "You'll be the admin of this household.": { "fr": "Vous serez l'administrateur de ce foyer.", "de": "Sie werden der Administrator dieses Haushalts sein.", }, "Already have an account?": { "fr": "Vous avez déjà un compte ?", "de": "Haben Sie bereits ein Konto?", }, "Log in": { "fr": "Se connecter", "de": "Anmelden", }, # ── dashboard.html ── "Today's Meals": { "fr": "Repas du jour", "de": "Heutige Mahlzeiten", }, "History — {date}": { "fr": "Historique — {date}", "de": "Verlauf — {date}", }, "◀ Prev": { "fr": "◀ Préc.", "de": "◀ Zurück", }, "Next ▶": { "fr": "Suiv. ▶", "de": "Weiter ▶", }, "Today": { "fr": "Aujourd'hui", "de": "Heute", }, "No registered users yet. Ask household members to register!": { "fr": "Pas encore d'utilisateurs. Invitez les membres du foyer à s'inscrire !", "de": "Noch keine registrierten Benutzer. Bitten Sie Haushaltsmitglieder, sich zu registrieren!", }, "User": { "fr": "Utilisateur", "de": "Benutzer", }, "Lunch": { "fr": "Déjeuner", "de": "Mittagessen", }, "Dinner": { "fr": "Dîner", "de": "Abendessen", }, "you": { "fr": "vous", "de": "du", }, "Home": { "fr": "Présent", "de": "Zuhause", }, "Out": { "fr": "Absent", "de": "Auswärts", }, "—": { "fr": "—", "de": "—", }, "Remove": { "fr": "Retirer", "de": "Entfernen", }, "Changed mind at {time}": { "fr": "A changé d'avis à {time}", "de": "Meinung geändert um {time}", }, "⚙ Account Settings": { "fr": "⚙ Paramètres du compte", "de": "⚙ Kontoeinstellungen", }, "Delete Household": { "fr": "Supprimer le foyer", "de": "Haushalt löschen", }, "This will remove all users and all data in this household.": { "fr": "Cela supprimera tous les utilisateurs et toutes les données de ce foyer.", "de": "Dies entfernt alle Benutzer und alle Daten in diesem Haushalt.", }, # ── JS confirm strings ── "Delete YOUR account? This cannot be undone.": { "fr": "Supprimer VOTRE compte ? Cette action est irréversible.", "de": "IHR Konto löschen? Dies kann nicht rückgängig gemacht werden.", }, "Remove {name} from this household?": { "fr": "Retirer {name} de ce foyer ?", "de": "{name} aus diesem Haushalt entfernen?", }, "Delete the ENTIRE household and ALL members? This cannot be undone.": { "fr": "Supprimer le foyer ENTIER et TOUS ses membres ? Cette action est irréversible.", "de": "Den GESAMTEN Haushalt und ALLE Mitglieder löschen? Dies kann nicht rückgängig gemacht werden.", }, # ── Page titles ── "Login — Meal Tracker": { "fr": "Connexion — Suivi des Repas", "de": "Anmeldung — Mahlzeiten-Tracker", }, "Register — Meal Tracker": { "fr": "Inscription — Suivi des Repas", "de": "Registrierung — Mahlzeiten-Tracker", }, "Dashboard — Meal Tracker": { "fr": "Tableau de bord — Suivi des Repas", "de": "Übersicht — Mahlzeiten-Tracker", }, # ── Flash / error messages ── "Username and password are required.": { "fr": "Le nom d'utilisateur et le mot de passe sont requis.", "de": "Benutzername und Passwort sind erforderlich.", }, "Invalid username or password.": { "fr": "Nom d'utilisateur ou mot de passe invalide.", "de": "Ungültiger Benutzername oder Passwort.", }, "Username must be at least 2 characters.": { "fr": "Le nom d'utilisateur doit comporter au moins 2 caractères.", "de": "Der Benutzername muss mindestens 2 Zeichen lang sein.", }, "Password must be at least 4 characters.": { "fr": "Le mot de passe doit comporter au moins 4 caractères.", "de": "Das Passwort muss mindestens 4 Zeichen lang sein.", }, "Passwords do not match.": { "fr": "Les mots de passe ne correspondent pas.", "de": "Die Passwörter stimmen nicht überein.", }, "Username already taken.": { "fr": "Ce nom d'utilisateur est déjà pris.", "de": "Dieser Benutzername ist bereits vergeben.", }, "Household name is required.": { "fr": "Le nom du foyer est requis.", "de": "Der Haushaltsname ist erforderlich.", }, "A household with that name already exists.": { "fr": "Un foyer avec ce nom existe déjà.", "de": "Ein Haushalt mit diesem Namen existiert bereits.", }, "Please select a household.": { "fr": "Veuillez sélectionner un foyer.", "de": "Bitte wählen Sie einen Haushalt aus.", }, "Selected household does not exist.": { "fr": "Le foyer sélectionné n'existe pas.", "de": "Der ausgewählte Haushalt existiert nicht.", }, "Account created! Welcome to your household.": { "fr": "Compte créé ! Bienvenue dans votre foyer.", "de": "Konto erstellt! Willkommen in Ihrem Haushalt.", }, "Invalid meal type.": { "fr": "Type de repas invalide.", "de": "Ungültige Mahlzeitart.", }, "Invalid status.": { "fr": "Statut invalide.", "de": "Ungültiger Status.", }, "Invalid date.": { "fr": "Date invalide.", "de": "Ungültiges Datum.", }, "Cannot change responses for past dates.": { "fr": "Impossible de modifier les réponses pour les dates passées.", "de": "Antworten für vergangene Daten können nicht geändert werden.", }, "Meal period not found.": { "fr": "Période de repas introuvable.", "de": "Mahlzeitenzeitraum nicht gefunden.", }, "Invalid date format. Use YYYY-MM-DD.": { "fr": "Format de date invalide. Utilisez AAAA-MM-JJ.", "de": "Ungültiges Datumsformat. Verwenden Sie JJJJ-MM-TT.", }, "Incorrect password. Account not deleted.": { "fr": "Mot de passe incorrect. Compte non supprimé.", "de": "Falsches Passwort. Konto nicht gelöscht.", }, "Your account and household have been deleted.": { "fr": "Votre compte et votre foyer ont été supprimés.", "de": "Ihr Konto und Ihr Haushalt wurden gelöscht.", }, "Your account has been deleted.": { "fr": "Votre compte a été supprimé.", "de": "Ihr Konto wurde gelöscht.", }, "Only admins can remove users.": { "fr": "Seuls les administrateurs peuvent retirer des utilisateurs.", "de": "Nur Administratoren können Benutzer entfernen.", }, "User not found in your household.": { "fr": "Utilisateur introuvable dans votre foyer.", "de": "Benutzer nicht in Ihrem Haushalt gefunden.", }, "Cannot remove the admin. Transfer admin role first or delete the household.": { "fr": "Impossible de retirer l'administrateur. Transférez d'abord le rôle admin ou supprimez le foyer.", "de": "Der Administrator kann nicht entfernt werden. Übertragen Sie zuerst die Admin-Rolle oder löschen Sie den Haushalt.", }, "User '{name}' has been removed.": { "fr": "L'utilisateur '{name}' a été retiré.", "de": "Benutzer '{name}' wurde entfernt.", }, "Only admins can delete the household.": { "fr": "Seuls les administrateurs peuvent supprimer le foyer.", "de": "Nur Administratoren können den Haushalt löschen.", }, "Incorrect password. Household not deleted.": { "fr": "Mot de passe incorrect. Foyer non supprimé.", "de": "Falsches Passwort. Haushalt nicht gelöscht.", }, "Household and all members have been deleted.": { "fr": "Le foyer et tous ses membres ont été supprimés.", "de": "Haushalt und alle Mitglieder wurden gelöscht.", }, # ── Ntfy notifications ── "Ntfy Notifications": { "fr": "Notifications Ntfy", "de": "Ntfy-Benachrichtigungen", }, "Get meal reminders on your phone via ntfy.": { "fr": "Recevez des rappels de repas sur votre téléphone via ntfy.", "de": "Erhalten Sie Essenserinnerungen auf Ihrem Handy via ntfy.", }, "Ntfy Topic URL": { "fr": "URL du sujet Ntfy", "de": "Ntfy-Themen-URL", }, "Access Token": { "fr": "Jeton d'accès", "de": "Zugriffstoken", }, "(optional)": { "fr": "(optionnel)", "de": "(optional)", }, "Save": { "fr": "Enregistrer", "de": "Speichern", }, "Send Test": { "fr": "Tester", "de": "Test senden", }, "Ntfy settings saved.": { "fr": "Paramètres Ntfy enregistrés.", "de": "Ntfy-Einstellungen gespeichert.", }, "Please enter an ntfy URL first.": { "fr": "Veuillez d'abord entrer une URL ntfy.", "de": "Bitte geben Sie zuerst eine ntfy-URL ein.", }, "Test notification sent! Check your device.": { "fr": "Notification de test envoyée ! Vérifiez votre appareil.", "de": "Testbenachrichtigung gesendet! Überprüfen Sie Ihr Gerät.", }, "Failed to send test notification. Check your ntfy URL and token.": { "fr": "Échec de l'envoi du test. Vérifiez votre URL ntfy et votre jeton.", "de": "Testbenachrichtigung fehlgeschlagen. Überprüfen Sie Ihre ntfy-URL und Ihr Token.", }, # ── Timezone ── "Household Timezone": { "fr": "Fuseau horaire du foyer", "de": "Zeitzone des Haushalts", }, "Used to send reminders at 10:00 (lunch) and 16:00 (dinner) local time.": { "fr": "Utilisé pour envoyer les rappels à 10h00 (déjeuner) et 16h00 (dîner) heure locale.", "de": "Wird verwendet, um Erinnerungen um 10:00 (Mittagessen) und 16:00 (Abendessen) Ortszeit zu senden.", }, "Only admins can change the timezone.": { "fr": "Seuls les administrateurs peuvent changer le fuseau horaire.", "de": "Nur Administratoren können die Zeitzone ändern.", }, "Please select a timezone.": { "fr": "Veuillez sélectionner un fuseau horaire.", "de": "Bitte wählen Sie eine Zeitzone aus.", }, "Timezone updated.": { "fr": "Fuseau horaire mis à jour.", "de": "Zeitzone aktualisiert.", }, # ── Public sharing ── "Public Sharing Link": { "fr": "Lien de partage public", "de": "Öffentlicher Freigabelink", }, "Share a read-only view of your household's meal status. The link is hard to guess — only people with it can see the data.": { "fr": "Partagez une vue en lecture seule du statut des repas de votre foyer. Le lien est difficile à deviner — seules les personnes qui le possèdent peuvent voir les données.", "de": "Teilen Sie eine schreibgeschützte Ansicht des Essensstatus Ihres Haushalts. Der Link ist schwer zu erraten — nur Personen mit dem Link können die Daten sehen.", }, "Copy": { "fr": "Copier", "de": "Kopieren", }, "Regenerate": { "fr": "Régénérer", "de": "Neu generieren", }, "Disable": { "fr": "Désactiver", "de": "Deaktivieren", }, "Enable": { "fr": "Activer", "de": "Aktivieren", }, "Public link is currently disabled.": { "fr": "Le lien public est actuellement désactivé.", "de": "Der öffentliche Link ist derzeit deaktiviert.", }, "No public link yet. Generate one to share.": { "fr": "Pas encore de lien public. Générez-en un pour partager.", "de": "Noch kein öffentlicher Link. Generieren Sie einen zum Teilen.", }, "Generate Link": { "fr": "Générer un lien", "de": "Link generieren", }, "Public link generated!": { "fr": "Lien public généré !", "de": "Öffentlicher Link generiert!", }, "Public link disabled.": { "fr": "Lien public désactivé.", "de": "Öffentlicher Link deaktiviert.", }, "Public link enabled.": { "fr": "Lien public activé.", "de": "Öffentlicher Link aktiviert.", }, "Only admins can manage the public link.": { "fr": "Seuls les administrateurs peuvent gérer le lien public.", "de": "Nur Administratoren können den öffentlichen Link verwalten.", }, "Unknown action.": { "fr": "Action inconnue.", "de": "Unbekannte Aktion.", }, "Link Not Found": { "fr": "Lien introuvable", "de": "Link nicht gefunden", }, "This sharing link is invalid or has been disabled. Please ask the household admin for a new link.": { "fr": "Ce lien de partage est invalide ou a été désactivé. Veuillez demander un nouveau lien à l'administrateur du foyer.", "de": "Dieser Freigabelink ist ungültig oder wurde deaktiviert. Bitte fragen Sie den Haushaltsadministrator nach einem neuen Link.", }, "Not Found — Meal Tracker": { "fr": "Introuvable — Suivi des Repas", "de": "Nicht gefunden — Mahlzeiten-Tracker", }, "No users yet.": { "fr": "Pas encore d'utilisateurs.", "de": "Noch keine Benutzer.", }, "Copied!": { "fr": "Copié !", "de": "Kopiert!", }, # ── Password change ── "👤 Account Settings": { "fr": "👤 Paramètres du compte", "de": "👤 Kontoeinstellungen", }, "🏠 Household Settings": { "fr": "🏠 Paramètres du foyer", "de": "🏠 Haushaltseinstellungen", }, "Change Password": { "fr": "Changer le mot de passe", "de": "Passwort ändern", }, "Current password": { "fr": "Mot de passe actuel", "de": "Aktuelles Passwort", }, "New password": { "fr": "Nouveau mot de passe", "de": "Neues Passwort", }, "Confirm new password": { "fr": "Confirmer le nouveau mot de passe", "de": "Neues Passwort bestätigen", }, "Password changed successfully.": { "fr": "Mot de passe changé avec succès.", "de": "Passwort erfolgreich geändert.", }, "Current password is incorrect.": { "fr": "Le mot de passe actuel est incorrect.", "de": "Das aktuelle Passwort ist falsch.", }, "New password must be at least 4 characters.": { "fr": "Le nouveau mot de passe doit comporter au moins 4 caractères.", "de": "Das neue Passwort muss mindestens 4 Zeichen lang sein.", }, "New passwords do not match.": { "fr": "Les nouveaux mots de passe ne correspondent pas.", "de": "Die neuen Passwörter stimmen nicht überein.", }, "This will permanently delete your account and all your responses.": { "fr": "Cela supprimera définitivement votre compte et toutes vos réponses.", "de": "Dies löscht Ihr Konto und alle Ihre Antworten dauerhaft.", }, "Delete My Account": { "fr": "Supprimer mon compte", "de": "Mein Konto löschen", }, "Enter your password to confirm": { "fr": "Entrez votre mot de passe pour confirmer", "de": "Geben Sie Ihr Passwort zur Bestätigung ein", }, } # ── Helpers ─────────────────────────────────────────────────────────────────── def parse_accept_language(header: str | None) -> str: """Parse Accept-Language header and return the best supported language code.""" if not header: return FALLBACK best = FALLBACK best_quality = 0.0 for part in header.split(","): chunk = part.strip().split(";") tag = chunk[0].strip().split("-")[0].lower() # "fr-FR" → "fr" quality = 1.0 if len(chunk) > 1 and chunk[1].strip().startswith("q="): try: quality = float(chunk[1].strip()[2:]) except ValueError: pass if tag in LANGUAGES and quality > best_quality: best_quality = quality best = tag return best def get_lang() -> str: """Return current language code (from session, header, or fallback).""" # Session override (user manually selected a language) lang = session.get("lang") if lang and lang in LANGUAGES: return lang # Parse Accept-Language header lang = parse_accept_language(request.headers.get("Accept-Language", "")) session["lang"] = lang return lang def t(key: str, **kwargs) -> str: """Translate a key into the current language. Usage in templates: {{ t('Log In') }} With placeholders: {{ t("User '{name}' has been removed.", name=username) }} """ lang = g.get("lang") or session.get("lang") or FALLBACK if lang == FALLBACK: result = key else: result = TRANSLATIONS.get(key, {}).get(lang, key) if kwargs: result = result.format(**kwargs) return result def init_i18n(app): """Register before_request handler and template globals on a Flask app.""" @app.before_request def _set_lang(): # Allow ?lang= override in query string qs_lang = request.args.get("lang") if qs_lang and qs_lang in LANGUAGES: session["lang"] = qs_lang g.lang = get_lang() @app.context_processor def _inject_i18n(): return { "t": t, "lang": g.get("lang", FALLBACK), "lang_name": LANGUAGES.get(g.get("lang", FALLBACK), "English"), }