Merge pull request #5546: add Czech (cs) locale support
Complete Czech locale (1642/1642 key parity, all function values, login screen, test guard). Rebased + translation-quality fixes by maintainer; original author @ostravajih. Gate: full suite + Codex SAFE TO SHIP.
This commit is contained in:
19 files changed
+1921
-22
No files matched your search
@@ -46,6 +46,7 @@
|
||||
|
||||
### Added
|
||||
|
||||
- **Czech (Čeština) is now a fully-supported UI language.** Added a complete Czech (`cs`) locale to Settings → Language with full key parity to English (all ~1,640 UI strings), real Czech translations for every string and function-valued key (including Slavic plural forms for message/queue counts and tool-activity summaries), a Czech login screen, and a dedicated locale-parity + placeholder + diacritics test guard mirroring the other per-language suites. Thanks @ostravajih. (#5546)
|
||||
- **Fork a scheduled-job (cron) conversation into your own editable chat.** Cron-run sessions are read-only (the scheduler owns them), so you couldn't continue one. You can now branch/fork a cron session into a new WebUI-owned conversation — the original read-only session is never modified, and the fork picks up its history so you can carry on. Only genuine cron sessions qualify (verified by the server-side source, not a guessable id), and every other read-only session stays unbranchable. Thanks @rodboev. (#5555, #5477)
|
||||
- **Pick the exact time and day for scheduled jobs — no cron syntax needed.** The Tasks "New job" form now builds the schedule as a plain sentence: choose a frequency (Hourly / Daily / Weekdays / Weekly / Monthly / Custom) and it shows just the controls that matter — a time picker, a day-of-week dropdown, or a day-of-month dropdown — e.g. "Weekly on Monday at 09:00." The generated cron expression is previewed inline, and the raw cron field now appears only under "Custom." Thanks @rodboev. (#5554, #5552)
|
||||
- **Transparent Stream fades newly streamed words in.** In Transparent Stream mode, newly arriving assistant words now fade in at the prose level as they stream — without animating (or re-flickering) the thinking rows, tool rows, or the transparent event rows. It respects your OS "reduce motion" setting (no fade when reduced motion is on) and is part of the Transparent Stream experience regardless of the "Fade text effect" toggle. Thanks @rodboev. (#5506, #5367)
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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",
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
|
||||
@@ -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'."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
@@ -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) ──────────
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user