From 87e44e0c40e3120e61bcf0ee8b57e59c762d0b5c Mon Sep 17 00:00:00 2001 From: Eliel Sousa Date: Wed, 23 Sep 2026 16:33:25 -0300 Subject: [PATCH] feat(i18n): padroniza pt/pt-br como idioma principal e adiciona seletor de idioma no header de login --- src/omini_rtksync/i18n.py | 18 +++++++----- src/omini_rtksync/render.py | 37 +++++++++++++++++++------ src/omini_rtksync/web.py | 15 ++++++++++ tests/test_web_render.py | 55 ++++++++++++++++++++++--------------- 4 files changed, 87 insertions(+), 38 deletions(-) diff --git a/src/omini_rtksync/i18n.py b/src/omini_rtksync/i18n.py index 860a407..4c262af 100644 --- a/src/omini_rtksync/i18n.py +++ b/src/omini_rtksync/i18n.py @@ -1,7 +1,7 @@ """Internacionalização da interface do painel. -Idioma padrão: inglês. Português e espanhol são opcionais e escolhidos pelo -seletor de bandeiras no topo do painel e no rodapé do login. A escolha é +Idioma padrão: português (pt-br). Inglês e espanhol são opcionais e escolhidos +pelo seletor de bandeiras no topo do painel e no cabeçalho do login. A escolha é persistida em SQLite (ver prefs.py), em cookies e em localStorage/sessionStorage, sobrevivendo a troca de navegador, reinicializações e limpezas de sessão. @@ -9,7 +9,7 @@ sendo carregados dinamicamente na inicialização e sob demanda via `recarrega_traducoes()`. -Chave ausente numa tradução cai para o inglês, nunca para a chave crua. +Chave ausente numa tradução cai para o padrão, nunca para a chave crua. O catálogo é o mesmo texto nos três irmãos. O nome do produto e o do gateway nunca são escritos aqui: entram por interpolação a partir de `identidade.py`, @@ -22,12 +22,12 @@ from .identidade import ROTULOS_DO_PRODUTO, NOME_DO_GATEWAY -DEFAULT_LANGUAGE = "en" +DEFAULT_LANGUAGE = "pt" # Código do idioma -> (rótulo nativo, classe de bandeira do flag-icons) LANGUAGES: Dict[str, tuple] = { - "en": ("English", "fi-us"), "pt": ("Português", "fi-br"), + "en": ("English", "fi-us"), "es": ("Español", "fi-es"), } @@ -79,7 +79,7 @@ def normalize_language(code: str) -> str: def translate(key: str, lang: str = DEFAULT_LANGUAGE, **params) -> str: - """Traduz uma chave, com fallback para inglês e interpolação opcional.""" + """Traduz uma chave, com fallback para o idioma padrão e interpolação opcional.""" lang = normalize_language(lang) # O rótulo do produto vem primeiro: são as poucas chaves que dependem do que # ESTE gateway faz, e elas moram em identidade.py justamente para que o @@ -90,7 +90,11 @@ def translate(key: str, lang: str = DEFAULT_LANGUAGE, **params) -> str: if text is None: text = ROTULOS_DO_PRODUTO.get(DEFAULT_LANGUAGE, {}).get(key) if text is None: - text = TRANSLATIONS.get(DEFAULT_LANGUAGE, {}).get(key, key) + text = TRANSLATIONS.get(DEFAULT_LANGUAGE, {}).get(key) + if text is None: + text = ROTULOS_DO_PRODUTO.get("en", {}).get(key) + if text is None: + text = TRANSLATIONS.get("en", {}).get(key, key) if params: try: return text.format(**params) diff --git a/src/omini_rtksync/render.py b/src/omini_rtksync/render.py index c47d961..084c958 100644 --- a/src/omini_rtksync/render.py +++ b/src/omini_rtksync/render.py @@ -197,7 +197,7 @@ def script_de_idioma() -> str: """.encode("utf-8") @@ -629,12 +648,12 @@ def render_language_switcher(current: str) -> str: for code, (label, flag) in LANGUAGES.items(): active = " active" if code == current else "" items.append( - f'
  • ' + f'
  • ' + f'{esc(label)}
  • ' ) return f""" - """ def metric_card(label: str, value: Any, icon: str, tone: str) -> str: diff --git a/src/omini_rtksync/web.py b/src/omini_rtksync/web.py index 0b37793..f38d967 100644 --- a/src/omini_rtksync/web.py +++ b/src/omini_rtksync/web.py @@ -454,6 +454,21 @@ def respond_html(self, corpo: bytes, status: HTTPStatus = HTTPStatus.OK) -> None self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(corpo))) self.send_header("Cache-Control", "no-store") + query = parse_qs(urlparse(self.path).query) + if "lang" in query and query["lang"]: + lang = normalize_language(query["lang"][0]) + if lang in LANGUAGES: + seguro = self.eh_conexao_segura() + s = "; Secure" if seguro else "" + self.send_header( + "Set-Cookie", + f"rtksync_lang={lang}; Path=/; Max-Age=31536000; SameSite=Lax{s}", + ) + if self.settings and getattr(self, "authenticated_user", None): + try: + set_preference(self.prefs_path(), "language", lang) + except Exception: + pass self.end_headers() self.write_body(corpo) diff --git a/tests/test_web_render.py b/tests/test_web_render.py index 68d0abe..7cb6ecc 100644 --- a/tests/test_web_render.py +++ b/tests/test_web_render.py @@ -29,9 +29,11 @@ def make_conn(provider: str, name: str, data: dict) -> ConnectionRecord: class TestRenderHelpers(unittest.TestCase): def test_duration_formatting(self): - self.assertEqual(render.format_duration(None), "Unlimited / N/A") + self.assertEqual(render.format_duration(None), "Ilimitado / N/A") + self.assertEqual(render.format_duration(None, "en"), "Unlimited / N/A") self.assertEqual(render.format_duration(None, "pt"), "Ilimitado / N/A") - self.assertEqual(render.format_duration(0), "Expired") + self.assertEqual(render.format_duration(0), "Expirado") + self.assertEqual(render.format_duration(0, "en"), "Expired") self.assertEqual(render.format_duration(-5, "es"), "Expirado") self.assertEqual(render.format_duration(45), "45s") self.assertEqual(render.format_duration(1440), "24 min") @@ -49,7 +51,8 @@ def test_refresh_reason_explains_why_nothing_was_renewed(self): "refreshToken": "ref", "expiresAt": now_ms + (24 * 60 * 1000), }) - self.assertIn("Outside the 15 min margin", render.render_refresh_reason(conn, 900)) + self.assertIn("Fora da margem de 15 min", render.render_refresh_reason(conn, 900)) + self.assertIn("Outside the 15 min margin", render.render_refresh_reason(conn, 900, "en")) reason_pt = render.render_refresh_reason(conn, refresh_margin=900, lang="pt") self.assertIn("Fora da margem de 15 min", reason_pt) self.assertIn("renovação prevista", reason_pt) @@ -60,7 +63,8 @@ def test_refresh_reason_inside_margin(self): "accessToken": "tok", "refreshToken": "ref", "expiresAt": now_ms + (5 * 60 * 1000), }) - self.assertIn("Within the 15 min margin", render.render_refresh_reason(conn, 900)) + self.assertIn("Dentro da margem", render.render_refresh_reason(conn, 900)) + self.assertIn("Within the 15 min margin", render.render_refresh_reason(conn, 900, "en")) self.assertIn("Dentro da margem", render.render_refresh_reason(conn, 900, "pt")) def test_refresh_reason_for_expired_and_apikey(self): @@ -68,10 +72,12 @@ def test_refresh_reason_for_expired_and_apikey(self): expired = make_conn("antigravity", "AG", { "accessToken": "t", "refreshToken": "r", "expiresAt": now_ms - 1000, }) - self.assertIn("expired", render.render_refresh_reason(expired, 900).lower()) + self.assertIn("expirad", render.render_refresh_reason(expired, 900).lower()) + self.assertIn("expired", render.render_refresh_reason(expired, 900, "en").lower()) apikey = make_conn("groq", "Groq", {"apiKey": "gsk-xxx"}) - self.assertIn("never expires", render.render_refresh_reason(apikey, 900)) + self.assertIn("não expira", render.render_refresh_reason(apikey, 900)) + self.assertIn("never expires", render.render_refresh_reason(apikey, 900, "en")) self.assertIn("não expira", render.render_refresh_reason(apikey, 900, "pt")) def test_local_instance_reason_reports_models(self): @@ -82,7 +88,8 @@ def test_local_instance_reason_reports_models(self): "discoveredModels": ["llama3.2:3b", "qwen2.5-coder:7b"], }) self.assertTrue(local.is_local) - self.assertIn("2 model(s)", render.render_refresh_reason(local, 900)) + self.assertIn("2 modelo(s)", render.render_refresh_reason(local, 900)) + self.assertIn("2 model(s)", render.render_refresh_reason(local, 900, "en")) def test_unreachable_local_instance_is_reported(self): # Quem diz que a instancia nao respondeu e a sonda, gravada em @@ -93,7 +100,8 @@ def test_unreachable_local_instance_is_reported(self): "apiKey": "k", "baseUrl": "http://localhost:11434/v1", "testStatus": "unreachable", }) - self.assertIn("did not answer", render.render_refresh_reason(local, 900)) + self.assertIn("não respondeu", render.render_refresh_reason(local, 900)) + self.assertIn("did not answer", render.render_refresh_reason(local, 900, "en")) def test_an_empty_catalog_is_not_called_unreachable(self): local = make_conn("ollama-local", "Ollama Local", { @@ -101,8 +109,9 @@ def test_an_empty_catalog_is_not_called_unreachable(self): "testStatus": "active", "discoveredModels": [], }) frase = render.render_refresh_reason(local, 900) - self.assertNotIn("did not answer", frase) - self.assertIn("no model installed", frase) + self.assertNotIn("não respondeu", frase) + self.assertIn("nenhum modelo instalado", frase) + self.assertIn("no model installed", render.render_refresh_reason(local, 900, "en")) class TestDashboardMarkup(unittest.TestCase): @@ -171,19 +180,20 @@ def test_refresh_controls_are_present(self): self.assertIn('action="/acoes/testar-gateway"', page) def test_security_banner_appears_only_with_default_password(self): - self.assertIn("Security warning", self._page(is_default_password=True)) - self.assertNotIn("Security warning", self._page(is_default_password=False)) + self.assertIn("Atenção de segurança", self._page(is_default_password=True)) + self.assertNotIn("Atenção de segurança", self._page(is_default_password=False)) + self.assertIn("Security warning", self._page(is_default_password=True, lang="en")) - def test_default_language_is_english_with_pt_and_es_available(self): + def test_default_language_is_portuguese_with_en_and_es_available(self): page = self._page() - self.assertIn('lang="en"', page) - self.assertIn("Monitored connections", page) + self.assertIn('lang="pt"', page) + self.assertIn("Conexões monitoradas", page) self.assertIn("flag-icons", page) - for flag in ("fi-us", "fi-br", "fi-es"): + for flag in ("fi-br", "fi-us", "fi-es"): self.assertIn(flag, page) - def test_page_renders_in_portuguese_and_spanish(self): - self.assertIn("Conexões monitoradas", self._page(lang="pt")) + def test_page_renders_in_english_and_spanish(self): + self.assertIn("Monitored connections", self._page(lang="en")) self.assertIn("Conexiones monitoreadas", self._page(lang="es")) def test_local_connection_shows_base_url_and_models(self): @@ -236,10 +246,11 @@ def test_injection_in_connection_name_is_escaped(self): def test_empty_state_renders(self): page = self._page(connections=[], combos=[]) - self.assertIn("No connection registered", page) - self.assertIn("No fallback combo", page) - page_pt = self._page(connections=[], combos=[], lang="pt") - self.assertIn("Nenhuma conexão registrada", page_pt) + self.assertIn("Nenhuma conexão registrada", page) + self.assertIn("Nenhum combo de fallback", page) + page_en = self._page(connections=[], combos=[], lang="en") + self.assertIn("No connection registered", page_en) + self.assertIn("No fallback combo", page_en) class TestDashboardOverHttp(unittest.TestCase):