feat(i18n): add Czech (cs) locale support

Add a complete Czech (cs) locale to hermes-webui: full key parity with the
English reference (1642/1642 keys), real Czech translations for all string and
function-valued keys, Slavic plural helpers for tool/worklog summaries, the cs
login-screen locale in api/routes.py, and a dedicated tests/test_czech_locale.py
parity+placeholder+diacritics guard mirroring the other per-locale tests. All
15 locale-count assertions bumped 14->15.

Co-authored-by: ostravajih <ostravajih@users.noreply.github.com>
This commit is contained in:
ostravajih
2026-07-06 20:52:23 +00:00
committed by nesquena-hermes
parent bb0439dcfa
commit 6da81f7a30
18 changed files with 1920 additions and 22 deletions
+9
View File
@@ -9204,6 +9204,15 @@ _LOGIN_LOCALE = {
"invalid_pw": "M\u1eadt kh\u1ea9u kh\u00f4ng h\u1ee3p l\u1ec7",
"conn_failed": "K\u1ebft n\u1ed1i th\u1ea5t b\u1ea1i",
},
"cs": {
"lang": "cs-CZ",
"title": "P\u0159ihl\u00e1sit se",
"subtitle": "Zadejte heslo pro pokra\u010dov\u00e1n\u00ed",
"placeholder": "Heslo",
"btn": "P\u0159ihl\u00e1sit se",
"invalid_pw": "Neplatn\u00e9 heslo",
"conn_failed": "P\u0159ipojen\u00ed selhalo",
},
}
+1692
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 (14 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode:")
assert count == 14, (
assert count == 15, (
f"Expected 14 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 (14 total)."""
src = I18N.read_text(encoding="utf-8")
count = src.count("open_in_vscode_failed:")
assert count == 14, (
assert count == 15, (
f"Expected 14 open_in_vscode_failed: entries (one per locale), found {count}"
)
@@ -78,7 +78,7 @@ def test_busy_placeholder_helper_preserves_compression_and_drafts():
def test_locale_blocks_cover_new_keys():
locale_blocks = I18N_JS.count("settings_default_message_mode_steer")
assert locale_blocks == 14
assert locale_blocks == 15
for key in [
"settings_label_busy_placeholder_hint",
"settings_desc_busy_placeholder_hint",
+1 -1
View File
@@ -330,7 +330,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 == 14, (
assert count == 15, (
f"i18n key '{key}' found {count} times — expected 14 (one per locale)"
)
+197
View File
@@ -0,0 +1,197 @@
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_czech_locale_block_exists():
src = read(REPO / "static" / "i18n.js")
cs_block = extract_locale_block(src, "cs")
assert cs_block
assert "_lang: 'cs'" in cs_block
assert "_label: 'Čeština'" in cs_block
assert "_speech: 'cs-CZ'" in cs_block
def test_czech_locale_includes_representative_translations():
src = read(REPO / "static" / "i18n.js")
cs_block = extract_locale_block(src, "cs")
expected = [
"settings_title: 'Nastavení'",
"settings_label_language: 'Jazyk'",
"login_title: 'Přihlášení'",
"approval_heading: 'Požadováno schválení'",
"tab_tasks: 'Úkoly'",
"tab_profiles: 'Profily'",
"empty_title: 'Jak vám mohu pomoci?'",
"onboarding_title: 'Vítejte v Hermes Web UI'",
]
for entry in expected:
assert entry in cs_block, f"missing expected Czech translation: {entry}"
def test_czech_locale_matches_english_key_coverage():
src = read(REPO / "static" / "i18n.js")
en_keys = set(locale_keys(src, "en"))
cs_keys = set(locale_keys(src, "cs"))
assert sorted(en_keys - cs_keys) == []
assert sorted(cs_keys - en_keys) == []
def test_czech_locale_has_no_duplicate_keys():
src = read(REPO / "static" / "i18n.js")
keys = locale_keys(src, "cs")
duplicates = sorted(k for k, count in Counter(keys).items() if count > 1)
assert not duplicates, f"Czech locale has duplicate keys: {duplicates}"
def test_czech_locale_keys_use_standard_indentation():
src = read(REPO / "static" / "i18n.js")
cs_block = extract_locale_block(src, "cs")
# Enforce strict 4-space indentation for keys.
badly_indented = []
for line in cs_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_czech_locale_arrow_function_values_mirror_english():
src = read(REPO / "static" / "i18n.js")
en_block = extract_locale_block(src, "en")
cs_block = extract_locale_block(src, "cs")
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*=>")
helper_re = re.compile(r"^_i18n[A-Za-z]+$")
def callable_values(block):
out = set()
for k, v in value_re.findall(block):
v = v.strip()
if arrow_re.match(v) or helper_re.match(v):
out.add(k)
return out
# Every key whose English value is a function must also be a function in cs
# (either an arrow or a named _i18n* helper reference).
assert callable_values(en_block) == callable_values(cs_block)
def test_czech_locale_preserves_placeholder_patterns():
src = read(REPO / "static" / "i18n.js")
en_block = extract_locale_block(src, "en")
cs_block = extract_locale_block(src, "cs")
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)
cs_kv = kv(cs_block)
for key, en_val in en_kv.items():
if key not in cs_kv:
continue
en_vars = sorted(placeholder_re.findall(en_val))
cs_vars = sorted(placeholder_re.findall(cs_kv[key]))
# Skip arrow functions which might contain conditional logic and thus more template vars.
if "=>" not in cs_kv[key]:
if en_vars or cs_vars:
assert cs_vars == en_vars, f"Key '{key}' placeholder mismatch in cs locale"
def test_czech_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")
cs_block = extract_locale_block(src, "cs")
for bad in ("\\\\u2026", "\\\\u2192", "\\\\u2713"):
assert bad not in cs_block, f"Czech locale must not contain {bad!r}"
def test_czech_locale_uses_real_utf8_diacritics():
"""Czech uses á č ď é ě í ň ó ř š ť ú ů ý ž — confirm the block carries real
UTF-8 diacritics, not ASCII-only text (which would mean nothing was translated)."""
src = read(REPO / "static" / "i18n.js")
cs_block = extract_locale_block(src, "cs")
diacritics = "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ"
assert any(ch in cs_block for ch in diacritics), "Czech locale has no diacritics"
@@ -123,7 +123,7 @@ class TestComposerVoiceButtonI18n:
"voice_mode_toggle_active",
)
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl", "vi")
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl", "vi", "cs")
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", "pl", "vi")
LOCALES = ("en", "fr", "it", "ja", "ru", "es", "de", "zh", "zh-Hant", "pt", "ko", "tr", "pl", "vi", "cs")
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:") == 14
assert src.count("usage_cached_percent:") == 14
assert src.count("usage_cache_hit_detail:") == 15
assert src.count("usage_cached_percent:") == 15
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:") == 14
assert js.count("settings_desc_hide_suggestions:") == 14
assert js.count("settings_label_hide_suggestions:") == 15
assert js.count("settings_desc_hide_suggestions:") == 15
changelog = CHANGELOG.read_text(encoding="utf-8")
assert "#2679" in changelog
assert "hide_empty_state_suggestions" in changelog
+1 -1
View File
@@ -6,7 +6,7 @@ PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
STYLE_CSS = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
LOCALE_COUNT = 14 # en, it, ja, ru, es, de, zh, zh-TW, pt, ko, fr, tr, pl
LOCALE_COUNT = 15 # en, it, ja, ru, es, de, zh, zh-Hant, pt, ko, fr, tr, pl, vi, cs
def test_help_nav_button_present():
+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 = 14
LOCALE_COUNT = 15
def test_raw_audio_active_recording_uses_dedicated_i18n_key():
@@ -76,8 +76,8 @@ def test_panels_round_trip_and_hot_apply_virtualize_toggle():
def test_virtualize_toggle_i18n_all_locales():
js = I18N.read_text(encoding="utf-8")
assert js.count("settings_label_virtualize_transcript:") == 14
assert js.count("settings_desc_virtualize_transcript:") == 14
assert js.count("settings_label_virtualize_transcript:") == 15
assert js.count("settings_desc_virtualize_transcript:") == 15
# ── #4343 force-off-for-everyone migration (load_settings behavior) ──────────
+1 -1
View File
@@ -86,4 +86,4 @@ def test_i18n_no_git_key_all_locales():
# Count occurrences of the new key (should be exactly 13, one per locale)
count = content.count("settings_update_no_git")
assert count == 14, f"Expected exactly 14 settings_update_no_git keys, found {count}"
assert count == 15, f"Expected exactly 15 settings_update_no_git keys, found {count}"
@@ -130,7 +130,7 @@ def test_i18n_new_keys_in_all_locales():
]
for key in required:
count = I18N_JS.count(f"{key}:")
assert count == 14, f"Key '{key}' appears {count} times, expected 14 (one per locale)"
assert count == 15, f"Key '{key}' appears {count} times, expected 15 (one per locale)"
def test_backend_already_accepts_new_fields():
+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:") == 14
assert js.count("settings_desc_rtl:") == 14
assert js.count("settings_label_rtl:") == 15
assert js.count("settings_desc_rtl:") == 15
+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:") == 14, "12 locales expected"
assert js.count("settings_desc_quota_chip:") == 14, "12 locales expected"
assert js.count("settings_label_quota_chip:") == 15, "12 locales expected"
assert js.count("settings_desc_quota_chip:") == 15, "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") == 14
assert I18N_JS.count("verdigris)") + I18N_JS.count("verdigris") == 15
+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/") == 14
assert I18N_JS.count("/zeus/") == 15