Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions src/omini_rtksync/i18n.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
"""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.

Os catálogos de tradução ficam armazenados em arquivos JSON em `locales/*.json`,
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`,
Expand All @@ -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"),
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
37 changes: 28 additions & 9 deletions src/omini_rtksync/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ def script_de_idioma() -> str:
<script>
(function() {
try {
var salvo = localStorage.getItem('rtksync_lang') || sessionStorage.getItem('rtksync_lang');
var salvo = sessionStorage.getItem('rtksync_lang') || localStorage.getItem('rtksync_lang');
var atual = document.documentElement.lang;
if (salvo && ['en', 'pt', 'es'].indexOf(salvo) !== -1) {
document.cookie = 'rtksync_lang=' + encodeURIComponent(salvo) + '; path=/; max-age=31536000; SameSite=Lax';
Expand All @@ -211,8 +211,8 @@ def script_de_idioma() -> str:
})();
function setRtksyncLang(lang) {
try {
localStorage.setItem('rtksync_lang', lang);
sessionStorage.setItem('rtksync_lang', lang);
localStorage.setItem('rtksync_lang', lang);
document.cookie = 'rtksync_lang=' + encodeURIComponent(lang) + '; path=/; max-age=31536000; SameSite=Lax';
} catch(e) {}
var u = new URL(window.location.href);
Expand Down Expand Up @@ -585,16 +585,34 @@ def render_login_page(
.form-control {{ background: var(--bg); border-color: var(--line); color: var(--text); }}
.form-control:focus {{ background: var(--bg); color: var(--text);
border-color: var(--accent); box-shadow: 0 0 0 .2rem color-mix(in srgb, var(--accent) 25%, transparent); }}
.barra-acoes {{ display: flex; align-items: center; gap: .5rem; }}
.barra-acoes .btn {{ height: 2rem; padding: 0 .65rem; display: inline-flex; align-items: center; }}
.dropdown-menu {{ background: var(--surface); border: 1px solid var(--line); }}
.dropdown-item {{ color: var(--text); }}
.dropdown-item:hover, .dropdown-item:focus {{ background: var(--surface-2); color: var(--text); }}
.dropdown-item.active {{ background: var(--accent); color: var(--bg); }}
</style>
</head>
<body class="d-flex flex-column min-vh-100 justify-content-between">
<header class="d-flex align-items-center justify-content-between px-3 px-sm-4 py-3 border-bottom" style="border-color: var(--line) !important;">
<div class="d-flex align-items-center gap-3">
<span class="brand-mark"><i class="bi {ICONE_DO_PRODUTO}" aria-hidden="true"></i></span>
<div>
<h1 class="h5 mb-0 fw-bold">{NOME_DO_PRODUTO}</h1>
<span class="text-secondary small font-monospace">{NOME_DO_GATEWAY}</span>
</div>
</div>
<div class="barra-acoes">
{render_language_switcher(lang)}
</div>
</header>
<div class="flex-grow-1 d-flex align-items-center justify-content-center p-3">
<main class="card shadow-sm" style="max-width:26rem;width:100%">
<div class="card-body p-4">
<div class="d-flex align-items-center gap-3 mb-3">
<span class="brand-mark"><i class="bi {ICONE_DO_PRODUTO}" aria-hidden="true"></i></span>
<div>
<h1 class="h5 mb-0 fw-bold">{NOME_DO_PRODUTO}</h1>
<h2 class="h5 mb-0 fw-bold">{NOME_DO_PRODUTO}</h2>
<span class="text-secondary small font-monospace">{NOME_DO_GATEWAY}</span>
</div>
</div>
Expand All @@ -606,7 +624,8 @@ def render_login_page(
</div>
</main>
</div>
{rodape_da_pathbit(lang, com_seletor=True)}
{rodape_da_pathbit(lang, com_seletor=False)}
<script src="{BOOTSTRAP_JS}"></script>
</body>
</html>""".encode("utf-8")

Expand All @@ -629,20 +648,20 @@ def render_language_switcher(current: str) -> str:
for code, (label, flag) in LANGUAGES.items():
active = " active" if code == current else ""
items.append(
f'<li><button class="dropdown-item d-flex align-items-center gap-2{active}" '
f'type="submit" name="lang" value="{esc(code)}" onclick="setRtksyncLang(\'{esc(code)}\')">'
f'<span class="fi {esc(flag)}"></span>{esc(label)}</button></li>'
f'<li><a class="dropdown-item d-flex align-items-center gap-2{active}" '
f'href="?lang={esc(code)}" onclick="setRtksyncLang(\'{esc(code)}\'); return false;">'
f'<span class="fi {esc(flag)}"></span>{esc(label)}</a></li>'
)
return f"""
<form method="post" action="/acoes/idioma" class="m-0 dropdown">
<div class="dropdown m-0">
<button class="btn btn-outline-light btn-sm dropdown-toggle d-inline-flex align-items-center gap-2"
type="button" data-bs-toggle="dropdown" aria-expanded="false"
aria-label="{esc(translate('language.label', current))}">
<span class="fi {esc(current_flag)}"></span>
</button>
<ul class="dropdown-menu dropdown-menu-end">{"".join(items)}
</ul>
</form>"""
</div>"""


def metric_card(label: str, value: Any, icon: str, tone: str) -> str:
Expand Down
15 changes: 15 additions & 0 deletions src/omini_rtksync/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Comment thread
logins-pathbit marked this conversation as resolved.
pass
self.end_headers()
self.write_body(corpo)

Expand Down
55 changes: 33 additions & 22 deletions tests/test_web_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -60,18 +63,21 @@ 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):
now_ms = int(time.time() * 1000)
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):
Expand All @@ -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
Expand All @@ -93,16 +100,18 @@ 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", {
"apiKey": "k", "baseUrl": "http://localhost:11434/v1",
"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):
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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):
Expand Down
Loading