mirror of
https://github.com/trezor/trezor-firmware.git
synced 2026-02-20 00:33:30 +01:00
chore: translations blanker script
- a helper script to blank certain translations for layouts if they are not used in the said layout - the script uses rules JSON [no changelog]
This commit is contained in:
291
core/translations/blank_translations.py
Normal file
291
core/translations/blank_translations.py
Normal file
@@ -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()
|
||||
96
core/translations/blank_translations_rules.json
Normal file
96
core/translations/blank_translations_rules.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user