What changed, and why it matters
This commit adds a helper script and a rules file for managing translated text in Trezor firmware. The script blanks out translation strings that do not apply to specific hardware layouts (e.g., removing Bluetooth-related text for devices that lack Bluetooth). It is a build/translation tooling change and does not modify device firmware code, cryptographic operations, or security-sensitive logic.
No security action required. Review as normal build/translation tooling if desired.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The commit introduces core/translations/blank_translations.py, a CLI utility that reads locale JSON files (e.g., en_Bolt.json), matches translation keys against exact/prefix/regex rules, and sets values to empty strings for layouts where those keys should not appear. It also adds core/translations/blank_translations_rules.json, which defines layout-specific allow/deny lists for translation keys. The script supports dry-run mode and can export blanked keys for Crowdin. It is purely a translation-management tool.
Changed components
core/translations/blank_translations.pycore/translations/blank_translations_rules.jsonInspect captured patch +387 / −0
diff --git a/core/translations/blank_translations.py b/core/translations/blank_translations.py
new file mode 100644
index 00000000..1d9ed10c
--- /dev/null
+++ b/core/translations/blank_translations.py
@@ -0,0 +1,291 @@
+"""Blank translation values based on layout-specific rules.
+
+Processes locale JSON files (e.g., en_Bolt.json) and blanks translation keys
+that should not appear on certain hardware layouts, as defined by a rules config.
+
+Usage:
+ python blank_translations.py --config rules.json --locales-dir ./locales
+ python blank_translations.py --config rules.json --locales-dir ./locales --dry-run
+"""
+
+import json
+import re
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+import click
+
+
+def load_json(path: Path) -> Dict[str, Any]:
+ with path.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def save_json(path: Path, data: Dict[str, Any]) -> None:
+ with path.open("w", encoding="utf-8") as f:
+ json.dump(data, f, ensure_ascii=False, indent=2)
+ f.write("\n")
+
+
+def infer_layout(file_path: Path) -> str:
+ # Expect pattern <lang>_<Layout>.json
+ stem = file_path.stem # en_Bolt
+ parts = stem.split("_", 1)
+ if len(parts) != 2:
+ raise ValueError(f"Cannot infer layout from filename: {file_path.name}")
+ return parts[1]
+
+
+def compile_rules(raw_rules: List[Dict[str, Any]]):
+ compiled = []
+ for r in raw_rules:
+ kinds = [k for k in ("exact", "prefix", "regex") if k in r]
+ if len(kinds) != 1:
+ raise ValueError(f"Rule must have exactly one of exact/prefix/regex: {r}")
+ matcher_type = kinds[0]
+ raw_pattern = r[matcher_type]
+
+ pattern = None
+ patterns = None # used for exact list-of-keys
+ regex = None
+
+ if matcher_type == "regex":
+ if not isinstance(raw_pattern, str):
+ raise ValueError(f"Regex pattern must be a string: {r}")
+ regex = re.compile(raw_pattern)
+ pattern = raw_pattern
+ elif matcher_type == "prefix":
+ if not isinstance(raw_pattern, str):
+ raise ValueError(f"Prefix must be a string: {r}")
+ pattern = raw_pattern
+ elif matcher_type == "exact":
+ if isinstance(raw_pattern, list):
+ if not raw_pattern:
+ raise ValueError("Exact list cannot be empty.")
+ if not all(isinstance(x, str) for x in raw_pattern):
+ raise ValueError("Exact list must contain only strings.")
+ patterns = set(raw_pattern)
+ elif isinstance(raw_pattern, str):
+ pattern = raw_pattern
+ else:
+ raise ValueError("Exact must be a string or a list of strings.")
+
+ compiled.append(
+ {
+ "type": matcher_type,
+ "pattern": pattern, # string for exact/prefix/regex, None for exact-list
+ "patterns": patterns, # set for exact-list, else None
+ "regex": regex, # compiled for regex, else None
+ "allowed": set(r.get("allowedLayouts", [])),
+ "denied": set(r.get("denyLayouts", [])),
+ "ignore": set(r.get("ignoreLayouts", [])),
+ "stop": r.get("stopAfterMatch", True),
+ "description": r.get("description", ""),
+ }
+ )
+ return compiled
+
+
+def match_rule(key: str, rules):
+ matches = []
+ for rule in rules:
+ t = rule["type"]
+ matched = False
+ if t == "exact":
+ if rule.get("patterns") is not None:
+ matched = key in rule["patterns"]
+ else:
+ matched = key == rule["pattern"]
+ elif t == "prefix":
+ matched = key.startswith(rule["pattern"])
+ elif t == "regex":
+ matched = bool(rule["regex"].search(key))
+
+ if matched:
+ matches.append(rule)
+ if rule["stop"]:
+ break
+ return matches
+
+
+def process_file(file_path: Path, rules, dry_run: bool = False):
+ data = load_json(file_path)
+ if "translations" not in data or not isinstance(data["translations"], dict):
+ raise ValueError(f"No 'translations' object in {file_path}")
+ translations = data["translations"]
+ layout = infer_layout(file_path)
+
+ if not translations:
+ return {
+ "file": file_path.name,
+ "layout": layout,
+ "changed": 0,
+ "total": 0,
+ "blanked": [],
+ }
+
+ changed = 0
+ total = len(translations)
+ blanked_keys: List[str] = []
+ for key, value in list(translations.items()):
+ if not isinstance(value, str):
+ continue
+ matches = match_rule(key, rules)
+ if not matches:
+ continue
+ rule = matches[0]
+ if layout in rule["ignore"]:
+ continue
+ allowed = rule["allowed"]
+ denied = rule["denied"]
+ blank = False
+ if allowed and layout not in allowed:
+ blank = True
+ elif denied and layout in denied:
+ blank = True
+ if blank and value != "":
+ changed += 1
+ blanked_keys.append(key)
+ if not dry_run:
+ translations[key] = ""
+ if not dry_run:
+ save_json(file_path, data)
+ return {
+ "file": file_path.name,
+ "layout": layout,
+ "changed": changed,
+ "total": total,
+ "blanked": blanked_keys,
+ }
+
+
+def write_blanked_file(out_dir: Path, original_filename: str, blanked_keys: List[str]):
+ if not blanked_keys:
+ return
+ out_dir.mkdir(parents=True, exist_ok=True)
+ out_path = out_dir / original_filename
+ payload = {"translations": {k: "" for k in blanked_keys}}
+ with out_path.open("w", encoding="utf-8") as f:
+ json.dump(payload, f, ensure_ascii=False, indent=2)
+ f.write("\n")
+
+
+def run_cleanup(
+ config_path: Path,
+ locales_dir: Path,
+ *,
+ lang: str = "en",
+ dry_run: Optional[bool] = None,
+ only: Optional[List[str]] = None,
+ blanked_out_dir: Optional[Path] = None,
+):
+ """
+ Execute cleanup using rules. Returns a dict with a summary and counts.
+ Does not print or exit; raises exceptions on fatal errors.
+ """
+ config = load_json(config_path)
+ if not isinstance(config, list):
+ raise ValueError("Config file must contain a list of rules.")
+ rules = compile_rules(config)
+ effective_dry = bool(dry_run)
+
+ if not locales_dir.is_dir():
+ raise FileNotFoundError(f"Locales directory not found: {locales_dir}")
+
+ lang = lang.lower()
+ files = sorted(locales_dir.glob(f"{lang}_*.json"))
+ if not files:
+ raise FileNotFoundError("No locale files found.")
+
+ if blanked_out_dir:
+ blanked_out_dir.mkdir(parents=True, exist_ok=True)
+
+ summary = []
+ total_blanked_keys = 0
+
+ for f in files:
+ layout = infer_layout(f)
+ if only and layout not in set(only):
+ continue
+ result = process_file(f, rules, dry_run=effective_dry)
+ summary.append(result)
+ total_blanked_keys += len(result["blanked"])
+ if blanked_out_dir and result["blanked"]:
+ write_blanked_file(blanked_out_dir, result["file"], result["blanked"])
+
+ return {
+ "summary": summary,
+ "total_blanked_keys": total_blanked_keys,
+ "dry_run": effective_dry,
+ "blanked_out_dir": str(blanked_out_dir) if blanked_out_dir else None,
+ }
+
+
+@click.command(name="cleanup-translations")
+@click.option(
+ "--config",
+ "config_path",
+ type=click.Path(exists=True, dir_okay=False, path_type=Path),
+ required=True,
+ help="Path to layout_rules.json",
+)
+@click.option(
+ "--locales-dir",
+ type=click.Path(exists=True, file_okay=False, path_type=Path),
+ required=True,
+ help="Directory containing locale JSON files",
+)
+@click.option(
+ "--lang", default="en", show_default=True, help="Language code to process"
+)
+@click.option(
+ "--dry-run", is_flag=True, help="Do not write changes to original locale files"
+)
+@click.option(
+ "--only",
+ multiple=True,
+ help="Limit to specific layouts (repeatable, e.g. --only Bolt --only Caesar)",
+)
+@click.option(
+ "--blanked-out-dir",
+ type=click.Path(file_okay=False, path_type=Path),
+ help="Directory to write JSON files containing only blanked keys (for Crowdin)",
+)
+def click_cleanup(
+ config_path: Path,
+ locales_dir: Path,
+ lang: str,
+ dry_run: bool,
+ only: tuple[str, ...],
+ blanked_out_dir: Optional[Path],
+):
+ """
+ Standalone CLI entrypoint using click. Also usable programmatically via run_cleanup().
+ """
+ try:
+ result = run_cleanup(
+ config_path=config_path,
+ locales_dir=locales_dir,
+ lang=lang,
+ dry_run=dry_run,
+ only=list(only) if only else None,
+ blanked_out_dir=blanked_out_dir,
+ )
+ except Exception as e:
+ raise click.ClickException(str(e)) from e
+
+ for r in result["summary"]:
+ click.echo(
+ f"[{r['layout']}] {r['file']}: blanked {r['changed']} of {r['total']}"
+ )
+ click.echo("Done.")
+ if result["dry_run"]:
+ click.echo("Dry run: no original files modified.")
+ if result["blanked_out_dir"] is not None:
+ click.echo(
+ f"Total blanked keys exported: {result['total_blanked_keys']} -> {result['blanked_out_dir']}"
+ )
+
+
+if __name__ == "__main__":
+ click_cleanup()
diff --git a/core/translations/blank_translations_rules.json b/core/translations/blank_translations_rules.json
new file mode 100644
index 00000000..b25cf0a0
--- /dev/null
+++ b/core/translations/blank_translations_rules.json
@@ -0,0 +1,96 @@
+[
+ {
+ "prefix": "ble__",
+ "allowedLayouts": ["Eckhart"],
+ "description": "BLE related strings only in Eckhart"
+ },
+ {
+ "prefix": "thp__",
+ "allowedLayouts": ["Eckhart"],
+ "description": "THP related strings only in Eckhart"
+ },
+ {
+ "prefix": "sd_card__",
+ "allowedLayouts": ["Bolt", "Delizia"],
+ "description": "Only Bolt and Delizia have SD card support"
+ },
+ {
+ "exact": ["auto_lock__on_battery", "auto_lock__on_usb"],
+ "allowedLayouts": ["Eckhart"],
+ "description": "Auto-lock distinction only in battery-powered devices."
+ },
+ {
+ "exact": ["sn__title", "sn__action"],
+ "allowedLayouts": ["Eckhart"],
+ "description": "Extracting serial number only in Eckhart"
+ },
+ {
+ "prefix": "led__",
+ "allowedLayouts": ["Eckhart"],
+ "description": "Only Eckhart has LED"
+ },
+ {
+ "prefix": "haptic_feedback__",
+ "allowedLayouts": ["Delizia", "Eckhart"],
+ "description": "Only Delizia and Eckhart have haptic feedback"
+ },
+ {
+ "prefix": "eos__",
+ "allowedLayouts": ["Bolt"],
+ "description": "Only Bolt has EOS support"
+ },
+ {
+ "prefix": "nem__",
+ "allowedLayouts": ["Bolt"],
+ "description": "Only Bolt has NEM support"
+ },
+ {
+ "prefix": "inputs__",
+ "allowedLayouts": ["Caesar"],
+ "description": "Theese are used only in Caesar input keyboard methods"
+ },
+ {
+ "exact": "tutorial__swipe_up_and_down",
+ "allowedLayouts": ["Delizia"],
+ "description": "Only Delizia has swipe up and down navigation"
+ },
+ {
+ "exact": ["tutorial__tropic_info", "tutorial__what_is_tropic"],
+ "allowedLayouts": ["Eckhart"],
+ "description": "Only Eckhart has tropic"
+ },
+ {
+ "exact": "tutorial__welcome_safe5",
+ "allowedLayouts": ["Delizia"]
+ },
+ {
+ "exact": "backup__title_backup_wallet",
+ "allowedLayouts": ["Caesar"]
+ },
+ {
+ "exact": [
+ "tutorial__welcome_safe7",
+ "tutorial__navigation_ts7",
+ "tutorial__power"
+ ],
+ "allowedLayouts": ["Eckhart"]
+ },
+ {
+ "exact": [
+ "reset__all_x_of_y_template",
+ "reset__any_x_of_y_template",
+ "reset__need_all_share_template",
+ "reset__need_any_share_template",
+ "reset__needed_to_form_a_group",
+ "reset__needed_to_recover_your_wallet",
+ "reset__num_of_shares_basic_info_template",
+ "reset__one_share",
+ "reset__set_it_to_count_template",
+ "reset__the_threshold_sets_the_number_of_shares",
+ "reset__to_form_group_template",
+ "reset__you_need_one_share"
+ ],
+ "allowedLayouts": ["Bolt"],
+ "description": "These are specific to Bolt layout"
+ }
+]
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.