This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 69257f04ee0 Improve translation completeness checker (required keys, 
coverage, unused) (#62983)
69257f04ee0 is described below

commit 69257f04ee0d62e6e3a25996fb24d033143cc7df
Author: André Ahlert <[email protected]>
AuthorDate: Tue Mar 10 14:49:42 2026 -0300

    Improve translation completeness checker (required keys, coverage, unused) 
(#62983)
    
    * Improve translation completeness checker: required keys, coverage, unused
    
    - Required keys: EN base + plural forms for locale when EN has {{count}}
    - Merge Extra and Unused into single Unused (keys not required)
    - Table: Required (base EN), Required (plural), Total required, Translated,
      Missing, Coverage (translated/total), TODOs, Unused
    - Coverage uses only real translations (exclude TODO placeholders)
    - Rename --remove-extra to --remove-unused; remove_unused_translations()
    - Update docs, SKILL, README, and help SVG
    
    * Add translation:pt-BR section to boring-cyborg config
    
    * Revert "Add translation:pt-BR section to boring-cyborg config"
    
    This reverts commit dda93a9ef391741157ce150d2bf2d1256da198d6.
    
    * Apply ruff format to ui_commands.py
    
    * Update breeze docs: regenerate check-translation-completeness output
    
    * Update ui_commands.py
    
    Co-authored-by: Copilot <[email protected]>
    
    * fix(breeze): translation checker row style (red for missing keys)
    
    Fix unreachable red branch in style logic: use red for missing keys,
    yellow for todos/unused, bold green when all clear. Also remove
    unused missing_counts param and rename extra_keys to unused_keys.
    
    ---------
    
    Co-authored-by: Copilot <[email protected]>
---
 airflow-core/src/airflow/ui/public/i18n/README.md  |   6 +-
 dev/breeze/doc/10_ui_tasks.rst                     |   6 +-
 .../output_ui_check-translation-completeness.svg   |   6 +-
 .../output_ui_check-translation-completeness.txt   |   2 +-
 .../src/airflow_breeze/commands/ui_commands.py     | 246 ++++++++++++---------
 .../airflow_breeze/commands/ui_commands_config.py  |   2 +-
 dev/breeze/tests/test_ui_commands.py               |  77 ++++++-
 7 files changed, 218 insertions(+), 127 deletions(-)

diff --git a/airflow-core/src/airflow/ui/public/i18n/README.md 
b/airflow-core/src/airflow/ui/public/i18n/README.md
index 994c499481f..c4a269bf8ed 100644
--- a/airflow-core/src/airflow/ui/public/i18n/README.md
+++ b/airflow-core/src/airflow/ui/public/i18n/README.md
@@ -332,16 +332,16 @@ Adding missing translations (with `TODO: translate` 
prefix):
 breeze ui check-translation-completeness --language <language_code> 
--add-missing
 ```
 
-You can also remove extra translations from the language of your choice:
+You can also remove unused translations from the language of your choice:
 
 ```bash
-breeze ui check-translation-completeness --language <language_code> 
--remove-extra
+breeze ui check-translation-completeness --language <language_code> 
--remove-unused
 ```
 
 Or from all languages:
 
 ```bash
-breeze ui check-translation-completeness --remove-extra
+breeze ui check-translation-completeness --remove-unused
 ```
 
 
diff --git a/dev/breeze/doc/10_ui_tasks.rst b/dev/breeze/doc/10_ui_tasks.rst
index 974a21dfccf..ee98f3770e4 100644
--- a/dev/breeze/doc/10_ui_tasks.rst
+++ b/dev/breeze/doc/10_ui_tasks.rst
@@ -80,11 +80,11 @@ Example usage:
      # Add missing translations with TODO markers
      breeze ui check-translation-completeness --add-missing
 
-     # Remove extra translations not present in English
-     breeze ui check-translation-completeness --remove-extra
+     # Remove unused translations (keys not required)
+     breeze ui check-translation-completeness --remove-unused
 
      # Fix translations for a specific language
-     breeze ui check-translation-completeness --language de --add-missing 
--remove-extra
+     breeze ui check-translation-completeness --language de --add-missing 
--remove-unused
 
 
 -----
diff --git a/dev/breeze/doc/images/output_ui_check-translation-completeness.svg 
b/dev/breeze/doc/images/output_ui_check-translation-completeness.svg
index de811978b2f..7815ad48719 100644
--- a/dev/breeze/doc/images/output_ui_check-translation-completeness.svg
+++ b/dev/breeze/doc/images/output_ui_check-translation-completeness.svg
@@ -105,9 +105,9 @@
 </text><text class="breeze-ui-check-translation-completeness-r1" x="12.2" 
y="93.2" textLength="463.6" 
clip-path="url(#breeze-ui-check-translation-completeness-line-3)">Check&#160;completeness&#160;of&#160;UI&#160;translations.</text><text
 class="breeze-ui-check-translation-completeness-r1" x="1464" y="93.2" 
textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-3)">
 </text><text class="breeze-ui-check-translation-completeness-r1" x="1464" 
y="117.6" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-4)">
 </text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="142" 
textLength="24.4" 
clip-path="url(#breeze-ui-check-translation-completeness-line-5)">╭─</text><text
 class="breeze-ui-check-translation-completeness-r5" x="24.4" y="142" 
textLength="256.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-5)">&#160;Translation&#160;options&#160;</text><text
 class="breeze-ui-check-translation-completeness-r5" x="280.6" y="142" 
textLength="1159" clip-path="url(#breeze- [...]
-</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="166.4" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-6)">│</text><text 
class="breeze-ui-check-translation-completeness-r4" x="24.4" y="166.4" 
textLength="170.8" 
clip-path="url(#breeze-ui-check-translation-completeness-line-6)">--language&#160;&#160;&#160;&#160;</text><text
 class="breeze-ui-check-translation-completeness-r6" x="219.6" y="166.4" 
textLength="24.4" clip-path="url(#bree [...]
-</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="190.8" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-7)">│</text><text 
class="breeze-ui-check-translation-completeness-r4" x="24.4" y="190.8" 
textLength="170.8" 
clip-path="url(#breeze-ui-check-translation-completeness-line-7)">--add-missing&#160;</text><text
 class="breeze-ui-check-translation-completeness-r1" x="268.4" y="190.8" 
textLength="1122.4" clip-path="url(#breeze-ui-check-t [...]
-</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="215.2" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-8)">│</text><text 
class="breeze-ui-check-translation-completeness-r4" x="24.4" y="215.2" 
textLength="170.8" 
clip-path="url(#breeze-ui-check-translation-completeness-line-8)">--remove-extra</text><text
 class="breeze-ui-check-translation-completeness-r1" x="268.4" y="215.2" 
textLength="1000.4" clip-path="url(#breeze-ui-check-transl [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="166.4" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-6)">│</text><text 
class="breeze-ui-check-translation-completeness-r4" x="24.4" y="166.4" 
textLength="183" 
clip-path="url(#breeze-ui-check-translation-completeness-line-6)">--language&#160;&#160;&#160;&#160;&#160;</text><text
 class="breeze-ui-check-translation-completeness-r6" x="231.8" y="166.4" 
textLength="24.4" clip-path="url(# [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="190.8" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-7)">│</text><text 
class="breeze-ui-check-translation-completeness-r4" x="24.4" y="190.8" 
textLength="183" 
clip-path="url(#breeze-ui-check-translation-completeness-line-7)">--add-missing&#160;&#160;</text><text
 class="breeze-ui-check-translation-completeness-r1" x="280.6" y="190.8" 
textLength="1122.4" clip-path="url(#breeze-ui-che [...]
+</text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="215.2" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-8)">│</text><text 
class="breeze-ui-check-translation-completeness-r4" x="24.4" y="215.2" 
textLength="183" 
clip-path="url(#breeze-ui-check-translation-completeness-line-8)">--remove-unused</text><text
 class="breeze-ui-check-translation-completeness-r1" x="280.6" y="215.2" 
textLength="915" clip-path="url(#breeze-ui-check-translatio [...]
 </text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="239.6" textLength="1464" 
clip-path="url(#breeze-ui-check-translation-completeness-line-9)">╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯</text><text
 class="breeze-ui-check-translation-completeness-r1" x="1464" y="239.6" 
textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-9)">
 </text><text class="breeze-ui-check-translation-completeness-r5" x="0" y="264" 
textLength="24.4" 
clip-path="url(#breeze-ui-check-translation-completeness-line-10)">╭─</text><text
 class="breeze-ui-check-translation-completeness-r5" x="24.4" y="264" 
textLength="195.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-10)">&#160;Common&#160;options&#160;</text><text
 class="breeze-ui-check-translation-completeness-r5" x="219.6" y="264" 
textLength="1220" clip-path="url(#breeze-ui- [...]
 </text><text class="breeze-ui-check-translation-completeness-r5" x="0" 
y="288.4" textLength="12.2" 
clip-path="url(#breeze-ui-check-translation-completeness-line-11)">│</text><text
 class="breeze-ui-check-translation-completeness-r4" x="24.4" y="288.4" 
textLength="109.8" 
clip-path="url(#breeze-ui-check-translation-completeness-line-11)">--verbose</text><text
 class="breeze-ui-check-translation-completeness-r6" x="158.6" y="288.4" 
textLength="24.4" clip-path="url(#breeze-ui-check-translation [...]
diff --git a/dev/breeze/doc/images/output_ui_check-translation-completeness.txt 
b/dev/breeze/doc/images/output_ui_check-translation-completeness.txt
index 68771c83bbe..acab51d294f 100644
--- a/dev/breeze/doc/images/output_ui_check-translation-completeness.txt
+++ b/dev/breeze/doc/images/output_ui_check-translation-completeness.txt
@@ -1 +1 @@
-f9abfd3676b04ed17b81796c9bcd79a9
+4d8c7e8b2fe193a4b8e8cf25a429ff7e
diff --git a/dev/breeze/src/airflow_breeze/commands/ui_commands.py 
b/dev/breeze/src/airflow_breeze/commands/ui_commands.py
index 2051c64d571..eb1843c4d2a 100644
--- a/dev/breeze/src/airflow_breeze/commands/ui_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/ui_commands.py
@@ -98,15 +98,15 @@ PLURAL_SUFFIXES = {
 
 class LocaleSummary(NamedTuple):
     """
-    Summary of missing and extra translation keys for a file, per locale.
+    Summary of missing and unused translation keys for a file, per locale.
 
     Attributes:
-        missing_keys: A dictionary mapping locale codes to lists of missing 
translation keys.
-        extra_keys: A dictionary mapping locale codes to lists of extra 
translation keys.
+        missing_keys: Required keys that the locale does not have.
+        unused_keys: Keys present in the locale that are not required (never 
used at runtime).
     """
 
     missing_keys: dict[str, list[str]]
-    extra_keys: dict[str, list[str]]
+    unused_keys: dict[str, list[str]]
 
 
 class LocaleFiles(NamedTuple):
@@ -142,9 +142,17 @@ def get_plural_base(key: str, suffixes: list[str]) -> str 
| None:
     return None
 
 
-def expand_plural_keys(keys: set[str], lang: str) -> set[str]:
+COUNT_PLACEHOLDER = "{{count}}"
+
+
+def expand_plural_keys(keys: set[str], lang: str, en_key_to_value: dict[str, 
str] | None = None) -> set[str]:
     """
-    For a set of keys, expand all plural bases to include all required 
suffixes for the language.
+    For a set of keys, expand plural bases to include required suffixes for 
the language.
+
+    When en_key_to_value is provided, only expand a base to all plural forms 
when at least
+    one of the English values for that base contains {{count}}. Keys without 
{{count}}
+    (e.g. fixed "1 Error") are not expanded, so locales are not required to 
have
+    unused forms like error_other when the source only has error_one.
     """
     console = get_console()
     suffixes = PLURAL_SUFFIXES.get(lang)
@@ -162,6 +170,11 @@ def expand_plural_keys(keys: set[str], lang: str) -> 
set[str]:
             base_to_suffixes.setdefault(base, set()).add(key[len(base) :])
     expanded = set(keys)
     for base in base_to_suffixes.keys():
+        if en_key_to_value is not None:
+            en_keys_for_base = [k for k in keys if get_plural_base(k, 
suffixes) == base]
+            any_has_count = any(COUNT_PLACEHOLDER in en_key_to_value.get(k, 
"") for k in en_keys_for_base)
+            if not any_has_count:
+                continue
         for suffix in suffixes:
             expanded.add(base + suffix)
     return expanded
@@ -192,6 +205,18 @@ def flatten_keys(d: dict, prefix: str = "") -> list[str]:
     return keys
 
 
+def flatten_keys_to_values(d: dict, prefix: str = "") -> dict[str, str]:
+    """Build a flat key -> value map for leaf string values (for i18n JSON)."""
+    result: dict[str, str] = {}
+    for k, v in d.items():
+        full_key = f"{prefix}.{k}" if prefix else k
+        if isinstance(v, dict):
+            result.update(flatten_keys_to_values(v, full_key))
+        elif isinstance(v, str):
+            result[full_key] = v
+    return result
+
+
 def compare_keys(
     locale_files: list[LocaleFiles],
 ) -> tuple[dict[str, LocaleSummary], dict[str, dict[str, int]]]:
@@ -199,7 +224,7 @@ def compare_keys(
     Compare all non-English locales with English locale only.
 
     Returns a tuple:
-      - summary dict: filename -> LocaleSummary(missing, extra)
+      - summary dict: filename -> LocaleSummary(missing_keys, unused_keys)
       - missing_counts dict: filename -> {locale: count_of_missing_keys}
     """
     all_files: set[str] = set()
@@ -221,25 +246,32 @@ def compare_keys(
             key_sets.append(LocaleKeySet(locale=lf.locale, keys=keys))
         keys_by_locale = {ks.locale: ks.keys for ks in key_sets}
         en_keys = keys_by_locale.get("en", set()) or set()
-        # Expand English keys for all required plural forms in each language
-        expanded_en_keys = {lang: expand_plural_keys(en_keys, lang) for lang 
in keys_by_locale.keys()}
+        en_key_to_value: dict[str, str] = {}
+        en_path = LOCALES_DIR / "en" / filename
+        if en_path.exists():
+            try:
+                en_data = load_json(en_path)
+                en_key_to_value = flatten_keys_to_values(en_data)
+            except Exception as e:
+                get_console().print(f"Error loading en values from {en_path}: 
{e}")
+        # Required = EN keys plus, for bases with {{count}} in EN, all plural 
forms for the locale
         missing_keys: dict[str, list[str]] = {}
-        extra_keys: dict[str, list[str]] = {}
+        unused_keys: dict[str, list[str]] = {}
         missing_counts[filename] = {}
         for ks in key_sets:
             if ks.locale == "en":
                 continue
-            required_keys = expanded_en_keys.get(ks.locale, en_keys)
+            required_keys = expand_plural_keys(en_keys, ks.locale, 
en_key_to_value)
             if ks.keys is None:
                 missing_keys[ks.locale] = list(required_keys)
-                extra_keys[ks.locale] = []
+                unused_keys[ks.locale] = []
                 missing_counts[filename][ks.locale] = len(required_keys)
             else:
                 missing = list(required_keys - ks.keys)
                 missing_keys[ks.locale] = missing
-                extra_keys[ks.locale] = list(ks.keys - required_keys)
+                unused_keys[ks.locale] = sorted(ks.keys - required_keys)
                 missing_counts[filename][ks.locale] = len(missing)
-        summary[filename] = LocaleSummary(missing_keys=missing_keys, 
extra_keys=extra_keys)
+        summary[filename] = LocaleSummary(missing_keys=missing_keys, 
unused_keys=unused_keys)
     return summary, missing_counts
 
 
@@ -296,15 +328,15 @@ def print_language_summary(locale_files: 
list[LocaleFiles], summary: dict[str, L
     for lf in sorted(locale_files):
         locale = lf.locale
         file_missing: dict[str, list[str]] = {}
-        file_extra: dict[str, list[str]] = {}
+        file_unused: dict[str, list[str]] = {}
         for filename, diff in sorted(summary.items()):
             missing_keys = diff.missing_keys.get(locale, [])
-            extra_keys = diff.extra_keys.get(locale, [])
+            unused_keys = diff.unused_keys.get(locale, [])
             if missing_keys:
                 file_missing[filename] = missing_keys
-            if extra_keys:
-                file_extra[filename] = extra_keys
-        if file_missing or file_extra:
+            if unused_keys:
+                file_unused[filename] = unused_keys
+        if file_missing or file_unused:
             if locale == "en":
                 continue
             console.print(Panel(f"[bold yellow]{locale}[/bold yellow]", 
style="blue"))
@@ -315,34 +347,38 @@ def print_language_summary(locale_files: 
list[LocaleFiles], summary: dict[str, L
                     console.print(f"    [magenta]{filename}[/magenta]:")
                     for k in keys:
                         console.print(f"      [yellow]{k}[/yellow]")
-            if file_extra:
-                found_difference = True
-                console.print("[red]  Extra keys (need to be 
removed/updated):[/red]")
-                for filename, keys in file_extra.items():
+            if file_unused:
+                console.print("[yellow]  Unused keys (not required, can be 
removed):[/yellow]")
+                for filename, keys in file_unused.items():
                     console.print(f"    [magenta]{filename}[/magenta]:")
                     for k in keys:
-                        console.print(f"      [yellow]{k}[/yellow]")
+                        console.print(f"      [dim]{k}[/dim]")
     return found_difference
 
 
+def is_todo_value(s: str) -> bool:
+    """Return True if the string is a TODO placeholder (e.g. 'TODO: translate: 
...')."""
+    return isinstance(s, str) and s.strip().startswith("TODO: translate")
+
+
 def count_todos(obj) -> int:
     """Count TODO: translate entries in a dict or list."""
     if isinstance(obj, dict):
         return sum(count_todos(v) for v in obj.values())
     if isinstance(obj, list):
         return sum(count_todos(v) for v in obj)
-    if isinstance(obj, str) and obj.strip().startswith("TODO: translate"):
+    if is_todo_value(obj):
         return 1
     return 0
 
 
-def print_translation_progress(locale_files, missing_counts, summary):
+def print_translation_progress(locale_files, summary):
     console = get_console()
     from rich.table import Table
 
     tables = defaultdict(lambda: Table(show_lines=True))
     all_files = set()
-    coverage_per_language = {}  # Collect total coverage per language
+    coverage_per_language = {}
     for lf in locale_files:
         all_files.update(lf.files)
 
@@ -355,91 +391,94 @@ def print_translation_progress(locale_files, 
missing_counts, summary):
         table = tables[lang]
         table.title = f"Translation Progress: {lang}"
         table.add_column("File", style="bold cyan")
-        table.add_column("Missing", style="red")
-        table.add_column("Extra", style="yellow")
-        table.add_column("TODOs", style="magenta")
+        table.add_column("Required (base EN)", style="dim")
+        table.add_column("Required (plural)", style="dim")
+        table.add_column("Total required", style="bold")
         table.add_column("Translated", style="green")
-        table.add_column("Total", style="bold")
+        table.add_column("Missing", style="red")
         table.add_column("Coverage", style="bold")
-        table.add_column("Completed", style="bold")
+        table.add_column("TODOs", style="magenta")
+        table.add_column("Unused", style="yellow")
+        total_required_base = 0
+        total_required_plural = 0
+        total_required = 0
+        total_translated = 0
         total_missing = 0
-        total_extra = 0
         total_todos = 0
-        total_translated = 0
-        total_total = 0
+        total_unused = 0
         for filename in sorted(all_files):
-            file_path = Path(LOCALES_DIR / lang / filename)
-            # Always get total from English version
             en_path = Path(LOCALES_DIR / "en" / filename)
-            if en_path.exists():
-                with open(en_path) as f:
-                    en_data = json.load(f)
-                file_total = sum(1 for _ in flatten_keys(en_data))
-            else:
-                file_total = 0
+            file_path = Path(LOCALES_DIR / lang / filename)
+            diff = summary.get(filename, LocaleSummary({}, {}))
+            if not en_path.exists():
+                continue
+            with open(en_path) as f:
+                en_data = json.load(f)
+            en_keys = set(flatten_keys(en_data))
+            en_key_to_value = flatten_keys_to_values(en_data)
+            required_keys = expand_plural_keys(en_keys, lang, en_key_to_value)
+            required_base_en = len(en_keys)
+            required_plural = len(required_keys) - required_base_en
+            total_req = len(required_keys)
+            file_missing = len(diff.missing_keys.get(lang, []))
+            file_unused = len(diff.unused_keys.get(lang, []))
             if file_path.exists():
                 with open(file_path) as f:
-                    data = json.load(f)
-                file_missing = missing_counts.get(filename, {}).get(lang, 0)
-                file_extra = len(summary.get(filename, LocaleSummary({}, 
{})).extra_keys.get(lang, []))
-
-                file_todos = count_todos(data)
+                    lang_data = json.load(f)
+                lang_key_to_value = flatten_keys_to_values(lang_data)
+                file_translated = sum(
+                    1
+                    for k in required_keys
+                    if k in lang_key_to_value and not 
is_todo_value(lang_key_to_value[k])
+                )
+                file_todos = sum(
+                    1 for k in required_keys if k in lang_key_to_value and 
is_todo_value(lang_key_to_value[k])
+                )
                 if file_todos > 0:
                     has_todos = True
-                file_translated = file_total - file_missing
-                # Coverage: translated / total
-                file_coverage_percent = 100 * file_translated / file_total if 
file_total else 100
-                # Complete percent: (translated - todos) / translated
-                file_actual_translated = file_translated - file_todos
-                complete_percent = 100 * file_actual_translated / 
file_translated if file_translated else 100
-                style = (
-                    "bold green"
-                    if file_missing == 0 and file_extra == 0 and file_todos == 0
-                    else (
-                        "yellow" if file_missing < file_total or file_extra > 
0 or file_todos > 0 else "red"
-                    )
-                )
             else:
-                file_missing = file_total
-                file_extra = len(summary.get(filename, LocaleSummary({}, 
{})).extra_keys.get(lang, []))
-                file_todos = 0
                 file_translated = 0
-                file_coverage_percent = 0
-                complete_percent = 0
+                file_todos = 0
+            file_coverage = 100 * file_translated / total_req if total_req 
else 100.0
+            if file_missing > 0:
                 style = "red"
+            elif file_todos > 0 or file_unused > 0:
+                style = "yellow"
+            else:
+                style = "bold green"
             table.add_row(
                 f"[bold reverse]{filename}[/bold reverse]",
+                str(required_base_en),
+                str(required_plural),
+                str(total_req),
+                str(file_translated),
                 str(file_missing),
-                str(file_extra),
+                f"{file_coverage:.1f}%",
                 str(file_todos),
-                str(file_translated),
-                str(file_total),
-                f"{file_coverage_percent:.1f}%",
-                f"{complete_percent:.1f}%",
+                str(file_unused),
                 style=style,
             )
+            total_required_base += required_base_en
+            total_required_plural += required_plural
+            total_required += total_req
+            total_translated += file_translated
             total_missing += file_missing
-            total_extra += file_extra
             total_todos += file_todos
-            total_translated += file_translated
-            total_total += file_total
-
-        # Calculate totals for this language
-        total_coverage_percent = 100 * total_translated / total_total if 
total_total else 100
-        total_actual_translated = total_translated - total_todos
-        total_complete_percent = 100 * total_actual_translated / 
total_translated if total_translated else 100
+            total_unused += file_unused
 
-        coverage_per_language[lang] = total_coverage_percent
+        total_coverage = 100 * total_translated / total_required if 
total_required else 100.0
+        coverage_per_language[lang] = total_coverage
         table.add_row(
             "All files",
+            str(total_required_base),
+            str(total_required_plural),
+            str(total_required),
+            str(total_translated),
             str(total_missing),
-            str(total_extra),
+            f"{total_coverage:.1f}%",
             str(total_todos),
-            str(total_translated),
-            str(total_total),
-            f"{total_coverage_percent:.1f}%",
-            f"{total_complete_percent:.1f}%",
-            style="red" if total_complete_percent < 100 else "bold green",
+            str(total_unused),
+            style="red" if total_missing > 0 or total_todos > 0 or 
total_unused > 0 else "bold green",
         )
 
     for _lang, table in tables.items():
@@ -512,16 +551,16 @@ def add_missing_translations(language: str, summary: 
dict[str, LocaleSummary]):
         console.print(f"[green]Added missing translations to 
{lang_path}[/green]")
 
 
-def remove_extra_translations(language: str, summary: dict[str, 
LocaleSummary]):
+def remove_unused_translations(language: str, summary: dict[str, 
LocaleSummary]):
     """
-    Remove extra translations for the selected language.
+    Remove unused translations for the selected language.
 
-    Removes keys that are present in the language file but missing in the 
English file.
+    Removes keys that are present in the language file but are not required.
     """
     console = get_console()
     for filename, diff in summary.items():
-        extra_keys = set(diff.extra_keys.get(language, []))
-        if not extra_keys:
+        unused_keys = set(diff.unused_keys.get(language, []))
+        if not unused_keys:
             continue
         lang_path = LOCALES_DIR / language / filename
         try:
@@ -530,12 +569,12 @@ def remove_extra_translations(language: str, summary: 
dict[str, LocaleSummary]):
             console.print(f"[yellow]Failed to load {language} file 
{lang_path}: {e}[/yellow]")
             continue
 
-        # Helper to recursively remove extra keys
+        # Helper to recursively remove unused keys
         def remove_keys(dst, prefix=""):
             keys_to_remove = []
             for k, v in list(dst.items()):
                 full_key = f"{prefix}.{k}" if prefix else k
-                if full_key in extra_keys:
+                if full_key in unused_keys:
                     keys_to_remove.append(k)
                 elif isinstance(v, dict):
                     remove_keys(v, full_key)
@@ -556,7 +595,7 @@ def remove_extra_translations(language: str, summary: 
dict[str, LocaleSummary]):
         with open(lang_path, "w", encoding="utf-8") as f:
             json.dump(lang_data, f, ensure_ascii=False, indent=2)
             f.write("\n")  # Ensure newline at the end of the file
-        console.print(f"[green]Removed {len(extra_keys)} extra translations 
from {lang_path}[/green]")
+        console.print(f"[green]Removed {len(unused_keys)} unused translations 
from {lang_path}[/green]")
 
 
 @click.group(cls=BreezeGroup, name="ui", help="Tools for UI development and 
maintenance")
@@ -581,15 +620,15 @@ def ui_group():
     help="Add missing translations for all languages except English, prefixed 
with 'TODO: translate:'.",
 )
 @click.option(
-    "--remove-extra",
+    "--remove-unused",
     is_flag=True,
     default=False,
-    help="Remove extra translations that are present in the language but 
missing in English.",
+    help="Remove unused translations (keys present in the language but not 
required).",
 )
 @option_verbose
 @option_dry_run
 def check_translation_completeness(
-    language: str | None = None, add_missing: bool = False, remove_extra: bool 
= False
+    language: str | None = None, add_missing: bool = False, remove_unused: 
bool = False
 ):
     locale_files = get_locale_files()
     console = get_console()
@@ -608,13 +647,13 @@ def check_translation_completeness(
             for filename, diff in summary.items():
                 filtered_summary[filename] = LocaleSummary(
                     missing_keys={lf.locale: diff.missing_keys.get(lf.locale, 
[])},
-                    extra_keys={lf.locale: diff.extra_keys.get(lf.locale, [])},
+                    unused_keys={lf.locale: diff.unused_keys.get(lf.locale, 
[])},
                 )
             add_missing_translations(lf.locale, filtered_summary)
         # After adding, re-run the summary for all languages
         summary, missing_counts = compare_keys(get_locale_files())
-    if remove_extra and language != "en":
-        # Loop through all languages except 'en' and remove extra translations
+    if remove_unused and language != "en":
+        # Loop through all languages except 'en' and remove unused translations
         if language:
             language_files = [lf for lf in locale_files if lf.locale == 
language]
         else:
@@ -624,9 +663,9 @@ def check_translation_completeness(
             for filename, diff in summary.items():
                 filtered_summary[filename] = LocaleSummary(
                     missing_keys={lf.locale: diff.missing_keys.get(lf.locale, 
[])},
-                    extra_keys={lf.locale: diff.extra_keys.get(lf.locale, [])},
+                    unused_keys={lf.locale: diff.unused_keys.get(lf.locale, 
[])},
                 )
-            remove_extra_translations(lf.locale, filtered_summary)
+            remove_unused_translations(lf.locale, filtered_summary)
         # After removing, re-run the summary for all languages
         summary, missing_counts = compare_keys(get_locale_files())
     if language:
@@ -642,7 +681,7 @@ def check_translation_completeness(
             for filename, diff in summary.items():
                 filtered_summary[filename] = LocaleSummary(
                     missing_keys={language: diff.missing_keys.get(language, 
[])},
-                    extra_keys={language: diff.extra_keys.get(language, [])},
+                    unused_keys={language: diff.unused_keys.get(language, [])},
                 )
             lang_diff = print_language_summary(
                 [lf for lf in locale_files if lf.locale == language], 
filtered_summary
@@ -653,7 +692,6 @@ def check_translation_completeness(
         found_difference = found_difference or lang_diff
     has_todos, coverage_per_language = print_translation_progress(
         [lf for lf in locale_files if language is None or lf.locale == 
language],
-        missing_counts,
         summary,
     )
     if not found_difference and not has_todos:
diff --git a/dev/breeze/src/airflow_breeze/commands/ui_commands_config.py 
b/dev/breeze/src/airflow_breeze/commands/ui_commands_config.py
index 83f9fd34fbf..e50e10e6fd6 100644
--- a/dev/breeze/src/airflow_breeze/commands/ui_commands_config.py
+++ b/dev/breeze/src/airflow_breeze/commands/ui_commands_config.py
@@ -31,7 +31,7 @@ UI_PARAMETERS: dict[str, list[dict[str, str | list[str]]]] = {
             "options": [
                 "--language",
                 "--add-missing",
-                "--remove-extra",
+                "--remove-unused",
             ],
         },
     ],
diff --git a/dev/breeze/tests/test_ui_commands.py 
b/dev/breeze/tests/test_ui_commands.py
index 843d985ed02..8f7b844be79 100644
--- a/dev/breeze/tests/test_ui_commands.py
+++ b/dev/breeze/tests/test_ui_commands.py
@@ -61,6 +61,25 @@ class TestPluralHandling:
         assert "message_many" in expanded
         assert "message_other" in expanded
 
+    def 
test_expand_plural_keys_only_expands_when_en_has_count_placeholder(self):
+        # When en has error_one without {{count}}, we must not require 
error_other
+        keys = {"error_one"}
+        en_key_to_value = {"error_one": "1 Error"}
+        expanded = expand_plural_keys(keys, "pt", en_key_to_value)
+        assert "error_one" in expanded
+        assert "error_other" not in expanded
+
+    def test_expand_plural_keys_expands_when_en_has_count_placeholder(self):
+        # Polish has 4 plural forms (_one, _few, _many, _other). Input has 
only _one and _other;
+        # expansion must add _few and _many so the test verifies the expansion 
logic ran.
+        keys = {"warning_one", "warning_other"}
+        en_key_to_value = {"warning_one": "1 Warning", "warning_other": 
"{{count}} Warnings"}
+        expanded = expand_plural_keys(keys, "pl", en_key_to_value)
+        assert "warning_one" in expanded
+        assert "warning_other" in expanded
+        assert "warning_few" in expanded
+        assert "warning_many" in expanded
+
 
 class TestFlattenKeys:
     def test_flatten_simple_dict(self):
@@ -112,7 +131,7 @@ class TestCompareKeys:
 
             assert "test.json" in summary
             assert summary["test.json"].missing_keys.get("de", []) == []
-            assert summary["test.json"].extra_keys.get("de", []) == []
+            assert summary["test.json"].unused_keys.get("de", []) == []
         finally:
             ui_commands.LOCALES_DIR = original_locales_dir
 
@@ -171,16 +190,50 @@ class TestCompareKeys:
             summary, missing_counts = compare_keys(locale_files)
 
             assert "test.json" in summary
-            assert "extra" in summary["test.json"].extra_keys.get("de", [])
+            assert "extra" in summary["test.json"].unused_keys.get("de", [])
+        finally:
+            ui_commands.LOCALES_DIR = original_locales_dir
+
+    def test_compare_keys_optional_plural_unused_when_no_count(self, tmp_path):
+        """Plural variant of EN base with no {{count}} in EN is reported as 
unused."""
+        en_dir = tmp_path / "en"
+        en_dir.mkdir()
+        de_dir = tmp_path / "de"
+        de_dir.mkdir()
+
+        # EN has only error_one (no {{count}}), so error_other is never used 
at runtime
+        en_data = {"dagWarnings": {"error_one": "1 Error"}}
+        de_data = {"dagWarnings": {"error_one": "1 Fehler", "error_other": 
"{{count}} Fehler"}}
+
+        (en_dir / "test.json").write_text(json.dumps(en_data))
+        (de_dir / "test.json").write_text(json.dumps(de_data))
+
+        import airflow_breeze.commands.ui_commands as ui_commands
+
+        original_locales_dir = ui_commands.LOCALES_DIR
+        ui_commands.LOCALES_DIR = tmp_path
+
+        try:
+            locale_files = [
+                LocaleFiles(locale="en", files=["test.json"]),
+                LocaleFiles(locale="de", files=["test.json"]),
+            ]
+            summary, _ = compare_keys(locale_files)
+
+            # EN base has no {{count}}, so error_other is not required and is 
unused
+            assert "dagWarnings.error_other" in 
summary["test.json"].unused_keys.get("de", [])
         finally:
             ui_commands.LOCALES_DIR = original_locales_dir
 
 
 class TestLocaleSummary:
     def test_locale_summary_creation(self):
-        summary = LocaleSummary(missing_keys={"de": ["key1", "key2"]}, 
extra_keys={"de": ["key3"]})
+        summary = LocaleSummary(
+            missing_keys={"de": ["key1", "key2"]},
+            unused_keys={"de": ["key3"]},
+        )
         assert summary.missing_keys == {"de": ["key1", "key2"]}
-        assert summary.extra_keys == {"de": ["key3"]}
+        assert summary.unused_keys == {"de": ["key3"]}
 
 
 class TestLocaleFiles:
@@ -255,7 +308,7 @@ class TestAddMissingTranslations:
         try:
             summary = LocaleSummary(
                 missing_keys={"de": ["farewell"]},
-                extra_keys={"de": []},
+                unused_keys={"de": []},
             )
             add_missing_translations("de", {"test.json": summary})
 
@@ -267,9 +320,9 @@ class TestAddMissingTranslations:
             ui_commands.LOCALES_DIR = original_locales_dir
 
 
-class TestRemoveExtraTranslations:
-    def test_remove_extra_translations(self, tmp_path):
-        from airflow_breeze.commands.ui_commands import 
remove_extra_translations
+class TestRemoveUnusedTranslations:
+    def test_remove_unused_translations(self, tmp_path):
+        from airflow_breeze.commands.ui_commands import 
remove_unused_translations
 
         de_dir = tmp_path / "de"
         de_dir.mkdir()
@@ -285,11 +338,11 @@ class TestRemoveExtraTranslations:
         try:
             summary = LocaleSummary(
                 missing_keys={"de": []},
-                extra_keys={"de": ["extra"]},
+                unused_keys={"de": ["extra"]},
             )
-            remove_extra_translations("de", {"test.json": summary})
+            remove_unused_translations("de", {"test.json": summary})
 
-            # Check that the extra key was removed
+            # Check that the unused key was removed
             de_data_updated = json.loads((de_dir / "test.json").read_text())
             assert "extra" not in de_data_updated
             assert "greeting" in de_data_updated
@@ -330,7 +383,7 @@ class TestNaturalSorting:
         try:
             summary = LocaleSummary(
                 missing_keys={"de": list(en_data.keys())},
-                extra_keys={"de": []},
+                unused_keys={"de": []},
             )
             add_missing_translations("de", {"test.json": summary})
 


Reply via email to