mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
chore(i18n): translation-bundle build script + W9e phase report
scripts/i18n/build-locale.py walks messages/en.json and emits the four
AI-initial locale bundles (FR / ES / IT / PL) via a layered translation
strategy: full-path overrides for highly contextual strings, a
common-vocab lookup keyed on the lowercase EN value, placeholder
protection that splits {interpolation} tokens and <tag> markers out
before translation and re-glues them verbatim afterwards, and a
fallback to the English value for long-tail prose the script can't
cover. Re-running the script on a fresh en.json keeps the four
locale bundles in parity automatically — handy when new keys land
between releases.
The phase report (.planning/phase-W9e-v1425-translations-report.md)
captures the per-locale vocabulary calls (Apple-Health / Withings
register checks for each language), the system-prompt routing
decision (EN body + REPLY LANGUAGE footer rather than four native
~500-line bodies, to preserve every safety contract verbatim), and
the deferred polish for v1.4.26 (hand-review prose for the highest-
traffic surfaces, native FR/ES/IT/PL prefs strings in
buildPrefsPrefix, locale-native date formats).
This commit is contained in:
1 parent
15d91834c7
commit
5d5a934774
2 files changed
+597
No files matched your search
@@ -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 `<MaintainershipBanner>` 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/<locale>.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 `<tag>`
|
||||
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 `<MaintainershipBanner>` 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).
|
||||
@@ -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}, <tag>) 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())
|
||||
Reference in new issue
Block a user