|
26 | 26 | run sees nothing stale and no-ops. Hand-edits always win; the machine only |
27 | 27 | touches strings whose English changed. |
28 | 28 | Every returned string is validated (placeholder parity, the settings UI markup |
29 | | -allowlist, formatting-tag parity, panel-link parity) before it is written; a |
30 | | -failure leaves that string unwritten and the run red rather than shipping a |
31 | | -broken translation. |
| 29 | +allowlist, formatting-tag parity, panel-link parity, and the config flow's |
| 30 | +hardcoded option labels staying untranslated) before it is written; a failure |
| 31 | +leaves that string unwritten and the run red rather than shipping a broken |
| 32 | +translation. |
32 | 33 |
|
33 | 34 | Rate limits and outages: requests are paced under the free-tier rate and |
34 | 35 | retry transient errors with backoff; a persistently failing batch marks its |
|
56 | 57 | from collections import Counter |
57 | 58 | from collections.abc import Container |
58 | 59 | from dataclasses import dataclass, field |
| 60 | +from functools import lru_cache |
59 | 61 | from pathlib import Path |
60 | 62 | from typing import Any, Literal, NamedTuple |
61 | 63 |
|
@@ -415,10 +417,54 @@ def build_plan(module: Any) -> Plan: |
415 | 417 | return plan |
416 | 418 |
|
417 | 419 |
|
| 420 | +_CONFIG_FLOW_PATH = REPO_ROOT / "custom_components" / "ha_mcp_tools" / "config_flow.py" |
| 421 | +_SELECTOR_LABEL_RE = re.compile(r'label="([^"]+)"') |
| 422 | +# English sources quote with straight or typographic marks — both already occur |
| 423 | +# in the shipped catalogs — and the label check has to see the quoted text |
| 424 | +# either way, or the spelling of a quote silently decides whether it applies. |
| 425 | +_QUOTED_RE = re.compile(r'["“”«»„]([^"“”«»„]+)["“”«»„]') |
| 426 | + |
| 427 | + |
| 428 | +@lru_cache(maxsize=1) |
| 429 | +def _hardcoded_option_labels() -> tuple[str, ...]: |
| 430 | + """Config-flow selector labels, which read English on every reader's form. |
| 431 | +
|
| 432 | + Quoting alone does not say whether a string may be translated: catalogs |
| 433 | + correctly translate quoted cross-references to their own option labels, |
| 434 | + and Home Assistant translates its own buttons ("Add entry"). What must |
| 435 | + survive verbatim is the narrower class this returns — labels our config |
| 436 | + flow hardcodes in Python, so no catalog can localise them and a reader |
| 437 | + told to pick a translated one goes looking for an option that is not on |
| 438 | + the form. Read from the source instead of listed here so a rename cannot |
| 439 | + strand a stale copy; ``test_connect_local_lan_quotes_the_bind_host_option`` |
| 440 | + guards the rename against the catalogs. |
| 441 | + """ |
| 442 | + labels: list[str] = _SELECTOR_LABEL_RE.findall(_CONFIG_FLOW_PATH.read_text("utf-8")) |
| 443 | + return tuple(labels) |
| 444 | + |
| 445 | + |
| 446 | +def _untranslatable_label_dropped(english: str, translated: str) -> str | None: |
| 447 | + """The hardcoded label this translation localised away, if any.""" |
| 448 | + quoted_texts: list[str] = _QUOTED_RE.findall(english) |
| 449 | + for quoted in quoted_texts: |
| 450 | + for label in _hardcoded_option_labels(): |
| 451 | + if ( |
| 452 | + label == quoted or label.startswith(f"{quoted} ") |
| 453 | + ) and quoted not in translated: |
| 454 | + return quoted |
| 455 | + return None |
| 456 | + |
| 457 | + |
418 | 458 | def _validate(item: WorkItem, translated: Any) -> str | None: |
419 | 459 | """The reason a translation is unusable for this item, or None.""" |
420 | 460 | if not isinstance(translated, str) or not translated.strip(): |
421 | 461 | return "empty or non-string translation" |
| 462 | + dropped = _untranslatable_label_dropped(item.english, translated) |
| 463 | + if dropped is not None: |
| 464 | + return ( |
| 465 | + f"the on-screen option {dropped!r} is hardcoded in the config flow " |
| 466 | + "and has to stay untranslated" |
| 467 | + ) |
422 | 468 | if set(_PLACEHOLDER_RE.findall(item.english)) != set( |
423 | 469 | _PLACEHOLDER_RE.findall(translated) |
424 | 470 | ): |
|
0 commit comments