Release v0.51.320 — Release KJ (Phase 2: Polish (pl) language support, #3781) (#3796)

Complete Polish locale. #3781 (@leszek3737). Full suite green, CI 11/11, 180 locale/parity tests pass. Co-authored-by: leszek3737 <leszek3737@users.noreply.github.com>
This commit is contained in:
nesquena-hermes
2026-06-07 13:48:52 -07:00
committed by GitHub
parent 2e1aa3c99c
commit 59de540b3d
15 changed files with 1567 additions and 21 deletions
+5
View File
@@ -3,6 +3,11 @@
## [Unreleased]
## [v0.51.320] — 2026-06-07 — Release KJ (Phase 2 — Polish (pl) language support)
### Added
- **Polish (pl) language support.** Adds a complete Polish translation set to the in-app localization, a Polish login-page locale, and resolver coverage for `pl` / `pl-PL` / `pl_PL`, with the locale registered consistently across every key group (appearance skins, approval/clarify prompts, tooltips, cache-usage labels, profile skill counts) and parity tests asserting the new locale is complete and actually translated. (#3781, @leszek3737)
## [v0.51.319] — 2026-06-07 — Release KI (Phase 3 light — refresh stale continuation metadata)
### Fixed
+9
View File
@@ -3561,6 +3561,15 @@ _LOGIN_LOCALE = {
"invalid_pw": "Ge\u00e7ersiz \u015fifre",
"conn_failed": "Ba\u011flant\u0131 ba\u015far\u0131s\u0131z",
},
"pl": {
"lang": "pl-PL",
"title": "Zaloguj si\u0119",
"subtitle": "Wpisz has\u0142o, aby kontynuowa\u0107",
"placeholder": "Has\u0142o",
"btn": "Zaloguj si\u0119",
"invalid_pw": "Nieprawid\u0142owe has\u0142o",
"conn_failed": "Po\u0142\u0105czenie nie powiod\u0142o si\u0119",
},
}
+1329 -1
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -229,7 +229,7 @@ class TestOpenInVsCodeI18n:
"""open_in_vscode key must appear exactly once per locale (11 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode:")
assert count == 11, (
assert count == 12, (
f"Expected 11 open_in_vscode: entries (one per locale), found {count}"
)
@@ -237,7 +237,7 @@ class TestOpenInVsCodeI18n:
"""open_in_vscode_failed key must appear exactly once per locale (11 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode_failed:")
assert count == 11, (
assert count == 12, (
f"Expected 11 open_in_vscode_failed: entries (one per locale), found {count}"
)
+1 -1
View File
@@ -162,7 +162,7 @@ class TestAuxiliaryModelsI18n:
"""Count of each key should equal the number of locales (12 with Turkish)."""
for key in self.REQUIRED_KEYS:
count = I18N_JS.count(f"{key}:")
assert count == 12, (
assert count == 13, (
f"i18n key '{key}' found {count} times — expected 12 (one per locale)"
)
@@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n:
"voice_mode_toggle_active",
)
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr")
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl")
def test_legacy_voice_toggle_key_removed(self):
"""The old key whose string was 'Voice input' caused the duplicate-
@@ -171,7 +171,7 @@ class TestComposerVoiceButtonI18n:
class TestVoiceModePreferenceGate:
"""boot.js must hide btnVoiceMode by default, surface it via Preferences."""
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr")
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl")
def test_voice_mode_pref_is_localstorage_backed(self):
"""The pref reads from localStorage key 'hermes-voice-mode-button'."""
+2 -2
View File
@@ -63,8 +63,8 @@ def test_context_indicator_surfaces_cache_hit_rate():
def test_cache_usage_labels_are_localized():
src = (ROOT / "static" / "i18n.js").read_text()
assert src.count("usage_cache_hit_detail:") == 12
assert src.count("usage_cached_percent:") == 12
assert src.count("usage_cache_hit_detail:") == 13
assert src.count("usage_cached_percent:") == 13
assert "usage_cache_hit_detail: 'Cache: {0}% hit ({1} read / {2} write)'" in src
assert "usage_cached_percent: '{0}% cached'" in src
+2 -2
View File
@@ -50,8 +50,8 @@ def test_panels_round_trip_and_hot_apply_hide_suggestions():
def test_hide_suggestions_i18n_all_locales_and_changelog():
js = I18N.read_text(encoding="utf-8")
assert js.count("settings_label_hide_suggestions:") == 12
assert js.count("settings_desc_hide_suggestions:") == 12
assert js.count("settings_label_hide_suggestions:") == 13
assert js.count("settings_desc_hide_suggestions:") == 13
changelog = CHANGELOG.read_text(encoding="utf-8")
assert "#2679" in changelog
assert "hide_empty_state_suggestions" in changelog
+1 -1
View File
@@ -5,7 +5,7 @@ BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
SESSIONS_JS = (ROOT / "static" / "sessions.js").read_text(encoding="utf-8")
LOCALE_COUNT = 12
LOCALE_COUNT = 13
def test_raw_audio_active_recording_uses_dedicated_i18n_key():
+7 -4
View File
@@ -267,7 +267,7 @@ def test_login_locale_count_matches_or_exceeds_floor():
assert k in login, f"_LOGIN_LOCALE missing core locale {k!r}"
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko", "pl"])
def test_login_locale_entry_well_formed(loc_key: str):
"""Each _LOGIN_LOCALE entry must have all required sub-keys and non-empty string values."""
login = _load_login_locale()
@@ -281,7 +281,7 @@ def test_login_locale_entry_well_formed(loc_key: str):
def test_login_locale_resolver_handles_new_locales():
"""_resolve_login_locale_key() must map ja/pt/ko (and common BCP-47 variants) to their entries."""
"""_resolve_login_locale_key() must map ja/pt/ko/pl (and common BCP-47 variants) to their entries."""
sys.path.insert(0, str(REPO))
from api.routes import _resolve_login_locale_key
@@ -293,6 +293,9 @@ def test_login_locale_resolver_handles_new_locales():
assert _resolve_login_locale_key("pt-PT") == "pt"
assert _resolve_login_locale_key("ko") == "ko"
assert _resolve_login_locale_key("ko-KR") == "ko"
assert _resolve_login_locale_key("pl") == "pl"
assert _resolve_login_locale_key("pl-PL") == "pl"
assert _resolve_login_locale_key("pl_PL") == "pl"
assert _resolve_login_locale_key("fr") == "fr"
assert _resolve_login_locale_key("fr-FR") == "fr"
assert _resolve_login_locale_key("fr-CA") == "fr"
@@ -310,7 +313,7 @@ def _value_of(seg: str, key: str) -> str | None:
return None
@pytest.mark.parametrize("loc_key", ["es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
@pytest.mark.parametrize("loc_key", ["es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko", "pl"])
def test_login_flow_keys_are_translated(loc_key: str):
"""Login/sign-out/password keys in static/i18n.js must NOT equal the English value.
@@ -351,7 +354,7 @@ SESSION_MANAGEMENT_KEYS = (
)
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko"])
@pytest.mark.parametrize("loc_key", ["en", "es", "de", "ru", "zh", "zh-Hant", "ja", "pt", "ko", "pl"])
def test_session_management_keys_present(loc_key: str):
"""Every locale block must define all session-management keys (no fallback to English)."""
seg = _i18n_locale_block(loc_key)
+201
View File
@@ -0,0 +1,201 @@
from collections import Counter
from pathlib import Path
import re
REPO = Path(__file__).resolve().parent.parent
def read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def extract_locale_block(src: str, locale_key: str) -> str:
start_match = re.search(rf"\b{re.escape(locale_key)}\s*:\s*\{{", src)
assert start_match, f"{locale_key} locale block not found"
start = start_match.end() - 1
depth = 0
in_single = False
in_double = False
in_backtick = False
escape = False
for i in range(start, len(src)):
ch = src[i]
if escape:
escape = False
continue
if in_single:
if ch == "\\":
escape = True
elif ch == "'":
in_single = False
continue
if in_double:
if ch == "\\":
escape = True
elif ch == '"':
in_double = False
continue
if in_backtick:
if ch == "\\":
escape = True
elif ch == "`":
in_backtick = False
continue
if ch == "'":
in_single = True
continue
if ch == '"':
in_double = True
continue
if ch == "`":
in_backtick = True
continue
if ch == "{":
depth += 1
continue
if ch == "}":
depth -= 1
if depth == 0:
return src[start + 1 : i]
raise AssertionError(f"{locale_key} locale block braces are not balanced")
def locale_keys(src: str, locale_key: str) -> list[str]:
key_pattern = re.compile(r"^\s*([a-zA-Z0-9_]+)\s*:", re.MULTILINE)
return key_pattern.findall(extract_locale_block(src, locale_key))
def test_polish_locale_block_exists():
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
assert pl_block
assert "_lang: 'pl'" in pl_block
assert "_label: 'Polski'" in pl_block
assert "_speech: 'pl-PL'" in pl_block
def test_polish_locale_includes_representative_translations():
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
expected = [
"settings_title: 'Ustawienia'",
"settings_label_language: 'Język'",
"login_title: 'Zaloguj się'",
"approval_heading: 'Wymagana aprobata'",
"tab_chat: 'Czat'",
"tab_tasks: 'Zadania'",
"tab_profiles: 'Profile'",
"empty_title: 'W czym mogę pomóc?'",
"onboarding_title: 'Witaj w Hermes Web UI'",
]
for entry in expected:
assert entry in pl_block
def test_polish_settings_detail_descriptions_are_translated():
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
expected = [
"settings_desc_workspace_panel_open: 'Gdy ta opcja jest włączona, panel obszaru roboczego / przeglądarki plików otwiera się automatycznie przy każdej nowej sesji. Nadal możesz go zamknąć ręcznie w dowolnym momencie.'",
"settings_desc_notifications: 'Pokaż powiadomienie systemowe, gdy odpowiedź zostanie ukończona, podczas gdy aplikacja działa w tle.'",
"settings_desc_token_usage: 'Wyświetla liczbę tokenów wejściowych/wyjściowych pod każdą odpowiedzią asystenta. Można też przełączyć za pomocą /usage.'",
"settings_desc_sidebar_density: 'Kontroluje, ile metadanych wyświetla lista sesji na lewym pasku bocznym.'",
"settings_desc_auto_title_refresh: 'Automatycznie generuje na nowo tytuł konwersacji na podstawie najnowszej wymiany, utrzymując go adekwatnym w miarę rozwoju rozmowy. Wymaga skonfigurowanego modelu LLM do generowania tytułów.'",
"settings_desc_external_sessions: 'Pokaż konwersacje z CLI, Telegrama, Discorda, Slacka i innych kanałów na liście sesji. Kliknij, aby zaimportować i kontynuować.'",
"settings_desc_cron_sessions: 'Wyświetlaj wyjście zadań cron jako konwersacje na pasku bocznym. Aktywne tylko wtedy, gdy włączone są sesje spoza WebUI. Domyślnie wyłączone; zadania o wysokiej częstotliwości mogą zalać pasek boczny.'",
"settings_desc_sync_insights: 'Odzwierciedla zużycie tokenów WebUI w state.db, dzięki czemu hermes /insights uwzględnia dane sesji przeglądarki. Domyślnie wyłączone.'",
"settings_desc_check_updates: 'Pokaż baner, gdy dostępne są nowsze wersje WebUI lub Agenta. Okresowo uruchamia pobieranie git fetch w tle.'",
"settings_desc_bot_name: 'Używane tylko dla profilu domyślnego. Inne profile używają własnych nazw profilu.'",
"settings_desc_password: 'Wpisz nowe hasło, aby je ustawić lub zmienić. Pozostaw puste, aby zachować obecne ustawienie.'",
]
for entry in expected:
assert entry in pl_block
def test_polish_locale_matches_english_key_coverage():
src = read(REPO / "static" / "i18n.js")
en_keys = set(locale_keys(src, "en"))
pl_keys = set(locale_keys(src, "pl"))
assert sorted(en_keys - pl_keys) == []
assert sorted(pl_keys - en_keys) == []
def test_polish_locale_has_no_duplicate_keys():
src = read(REPO / "static" / "i18n.js")
keys = locale_keys(src, "pl")
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
assert not duplicates, f"Polish locale has duplicate keys: {duplicates}"
def test_polish_locale_keys_use_standard_indentation():
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
# Enforce strict 4-space indentation for keys.
badly_indented = []
for line in pl_block.splitlines():
m = re.match(r"^(\s*)[a-zA-Z0-9_]+\s*:", line)
if m and len(m.group(1)) != 4:
badly_indented.append(f"{len(m.group(1))} spaces: {line.strip()}")
assert badly_indented == []
def test_polish_locale_arrow_function_values_mirror_english():
src = read(REPO / "static" / "i18n.js")
en_block = extract_locale_block(src, "en")
pl_block = extract_locale_block(src, "pl")
value_re = re.compile(r"^\s+([a-zA-Z0-9_]+):\s*(.+?)(?:,\s*$|\s*$)", re.MULTILINE)
arrow_re = re.compile(r"^\s*\(?[a-zA-Z_,\s]*\)?\s*=>")
def arrows(block):
return {k for k, v in value_re.findall(block) if arrow_re.match(v)}
assert arrows(pl_block) == arrows(en_block)
def test_polish_locale_preserves_placeholder_patterns():
src = read(REPO / "static" / "i18n.js")
en_block = extract_locale_block(src, "en")
pl_block = extract_locale_block(src, "pl")
value_re = re.compile(r"^\s+([a-zA-Z0-9_]+):\s*(.+?)(?:,\s*$|\s*$)", re.MULTILINE)
placeholder_re = re.compile(r"\{[0-9]+\}|\$\{[a-zA-Z_][a-zA-Z0-9_]*\}")
def kv(block):
out = {}
for k, v in value_re.findall(block):
out[k] = v
return out
en_kv = kv(en_block)
pl_kv = kv(pl_block)
for key, en_val in en_kv.items():
if key not in pl_kv:
continue
en_vars = sorted(placeholder_re.findall(en_val))
pl_vars = sorted(placeholder_re.findall(pl_kv[key]))
# Skip arrow functions which might contain duplicate conditional logic and thus more template vars
if "=>" not in pl_kv[key]:
if en_vars or pl_vars:
assert pl_vars == en_vars, f"Key '{key}' missing placeholders in pl locale"
def test_polish_locale_has_no_double_escaped_unicode_sequences():
"""JSON-style double escapes (\\\\u2026) render literal backslash-u in the UI."""
src = read(REPO / "static" / "i18n.js")
pl_block = extract_locale_block(src, "pl")
for bad in ("\\\\u2026", "\\\\u2192", "\\\\u2713"):
assert bad not in pl_block, f"Polish locale must not contain {bad!r}"
+2 -2
View File
@@ -112,5 +112,5 @@ def test_rtl_in_config_defaults_and_writable_keys():
def test_rtl_localized_in_all_locales():
js = I18N.read_text(encoding="utf-8")
# Count occurrences — should match the 11 locale blocks
assert js.count("settings_label_rtl:") == 12
assert js.count("settings_desc_rtl:") == 12
assert js.count("settings_label_rtl:") == 13
assert js.count("settings_desc_rtl:") == 13
+2 -2
View File
@@ -88,5 +88,5 @@ def test_quota_chip_panels_round_trip():
def test_quota_chip_localized_in_all_locales():
js = I18N.read_text(encoding="utf-8")
assert js.count("settings_label_quota_chip:") == 12, "12 locales expected"
assert js.count("settings_desc_quota_chip:") == 12, "12 locales expected"
assert js.count("settings_label_quota_chip:") == 13, "12 locales expected"
assert js.count("settings_desc_quota_chip:") == 13, "12 locales expected"
+1 -1
View File
@@ -38,4 +38,4 @@ def test_verdigris_has_no_light_variant():
def test_verdigris_i18n_lists_skin_in_all_locales():
# There are 12 locales; each should now include verdigris as the trailing skin.
# 10 locales use ASCII closing paren, 2 Chinese locales use full-width paren.
assert I18N_JS.count("verdigris)") + I18N_JS.count("verdigris") == 12
assert I18N_JS.count("verdigris)") + I18N_JS.count("verdigris") == 13
+1 -1
View File
@@ -45,4 +45,4 @@ def test_zeus_modals_are_not_navy():
def test_zeus_i18n_lists_skin_in_all_locales():
# There are 12 locales; each should include zeus in the skin list.
assert I18N_JS.count("/zeus/") == 12
assert I18N_JS.count("/zeus/") == 13