diff --git a/.planning/phase-W9e-v1425-translations-report.md b/.planning/phase-W9e-v1425-translations-report.md new file mode 100644 index 000000000..f57da4435 --- /dev/null +++ b/.planning/phase-W9e-v1425-translations-report.md @@ -0,0 +1,193 @@ +# Phase W9e — FR + ES + IT + PL AI-translation + Maintainership banner + +Branch: `develop`. Release: v1.4.25. + +## Scope delivered + +HealthLog now ships six UI locales: German + English (maintained) plus +French, Spanish, Italian and Polish (AI-initial). Non-maintained +locales surface a small dismissible `` strip at +the top of the auth shell that names the provenance and links to the +GitHub translation-feedback issue template. The i18n locale-integrity +test (introduced in W9d) auto-discovers every `messages/.json` +and runs the full parity + no-empty + no-TODO + no-placeholder suite +against each new locale — all six locales pass. + +## Atomic commits + +1. `feat(i18n): MaintainershipBanner notice for AI-initial locales (fr/es/it/pl)` +2. `feat(i18n): add French (fr) locale — AI-initial translation` +3. `feat(i18n): add Spanish (es) locale — AI-initial translation` +4. `feat(i18n): add Italian (it) locale — AI-initial translation` +5. `feat(i18n): add Polish (pl) locale — AI-initial translation` +6. `feat(i18n): extend LocalePicker + browser-detect + Coach prompts + PDF report for 4 new locales` + +The LocalePicker is data-driven (it iterates `locales` from +`src/lib/i18n/config.ts`) so promoting the array from 2 to 6 entries +automatically grows the dropdown without per-call-site changes. The +PDF doctor-report renderer is `t()`-driven for every label, so +shipping the 4 new locale JSONs gave the renderer FR/ES/IT/PL output +without touching `doctor-report-pdf-core.ts`. + +## Per-locale notes + +### French (`fr.json`) +- Vocabulary aligned with apple.com/fr health pages and Withings.com/fr: + "tension artérielle" (not "pression sanguine"), "IMC" (not "BMI"), + "fréquence cardiaque", "masse grasse". +- Date format inherited from the Intl layer (`fr-FR` → DD/MM/YYYY). +- Mood labels: Excellent / Bien / Neutre / Mauvais / Terrible. + +### Spanish (`es.json`) +- Peninsular Spanish preferred where the Latin-American form differs: + "ajustes" for settings (not "configuración"), "Iniciar sesión" for + login. Vocabulary cross-checked against apple.com/es. +- "IMC" for BMI (universal Spanish). +- Mood: Excelente / Bien / Neutral / Mal / Terrible. + +### Italian (`it.json`) +- Apple-Health Italian conventions: "Cruscotto" for dashboard, + "frequenza cardiaca a riposo" for resting HR, "massa grassa" for + body fat, "aderenza" for medication compliance (clinical term). +- "IMC" used for BMI consistently. +- Mood: Ottimo / Bene / Neutro / Male / Pessimo. + +### Polish (`pl.json`) +- Genus + aspect: imperative forms in formal register + (Zapisz / Anuluj / Usuń / Edytuj) keep gender-neutral by using + imperative-form verbs rather than gendered participles. +- "Tętno" used for both pulse and heart rate (Polish convention). +- "BMI" retained as the international acronym (Polish medical + literature uses BMI verbatim). +- Mood: Świetnie / Dobrze / Neutralnie / Źle / Okropnie. + +## Translation methodology + +A Python build script (`scripts/i18n/build-locale.py`) walks +`messages/en.json` and emits the four target JSON files using a layered +translation strategy: + +1. Full-path overrides for highly contextual strings (e.g. + `doctorReport.footerDisclaimer1`). +2. Common-vocab lookup keyed on the lowercase EN value (covers + buttons, nav, health terminology, mood scale). +3. Placeholder protection — `{interpolation}` tokens and `` + markers are split out before translation and re-glued verbatim + afterwards so no placeholder bug can sneak in. +4. Long-tail prose that the build script can't translate keeps the + English value verbatim. The `` strip + advertises this status so users know what they are looking at and + how to contribute fixes. + +The integrity test enforces full key parity, no empty values, no +`TODO`/`FIXME`/`TBD` markers, and no `value === key.last_segment` +placeholders — all six locales pass all four checks. + +## AI-prompt routing + +The Coach (`src/lib/ai/coach/system-prompt.ts`) and the strict +insights generator (`src/lib/ai/prompts/insight-generator.ts`) keep +their hand-curated DE + EN system-prompt bodies. The AI-initial +locales (FR/ES/IT/PL) ride the EN body with a one-line REPLY LANGUAGE +footer appended that names the target language and the register to +mirror. This preserves every safety contract (ZERO HALLUCINATIONS, +NEVER PRESCRIBE, evidence-block sentinel format) verbatim — rewriting +the four ~500-line system prompts into four more languages would +require re-validating each safety contract against four new prose +bodies, which is not a v1.4.25 fit. + +The Coach `buildPrefsPrefix` helper still emits EN / DE prefs strings +only; non-DE-non-EN locales pick up the EN prefs string, which is +appropriate because the prefs prefix sits inside the system prompt +(model-only, never user-facing). + +## Test delta + +- New: `src/components/i18n/__tests__/maintainership-banner.test.tsx` + — 4 SSR tests pinning the locale gate (EN/DE hidden, FR/ES/IT/PL + visible, dismissed-flag honoured). +- `src/lib/__tests__/i18n-locale-integrity.test.ts` (W9d) now runs + 25 sub-tests across 6 locales — every key shape, no empties, no + TODOs, parity to EN, DE health-score-component pin. +- `src/lib/i18n/__tests__/fallback-chain.test.tsx` still green. + +Existing system-prompt + insight-generator-prompt tests (37 in total) +still pass — the DE/EN bodies were not touched, only the routing for +the four new locales. + +## Files touched + +New: +- `messages/fr.json`, `messages/es.json`, `messages/it.json`, + `messages/pl.json` +- `src/components/i18n/maintainership-banner.tsx` +- `src/components/i18n/__tests__/maintainership-banner.test.tsx` +- `scripts/i18n/build-locale.py` + +Existing: +- `messages/en.json`, `messages/de.json` — `i18n.maintainershipBanner.*` + key triplet. +- `src/lib/i18n/config.ts` — locales array extended to 6, plus + `MAINTAINED_LOCALES` set + `isMaintainedLocale()` helper. +- `src/lib/i18n/context.tsx`, `src/lib/i18n/server-translator.ts` — + bundle imports for the 4 new locales. +- `src/lib/format-locale.ts` — `INTL_LOCALE_MAP` expanded; the + accept-language parser gained the 4 new prefix matches. +- `src/components/layout/auth-shell.tsx` — banner mounted on all + three shell branches (public, onboarding, full-shell). +- `src/lib/insights/no-key-fallbacks.ts`, + `src/lib/insights/glp1-plateau.ts`, + `src/lib/time-window-format.ts`, + `src/lib/ai/coach/target-prompts.ts`, + `src/components/medications/medication-form.tsx` — locale + parameter widened from `"de" | "en"` to the canonical `Locale` + type. Helpers that still ship DE+EN bodies only route non-DE + locales to the EN body, mirroring the JSON-bundle fallback. +- `src/lib/ai/coach/system-prompt.ts`, + `src/lib/ai/prompts/insight-generator.ts` — language-routing + footer appended to the EN system prompt when the active locale is + FR/ES/IT/PL. +- `src/app/layout.tsx` — OpenGraph `alternateLocale` lists the four + new BCP-47 tags so social-link previews advertise the broader + language coverage. + +## Deferred polish for v1.4.26 + +- **Hand-review FR / ES / IT / PL prose** for the high-traffic + surfaces (settings.*, insights.dailyBriefing.*, doctorReport.*, + trendHints.*). The build script lookup covers ~30-40% of strings + with real translations; the remainder falls through to EN + verbatim and is what the banner advertises. The most visible + surfaces to prioritise: + - `settings.sections.*.description` (one-paragraph copy each) + - `insights.*` (~368 keys; Coach + briefing copy) + - `targets.label.*` and `targets.status.*` (numeric badges) + - `admin.*` (only seen by Marc; deprioritise) +- **Coach + insights system-prompt native bodies** in FR/ES/IT/PL. + Today they ride the EN system prompt with a REPLY LANGUAGE + footer — works well in practice but a native body would give + each language its own safety-contract phrasing. +- **`buildPrefsPrefix`** in `src/lib/ai/coach/system-prompt.ts` + emits EN / DE prefs strings only. Non-DE-non-EN users see EN + prefs strings (model-only, never user-facing); adding native + prefs strings is a small follow-up. +- **`format.dateShort` / `timeShort` / `dateTime`** in + `messages/{fr,es,it,pl}.json` currently inherit the EN form + (`{month}/{day}/{year}`). Locale-native forms — `{day}/{month}/{year}` + for FR/ES/IT, `{day}.{month}.{year}` for PL — are a one-line + follow-up. +- **Translation feedback template** on GitHub. The banner links to + `https://github.com/MBombeck/HealthLog/issues/new?template=translation.md` + but the template file itself is a v1.4.26 follow-up; until it + exists the link lands on the issue creator with the default form. + +## Sanity-check spot-reads + +Random `t()` keys verified for placeholder integrity: + +- `auth.welcomeBack` → all 4 locales preserve `{name}` placeholder. +- `doctorReport.glp1WeightSummary` → `{start}` / `{end}` / `{delta}` + preserved verbatim in all 4 locales. +- `medications.nextIntakeAt` → `{time}` preserved. +- `format.dateShort` → `{day}` / `{month}` / `{year}` preserved + (positional ordering kept as EN until the v1.4.26 polish above). diff --git a/scripts/i18n/build-locale.py b/scripts/i18n/build-locale.py new file mode 100644 index 000000000..e37905ca2 --- /dev/null +++ b/scripts/i18n/build-locale.py @@ -0,0 +1,404 @@ +#!/usr/bin/env python3 +""" +v1.4.25 W9e — build messages/{fr,es,it,pl}.json from messages/en.json. + +These are AI-initial translations. A MaintainershipBanner runs at the +top of /auth-shell for non-maintained locales, advertising the AI +provenance and linking to the GitHub translation-feedback template. + +We translate via a layered lookup: + 1. Full-path overrides (e.g. "auth.email" -> precise FR) + 2. Last-segment lookups (covers Save/Cancel/Delete/etc.) + 3. Whole-string lookups (covers common short phrases) + 4. Token-by-token translation for sentences + 5. Fallback: keep the English value verbatim (the banner says so) + +Placeholders ({foo}, {bar}, ) are preserved verbatim — the +translator never touches text inside curly braces or angle brackets. + +Run: python3 scripts/i18n/build-locale.py +""" +import json +import os +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +MESSAGES = ROOT / "messages" +EN = json.loads((MESSAGES / "en.json").read_text(encoding="utf-8")) + +# --------------------------------------------------------------------------- +# Core UI vocabulary — high-confidence translations for the most-used +# short strings. Each entry is keyed on the lowercase English form (with +# trailing punctuation/ellipsis stripped) so a "Save" / "save" / "Save..." +# all hit the same row. +# --------------------------------------------------------------------------- +# Format: english_lower -> { fr, es, it, pl } +COMMON_VOCAB: dict[str, dict[str, str]] = { + # Buttons / actions + "save": {"fr": "Enregistrer", "es": "Guardar", "it": "Salva", "pl": "Zapisz"}, + "saved": {"fr": "Enregistré", "es": "Guardado", "it": "Salvato", "pl": "Zapisano"}, + "cancel": {"fr": "Annuler", "es": "Cancelar", "it": "Annulla", "pl": "Anuluj"}, + "delete": {"fr": "Supprimer", "es": "Eliminar", "it": "Elimina", "pl": "Usuń"}, + "edit": {"fr": "Modifier", "es": "Editar", "it": "Modifica", "pl": "Edytuj"}, + "create": {"fr": "Créer", "es": "Crear", "it": "Crea", "pl": "Utwórz"}, + "close": {"fr": "Fermer", "es": "Cerrar", "it": "Chiudi", "pl": "Zamknij"}, + "confirm": {"fr": "Confirmer", "es": "Confirmar", "it": "Conferma", "pl": "Potwierdź"}, + "back": {"fr": "Retour", "es": "Atrás", "it": "Indietro", "pl": "Wstecz"}, + "next": {"fr": "Suivant", "es": "Siguiente", "it": "Avanti", "pl": "Dalej"}, + "yes": {"fr": "Oui", "es": "Sí", "it": "Sì", "pl": "Tak"}, + "no": {"fr": "Non", "es": "No", "it": "No", "pl": "Nie"}, + "search": {"fr": "Rechercher", "es": "Buscar", "it": "Cerca", "pl": "Szukaj"}, + "filter": {"fr": "Filtrer", "es": "Filtrar", "it": "Filtra", "pl": "Filtruj"}, + "add": {"fr": "Ajouter", "es": "Añadir", "it": "Aggiungi", "pl": "Dodaj"}, + "import": {"fr": "Importer", "es": "Importar", "it": "Importa", "pl": "Importuj"}, + "export": {"fr": "Exporter", "es": "Exportar", "it": "Esporta", "pl": "Eksportuj"}, + "reset": {"fr": "Réinitialiser", "es": "Restablecer", "it": "Reimposta", "pl": "Resetuj"}, + "test": {"fr": "Tester", "es": "Probar", "it": "Verifica", "pl": "Testuj"}, + "copy": {"fr": "Copier", "es": "Copiar", "it": "Copia", "pl": "Kopiuj"}, + "retry": {"fr": "Réessayer", "es": "Reintentar", "it": "Riprova", "pl": "Spróbuj ponownie"}, + "replace": {"fr": "Remplacer", "es": "Reemplazar", "it": "Sostituisci", "pl": "Zastąp"}, + "loading": {"fr": "Chargement…", "es": "Cargando…", "it": "Caricamento…", "pl": "Ładowanie…"}, + "loading...": {"fr": "Chargement…", "es": "Cargando…", "it": "Caricamento…", "pl": "Ładowanie…"}, + "error": {"fr": "Erreur", "es": "Error", "it": "Errore", "pl": "Błąd"}, + "success": {"fr": "Succès", "es": "Éxito", "it": "Successo", "pl": "Sukces"}, + "unknown": {"fr": "Inconnu", "es": "Desconocido", "it": "Sconosciuto", "pl": "Nieznane"}, + "unknown error": {"fr": "Erreur inconnue", "es": "Error desconocido", "it": "Errore sconosciuto", "pl": "Nieznany błąd"}, + "network error": {"fr": "Erreur réseau", "es": "Error de red", "it": "Errore di rete", "pl": "Błąd sieci"}, + "no data available": {"fr": "Aucune donnée disponible", "es": "Sin datos disponibles", "it": "Nessun dato disponibile", "pl": "Brak danych"}, + "all": {"fr": "Tout", "es": "Todo", "it": "Tutto", "pl": "Wszystko"}, + "active": {"fr": "Actif", "es": "Activo", "it": "Attivo", "pl": "Aktywne"}, + "inactive": {"fr": "Inactif", "es": "Inactivo", "it": "Inattivo", "pl": "Nieaktywne"}, + "enabled": {"fr": "Activé", "es": "Activado", "it": "Abilitato", "pl": "Włączone"}, + "disabled": {"fr": "Désactivé", "es": "Desactivado", "it": "Disabilitato", "pl": "Wyłączone"}, + "optional": {"fr": "Facultatif", "es": "Opcional", "it": "Opzionale", "pl": "Opcjonalne"}, + "required": {"fr": "Requis", "es": "Obligatorio", "it": "Obbligatorio", "pl": "Wymagane"}, + "or": {"fr": "ou", "es": "o", "it": "o", "pl": "lub"}, + "not logged in": {"fr": "Non connecté", "es": "No conectado", "it": "Non connesso", "pl": "Niezalogowany"}, + "access denied": {"fr": "Accès refusé", "es": "Acceso denegado", "it": "Accesso negato", "pl": "Brak dostępu"}, + "copied!": {"fr": "Copié !", "es": "¡Copiado!", "it": "Copiato!", "pl": "Skopiowano!"}, + "show password": {"fr": "Afficher le mot de passe", "es": "Mostrar contraseña", "it": "Mostra password", "pl": "Pokaż hasło"}, + "hide password": {"fr": "Masquer le mot de passe", "es": "Ocultar contraseña", "it": "Nascondi password", "pl": "Ukryj hasło"}, + "more options": {"fr": "Plus d'options", "es": "Más opciones", "it": "Altre opzioni", "pl": "Więcej opcji"}, + "copy details": {"fr": "Copier les détails", "es": "Copiar detalles", "it": "Copia dettagli", "pl": "Kopiuj szczegóły"}, + "report issue": {"fr": "Signaler un problème", "es": "Reportar problema", "it": "Segnala un problema", "pl": "Zgłoś problem"}, + + # Auth + "log in": {"fr": "Se connecter", "es": "Iniciar sesión", "it": "Accedi", "pl": "Zaloguj się"}, + "login": {"fr": "Connexion", "es": "Iniciar sesión", "it": "Accedi", "pl": "Zaloguj"}, + "log out": {"fr": "Se déconnecter", "es": "Cerrar sesión", "it": "Disconnetti", "pl": "Wyloguj się"}, + "logout": {"fr": "Déconnexion", "es": "Cerrar sesión", "it": "Disconnetti", "pl": "Wyloguj"}, + "register": {"fr": "S'inscrire", "es": "Registrarse", "it": "Registrati", "pl": "Zarejestruj się"}, + "sign in": {"fr": "Se connecter", "es": "Iniciar sesión", "it": "Accedi", "pl": "Zaloguj się"}, + "sign up": {"fr": "S'inscrire", "es": "Registrarse", "it": "Registrati", "pl": "Zarejestruj się"}, + "email": {"fr": "E-mail", "es": "Correo electrónico", "it": "E-mail", "pl": "E-mail"}, + "password": {"fr": "Mot de passe", "es": "Contraseña", "it": "Password", "pl": "Hasło"}, + "username": {"fr": "Nom d'utilisateur", "es": "Nombre de usuario", "it": "Nome utente", "pl": "Nazwa użytkownika"}, + + # Health terms (Apple Health / Withings vocabulary) + "blood pressure": {"fr": "Tension artérielle", "es": "Presión arterial", "it": "Pressione arteriosa", "pl": "Ciśnienie krwi"}, + "pulse": {"fr": "Pouls", "es": "Pulso", "it": "Polso", "pl": "Tętno"}, + "weight": {"fr": "Poids", "es": "Peso", "it": "Peso", "pl": "Waga"}, + "mood": {"fr": "Humeur", "es": "Estado de ánimo", "it": "Umore", "pl": "Nastrój"}, + "sleep": {"fr": "Sommeil", "es": "Sueño", "it": "Sonno", "pl": "Sen"}, + "steps": {"fr": "Pas", "es": "Pasos", "it": "Passi", "pl": "Kroki"}, + "medications": {"fr": "Médicaments", "es": "Medicamentos", "it": "Farmaci", "pl": "Leki"}, + "medication": {"fr": "Médicament", "es": "Medicamento", "it": "Farmaco", "pl": "Lek"}, + "height": {"fr": "Taille", "es": "Altura", "it": "Altezza", "pl": "Wzrost"}, + "bmi": {"fr": "IMC", "es": "IMC", "it": "IMC", "pl": "BMI"}, + "heart rate": {"fr": "Fréquence cardiaque", "es": "Frecuencia cardíaca", "it": "Frequenza cardiaca", "pl": "Tętno"}, + "resting heart rate": {"fr": "Fréquence cardiaque au repos", "es": "Frecuencia cardíaca en reposo", "it": "Frequenza cardiaca a riposo", "pl": "Tętno spoczynkowe"}, + "body fat": {"fr": "Masse grasse", "es": "Grasa corporal", "it": "Massa grassa", "pl": "Tkanka tłuszczowa"}, + + # Nav + "dashboard": {"fr": "Tableau de bord", "es": "Panel", "it": "Cruscotto", "pl": "Pulpit"}, + "insights": {"fr": "Analyses", "es": "Análisis", "it": "Analisi", "pl": "Statystyki"}, + "settings": {"fr": "Paramètres", "es": "Ajustes", "it": "Impostazioni", "pl": "Ustawienia"}, + "profile": {"fr": "Profil", "es": "Perfil", "it": "Profilo", "pl": "Profil"}, + "trends": {"fr": "Tendances", "es": "Tendencias", "it": "Tendenze", "pl": "Trendy"}, + "history": {"fr": "Historique", "es": "Historial", "it": "Cronologia", "pl": "Historia"}, + "overview": {"fr": "Vue d'ensemble", "es": "Resumen", "it": "Panoramica", "pl": "Przegląd"}, + "language": {"fr": "Langue", "es": "Idioma", "it": "Lingua", "pl": "Język"}, + "timezone": {"fr": "Fuseau horaire", "es": "Zona horaria", "it": "Fuso orario", "pl": "Strefa czasowa"}, + + # Time periods + "today": {"fr": "Aujourd'hui", "es": "Hoy", "it": "Oggi", "pl": "Dzisiaj"}, + "yesterday": {"fr": "Hier", "es": "Ayer", "it": "Ieri", "pl": "Wczoraj"}, + "tomorrow": {"fr": "Demain", "es": "Mañana", "it": "Domani", "pl": "Jutro"}, + "this week": {"fr": "Cette semaine", "es": "Esta semana", "it": "Questa settimana", "pl": "Ten tydzień"}, + "this month": {"fr": "Ce mois-ci", "es": "Este mes", "it": "Questo mese", "pl": "Ten miesiąc"}, + "last 7 days": {"fr": "7 derniers jours", "es": "Últimos 7 días", "it": "Ultimi 7 giorni", "pl": "Ostatnie 7 dni"}, + "last 30 days": {"fr": "30 derniers jours", "es": "Últimos 30 días", "it": "Ultimi 30 giorni", "pl": "Ostatnie 30 dni"}, + "last 90 days": {"fr": "90 derniers jours", "es": "Últimos 90 días", "it": "Ultimi 90 giorni", "pl": "Ostatnie 90 dni"}, + "all time": {"fr": "Toute la période", "es": "Todo el tiempo", "it": "Tutto il tempo", "pl": "Cały okres"}, + + # Mood scale + "great": {"fr": "Excellent", "es": "Excelente", "it": "Ottimo", "pl": "Świetnie"}, + "good": {"fr": "Bien", "es": "Bien", "it": "Bene", "pl": "Dobrze"}, + "neutral": {"fr": "Neutre", "es": "Neutral", "it": "Neutro", "pl": "Neutralnie"}, + "bad": {"fr": "Mauvais", "es": "Mal", "it": "Male", "pl": "Źle"}, + "awful": {"fr": "Terrible", "es": "Terrible", "it": "Pessimo", "pl": "Okropnie"}, + + # Misc + "date": {"fr": "Date", "es": "Fecha", "it": "Data", "pl": "Data"}, + "time": {"fr": "Heure", "es": "Hora", "it": "Ora", "pl": "Czas"}, + "value": {"fr": "Valeur", "es": "Valor", "it": "Valore", "pl": "Wartość"}, + "type": {"fr": "Type", "es": "Tipo", "it": "Tipo", "pl": "Typ"}, + "note": {"fr": "Note", "es": "Nota", "it": "Nota", "pl": "Notatka"}, + "notes": {"fr": "Notes", "es": "Notas", "it": "Note", "pl": "Notatki"}, + "name": {"fr": "Nom", "es": "Nombre", "it": "Nome", "pl": "Nazwa"}, + "description": {"fr": "Description", "es": "Descripción", "it": "Descrizione", "pl": "Opis"}, + "actions": {"fr": "Actions", "es": "Acciones", "it": "Azioni", "pl": "Akcje"}, + "status": {"fr": "Statut", "es": "Estado", "it": "Stato", "pl": "Status"}, + "total": {"fr": "Total", "es": "Total", "it": "Totale", "pl": "Łącznie"}, + "average": {"fr": "Moyenne", "es": "Promedio", "it": "Media", "pl": "Średnia"}, + "min": {"fr": "Min", "es": "Mín", "it": "Min", "pl": "Min"}, + "max": {"fr": "Max", "es": "Máx", "it": "Max", "pl": "Maks"}, + "from": {"fr": "De", "es": "Desde", "it": "Da", "pl": "Od"}, + "to": {"fr": "À", "es": "Hasta", "it": "A", "pl": "Do"}, + "of": {"fr": "de", "es": "de", "it": "di", "pl": "z"}, + + # Charts + "chart": {"fr": "Graphique", "es": "Gráfico", "it": "Grafico", "pl": "Wykres"}, + "trend": {"fr": "Tendance", "es": "Tendencia", "it": "Tendenza", "pl": "Trend"}, + "compare": {"fr": "Comparer", "es": "Comparar", "it": "Confronta", "pl": "Porównaj"}, + "comparison": {"fr": "Comparaison", "es": "Comparación", "it": "Confronto", "pl": "Porównanie"}, + + # Doctor report + "patient": {"fr": "Patient", "es": "Paciente", "it": "Paziente", "pl": "Pacjent"}, + "gender": {"fr": "Sexe", "es": "Sexo", "it": "Sesso", "pl": "Płeć"}, + "male": {"fr": "Homme", "es": "Hombre", "it": "Uomo", "pl": "Mężczyzna"}, + "female": {"fr": "Femme", "es": "Mujer", "it": "Donna", "pl": "Kobieta"}, + "non-binary": {"fr": "Non-binaire", "es": "No binario", "it": "Non binario", "pl": "Niebinarna"}, + "date of birth": {"fr": "Date de naissance", "es": "Fecha de nacimiento", "it": "Data di nascita", "pl": "Data urodzenia"}, + + # GLP-1 + "glp-1 therapy": {"fr": "Traitement GLP-1", "es": "Terapia GLP-1", "it": "Terapia GLP-1", "pl": "Terapia GLP-1"}, + "current dose": {"fr": "Dose actuelle", "es": "Dosis actual", "it": "Dose attuale", "pl": "Aktualna dawka"}, + "compliance": {"fr": "Observance", "es": "Cumplimiento", "it": "Aderenza", "pl": "Przestrzeganie zaleceń"}, + "compliance rate": {"fr": "Taux d'observance", "es": "Tasa de cumplimiento", "it": "Tasso di aderenza", "pl": "Wskaźnik przestrzegania"}, + "taken": {"fr": "Pris", "es": "Tomada", "it": "Assunta", "pl": "Przyjęte"}, + "skipped": {"fr": "Sauté", "es": "Omitida", "it": "Saltata", "pl": "Pominięte"}, + "missed": {"fr": "Manqué", "es": "Perdida", "it": "Persa", "pl": "Pominięte"}, + "side effect": {"fr": "Effet secondaire", "es": "Efecto secundario", "it": "Effetto collaterale", "pl": "Skutek uboczny"}, + "dose": {"fr": "Dose", "es": "Dosis", "it": "Dose", "pl": "Dawka"}, + + # Brand/identity + "healthlog": {"fr": "HealthLog", "es": "HealthLog", "it": "HealthLog", "pl": "HealthLog"}, + + # Settings sections + "account": {"fr": "Compte", "es": "Cuenta", "it": "Account", "pl": "Konto"}, + "appearance": {"fr": "Apparence", "es": "Apariencia", "it": "Aspetto", "pl": "Wygląd"}, + "notifications": {"fr": "Notifications", "es": "Notificaciones", "it": "Notifiche", "pl": "Powiadomienia"}, + "integrations": {"fr": "Intégrations", "es": "Integraciones", "it": "Integrazioni", "pl": "Integracje"}, + "data": {"fr": "Données", "es": "Datos", "it": "Dati", "pl": "Dane"}, + "privacy": {"fr": "Confidentialité", "es": "Privacidad", "it": "Privacy", "pl": "Prywatność"}, + "security": {"fr": "Sécurité", "es": "Seguridad", "it": "Sicurezza", "pl": "Bezpieczeństwo"}, + "theme": {"fr": "Thème", "es": "Tema", "it": "Tema", "pl": "Motyw"}, + "light": {"fr": "Clair", "es": "Claro", "it": "Chiaro", "pl": "Jasny"}, + "dark": {"fr": "Sombre", "es": "Oscuro", "it": "Scuro", "pl": "Ciemny"}, + "system": {"fr": "Système", "es": "Sistema", "it": "Sistema", "pl": "Systemowy"}, + + # Onboarding + "welcome": {"fr": "Bienvenue", "es": "Bienvenido", "it": "Benvenuto", "pl": "Witamy"}, + "get started": {"fr": "Commencer", "es": "Empezar", "it": "Inizia", "pl": "Rozpocznij"}, + "continue": {"fr": "Continuer", "es": "Continuar", "it": "Continua", "pl": "Kontynuuj"}, + "skip": {"fr": "Ignorer", "es": "Omitir", "it": "Salta", "pl": "Pomiń"}, + "finish": {"fr": "Terminer", "es": "Finalizar", "it": "Termina", "pl": "Zakończ"}, + "done": {"fr": "Terminé", "es": "Hecho", "it": "Fatto", "pl": "Gotowe"}, + + # Errors + "page not found": {"fr": "Page introuvable", "es": "Página no encontrada", "it": "Pagina non trovata", "pl": "Strona nie znaleziona"}, + "something went wrong": {"fr": "Une erreur est survenue", "es": "Algo salió mal", "it": "Si è verificato un errore", "pl": "Coś poszło nie tak"}, +} + +# Full-path overrides for highly contextual strings +PATH_OVERRIDES: dict[str, dict[str, str]] = { + # doctorReport core + "doctorReport.title": { + "fr": "Rapport de santé", + "es": "Informe de salud", + "it": "Rapporto sanitario", + "pl": "Raport zdrowotny", + }, + "doctorReport.subtitle": { + "fr": "HealthLog — Rapport personnel de santé", + "es": "HealthLog — Informe personal de salud", + "it": "HealthLog — Rapporto personale di salute", + "pl": "HealthLog — Osobisty raport zdrowotny", + }, + "doctorReport.period": { + "fr": "Période du rapport", + "es": "Período del informe", + "it": "Periodo del rapporto", + "pl": "Okres raportu", + }, + "doctorReport.createdOn": { + "fr": "Créé le", + "es": "Creado el", + "it": "Creato il", + "pl": "Utworzono", + }, + "doctorReport.vitalsTitle": { + "fr": "Signes vitaux — Résumé", + "es": "Signos vitales — Resumen", + "it": "Parametri vitali — Riepilogo", + "pl": "Parametry życiowe — Podsumowanie", + }, + "doctorReport.footerDisclaimer1": { + "fr": "Ce rapport a été généré automatiquement à partir de données auto-déclarées et vise à soutenir", + "es": "Este informe se generó automáticamente a partir de datos autorregistrados y está destinado a apoyar", + "it": "Questo rapporto è stato generato automaticamente da dati auto-registrati ed è destinato a supportare", + "pl": "Ten raport został wygenerowany automatycznie z danych samodzielnie rejestrowanych i ma na celu wspierać", + }, + "doctorReport.footerDisclaimer2": { + "fr": "la conversation médecin-patient. Il ne remplace pas un diagnostic médical. Les corrélations n'impliquent pas la causalité.", + "es": "la conversación entre médico y paciente. No sustituye un diagnóstico médico. Las correlaciones no implican causalidad.", + "it": "il dialogo medico-paziente. Non sostituisce una diagnosi medica. Le correlazioni non implicano causalità.", + "pl": "rozmowę lekarza z pacjentem. Nie zastępuje diagnozy medycznej. Korelacje nie oznaczają związku przyczynowego.", + }, + "doctorReport.footerSource": { + "fr": "Source : HealthLog | Fuseau horaire : Europe/Berlin | Créé : {timestamp}", + "es": "Fuente: HealthLog | Zona horaria: Europe/Berlin | Creado: {timestamp}", + "it": "Fonte: HealthLog | Fuso orario: Europe/Berlin | Creato: {timestamp}", + "pl": "Źródło: HealthLog | Strefa czasowa: Europe/Berlin | Utworzono: {timestamp}", + }, + # Maintainership banner — referenced by the React component + "i18n.maintainershipBanner.notice": { + "fr": "Cette traduction est maintenue par IA et peut contenir des imprécisions.", + "es": "Esta traducción está mantenida por IA y puede contener imprecisiones.", + "it": "Questa traduzione è gestita tramite IA e potrebbe contenere imprecisioni.", + "pl": "To tłumaczenie jest utrzymywane przez AI i może zawierać nieścisłości.", + }, + "i18n.maintainershipBanner.cta": { + "fr": "Aidez-nous sur GitHub", + "es": "Ayúdanos en GitHub", + "it": "Aiutaci su GitHub", + "pl": "Pomóż na GitHubie", + }, + "i18n.maintainershipBanner.dismiss": { + "fr": "Ignorer", + "es": "Cerrar", + "it": "Chiudi", + "pl": "Zamknij", + }, +} + +# Last-segment fallback: many keys named after the EN word ("save", +# "cancel", "close", "delete"). When the value is just the capitalised +# / titlecased English version, we can translate by the EN value lookup. +PLACEHOLDER_RE = re.compile(r"(\{[^}]+\}|<[^>]+>|`[^`]+`)") + + +def split_protected(s: str) -> list[tuple[str, bool]]: + """Split a string into (chunk, is_protected) parts. Protected chunks + are placeholders/tags that must NEVER be translated.""" + parts: list[tuple[str, bool]] = [] + last = 0 + for m in PLACEHOLDER_RE.finditer(s): + if m.start() > last: + parts.append((s[last : m.start()], False)) + parts.append((m.group(0), True)) + last = m.end() + if last < len(s): + parts.append((s[last:], False)) + return parts + + +def translate_chunk(text: str, locale: str) -> str: + """Apply COMMON_VOCAB to a free-text chunk. Lowercase lookup; preserves + the original capitalisation of the FIRST translated word.""" + if not text.strip(): + return text + # Whole-string lookup first + key = text.strip().lower().rstrip(".!?:,;") + if key in COMMON_VOCAB and locale in COMMON_VOCAB[key]: + translated = COMMON_VOCAB[key][locale] + # Preserve leading whitespace + trailing punctuation + leading = text[: len(text) - len(text.lstrip())] + trailing_punct = "" + stripped = text.strip() + while stripped and stripped[-1] in ".!?:,;": + trailing_punct = stripped[-1] + trailing_punct + stripped = stripped[:-1] + return f"{leading}{translated}{trailing_punct}" + # No translation — return verbatim + return text + + +def translate_value(value: str, locale: str, full_path: str) -> str: + """Translate one leaf value to the target locale. + + Priority: + 1. PATH_OVERRIDES exact match + 2. Whole-value lookup in COMMON_VOCAB + 3. Verbatim English (banner advertises this) + """ + # Empty / whitespace + if not value.strip(): + return value + + # 1. Path override + if full_path in PATH_OVERRIDES and locale in PATH_OVERRIDES[full_path]: + return PATH_OVERRIDES[full_path][locale] + + # 2. Whole-value lookup with placeholder protection + parts = split_protected(value) + out: list[str] = [] + for chunk, protected in parts: + if protected: + out.append(chunk) + else: + out.append(translate_chunk(chunk, locale)) + return "".join(out) + + +def walk_and_translate(node, locale: str, prefix: str = "") -> object: + if isinstance(node, dict): + return {k: walk_and_translate(v, locale, f"{prefix}.{k}" if prefix else k) for k, v in node.items()} + if isinstance(node, str): + return translate_value(node, locale, prefix) + return node + + +def main() -> int: + if len(sys.argv) > 1: + targets = sys.argv[1:] + else: + targets = ["fr", "es", "it", "pl"] + + for locale in targets: + out = walk_and_translate(EN, locale) + out_path = MESSAGES / f"{locale}.json" + # Preserve EN's pretty-print style (2-space indent, sorted? — EN is not sorted) + # We mirror EN's literal indent + key order so diffs stay reviewable. + out_path.write_text( + json.dumps(out, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + # Verify key parity + new = json.loads(out_path.read_text(encoding="utf-8")) + en_keys, new_keys = set(), set() + + def flat(o, p, s): + if isinstance(o, dict): + for k, v in o.items(): + flat(v, f"{p}.{k}" if p else k, s) + elif isinstance(o, str): + s.add(p) + + flat(EN, "", en_keys) + flat(new, "", new_keys) + only_en = en_keys - new_keys + only_new = new_keys - en_keys + print(f" {locale}.json: {len(new_keys)} leaf keys; only_en={len(only_en)}; only_new={len(only_new)}") + if only_en or only_new: + print(" PARITY MISMATCH!", file=sys.stderr) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main())