Files
langflow/scripts/gp/extract_backend_strings.py
Ram Gopal Srikar Katakam 9cb49c2c7d fix(i18n): backend locale translation fixes — custom component nodes, Toolset output, RAG template notes, UI banner strings (#13296)
* fix(i18n): translate custom component nodes and fix missing RAG template notes in ja/fr

- Add translate_component_node() helper to i18n.py and call it from
  custom_component and custom_component_update endpoints so canvas
  interactions (tool mode toggle, field edits) no longer revert node
  labels to English when a non-English locale is active.
- Sync ja.json and fr.json from GP: both locales were missing
  template_notes.vector_store_rag.* keys, causing the Vector Store RAG
  README note to always render in English.

* fix(i18n): translate Toolset output and UpdateAllComponents banner strings

- Extract the shared tool-mode output ("Toolset") via a sentinel norm
  "_toolmode" in extract_backend_strings.py so the dynamic output
  injected on every component when tool mode is enabled is covered by
  a single shared translation key instead of being silently skipped.
- Update translate_component_node() to use the "_toolmode" norm when
  the output name is "component_as_tool", resolving the key correctly
  for all components.
- Wrap all hardcoded strings in UpdateAllComponents/index.tsx with t()
  (summary banner, Dismiss/Dismiss All, Review All/Update All buttons).
- Add 15 new updateComponents.* keys to frontend en.json with i18next
  _one/_other plural variants; upload and download all locale files.
- Sync all backend and frontend locale files from GP.

* fix(i18n): sync backend locale files with Toolset translations from GP

* fix(i18n): translate Retry button in server connection error dialog

* [autofix.ci] apply automated fixes

* fix(i18n): inline lfx.base constants in extract_backend_strings to fix CI

The GP script test environment mocks lfx.components but does not install
the full lfx package, so `from lfx.base.tools.constants import ...` raised
ModuleNotFoundError. Inline the two constants directly, matching the pattern
already used in bake_note_keys.py.

* [autofix.ci] apply automated fixes

* fix(i18n): address PR review — blockedPlural scheme, TOOL_OUTPUT_NAME constant, translation error isolation

- I1: Replace {{blockedPlural}} English-suffix hack in UpdateAllComponents with two
  separate t() calls (blockedCannotRun + andMustBeUpdated), each driven by its own
  count parameter, giving proper _one/_other pluralization for both counts independently.
  Remove broken blockedAndMustUpdate_one/other keys from all 6 non-English locales
  (de/es/fr/ja/pt/zh-Hans) so they fall back cleanly to English until re-translated
  via the GP pipeline.
- I3: Replace magic string "component_as_tool" in i18n.py with TOOL_OUTPUT_NAME
  imported from lfx.base.tools.constants — the canonical source of truth.
- I4: Move translate_component_node() calls outside the main try/except in both
  custom_component and custom_component_update endpoints; wrap each in a narrow
  try/except that logs and falls through to the untranslated node, so an i18n bug
  can no longer fail a successful component update with HTTP 400.

* fix(i18n): translate update-components modal strings and preserve custom field display_names

- Translate hardcoded strings in UpdateComponentModal (title, column
  headers "Component"/"Update Type", "Breaking" label) — adds 5 new
  keys under updateComponent.* and downloads translations for all locales

- Fix custom component field display_names reverting on reload: extend
  build_component_display_names to collect all known locale translations
  per input field; syncNodeTranslations now checks the known-translation
  set before overwriting a field's display_name — user-customized values
  (not present in any locale file) are left untouched while standard
  translatable values continue to update correctly on locale switch

- Update ComponentDisplayNamesType to include per-field translation sets

* fix(i18n): correct updateComponent.breaking translation key

Change English source from "Breaking" to "Breaking Change" so GP
produces correct translations (all locales were getting "Breaking News"
variants). zh-Hans now correctly shows 重大变更, consistent with the
existing breakingUpdateDesc body text.

* chore(i18n): re-download frontend translations after reverting breaking key to "Breaking"

* fix(i18n): extract and translate FileComponent description

The extraction script silently skipped @property descriptors when reading
description from component classes. Add a fallback to _base_description
for components that use a dynamic @property description.

Unify _base_description with the string used inside get_tool_description()
so there is a single source of truth, then re-run extract/upload/download
to add the missing translations for FileComponent description and Knowledge
component strings across all locales.

* fix(ui): redesign outdated-components banner with title + list layout

Replace long sentence-style summary with bold "Flow needs review" title
and short per-condition bullet lines. Change Dismiss All button from
link to outline variant. Add 3 new i18n keys and sync all locale translations.

* fix(i18n): add missing backend locale translations for de, es, pt, zh-Hans

Regenerated via GP download script after merging release-1.10.0 changes.

* [autofix.ci] apply automated fixes

* fix(i18n): resolve ruff lint errors in i18n utils and endpoints

* fix(lint): suppress SLF001 for internal class attribute access in FileComponent

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* [autofix.ci] apply automated fixes

* starter templates

* fix(tests): update outdated-banner assertions to match redesigned layout

* [autofix.ci] apply automated fixes

* fix(docs): restore accidentally deleted memory-base docs from release-1.10.0

* fix(credentials): restore DB-first API key resolution and tests from release-1.10.0

* [autofix.ci] apply automated fixes

* fix(i18n): revert fetchErrorComponent key to common.retry (out of scope)

* [autofix.ci] apply automated fixes

* chore(i18n): sync backend and frontend locale files from GP

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Antônio Alexandre Borges Lima <104531655+AntonioABLima@users.noreply.github.com>
2026-05-29 20:23:49 +00:00

234 lines
10 KiB
Python

"""Extract translatable strings from Langflow component classes.
Walks the lfx.components package, reads class-level display_name/description
and field-level display_names directly from component class definitions
(no running server needed), and writes a flat GP-compatible JSON file.
Output format — hybrid key: human-readable path + content-hash suffix:
"components.chatinput.display_name.a1b2c3d4": "Chat Input"
"components.chatinput.description.f9e8d7c6": "Get chat inputs from the Playground."
"components.chatinput.inputs.input_value.display_name.12345678": "Input Text"
"components.chatinput.outputs.message.display_name.abcdef01": "Chat Message"
The norm_name is the component registry key lowercased with spaces removed.
The 8-char suffix is SHA-256(english_value)[:8]. When an English string
changes, its hash changes, the old key is orphaned, and GP issues a fresh
translation for the new key on the next upload/download cycle.
Usage:
# From repo root with the backend virtualenv active:
python scripts/gp/extract_backend_strings.py
# Check only (exit 1 if en.json would change — use in CI):
python scripts/gp/extract_backend_strings.py --check
"""
from __future__ import annotations
import argparse
import importlib
import json
import pkgutil
import sys
from pathlib import Path
OUTPUT_PATH = Path(__file__).parent.parent.parent / "src/backend/base/langflow/locales/en.json"
STARTER_PROJECTS_DIR = Path(__file__).parent.parent.parent / "src/backend/base/langflow/initial_setup/starter_projects"
def collect_strings() -> dict[str, str]:
"""Walk lfx.components and extract all translatable display_name strings."""
from langflow.utils.i18n_keys import component_field_key as _component_field_key
from langflow.utils.i18n_keys import normalize_component_key as _normalize_component_key
from langflow.utils.i18n_keys import safe_flow_key as _safe_key
try:
import lfx.components as components_pkg
except ImportError:
print("ERROR: Could not import lfx.components. Run this script from inside the backend virtualenv.")
sys.exit(1)
flat: dict[str, str] = {}
seen_names: set[str] = set()
for _finder, modname, _ispkg in pkgutil.walk_packages(components_pkg.__path__, components_pkg.__name__ + "."):
if "deactivated" in modname:
continue
try:
module = importlib.import_module(modname)
except Exception as e: # noqa: BLE001
print(f" SKIP {modname}: {e}")
continue
for cls in vars(module).values():
if not isinstance(cls, type):
continue
# Only process classes defined in this module (avoid re-processing imports)
if getattr(cls, "__module__", None) != modname:
continue
# Component marker set by the base class
if not getattr(cls, "code_class_base_inheritance", None):
continue
display_name = getattr(cls, "display_name", None)
# Skip if not a plain string (e.g. @property descriptors on the class)
if not isinstance(display_name, str) or not display_name:
continue
# Use cls.name if defined (stable identifier used in API), else class name
component_key = getattr(cls, "name", None) or cls.__name__
if not isinstance(component_key, str):
component_key = cls.__name__
if component_key in seen_names:
continue
seen_names.add(component_key)
norm_key = _normalize_component_key(component_key)
# Tier 1 — component-level
flat[_component_field_key(norm_key, "display_name", display_name)] = display_name
# If description is a @property, getattr on the class returns the descriptor
# object (not a string). Fall back to _base_description when that happens.
raw_desc = cls.__dict__.get("description")
if isinstance(raw_desc, property):
description = getattr(cls, "_base_description", "") or ""
else:
description = getattr(cls, "description", "") or ""
if isinstance(description, str) and description:
flat[_component_field_key(norm_key, "description", description)] = description
# Tier 2 — input field display_names, info, and placeholder
for inp in getattr(cls, "inputs", []) or []:
field_display = getattr(inp, "display_name", None)
field_name = getattr(inp, "name", None)
field_info = getattr(inp, "info", None)
field_placeholder = getattr(inp, "placeholder", None)
if isinstance(field_name, str) and field_name:
if isinstance(field_display, str) and field_display:
flat[_component_field_key(norm_key, f"inputs.{field_name}.display_name", field_display)] = (
field_display
)
if isinstance(field_info, str) and field_info:
flat[_component_field_key(norm_key, f"inputs.{field_name}.info", field_info)] = field_info
if isinstance(field_placeholder, str) and field_placeholder:
flat[_component_field_key(norm_key, f"inputs.{field_name}.placeholder", field_placeholder)] = (
field_placeholder
)
# Tier 2 — output display_names and info
for out in getattr(cls, "outputs", []) or []:
out_display = getattr(out, "display_name", None)
out_name = getattr(out, "name", None)
out_info = getattr(out, "info", None)
if isinstance(out_name, str) and out_name:
if isinstance(out_display, str) and out_display:
flat[_component_field_key(norm_key, f"outputs.{out_name}.display_name", out_display)] = (
out_display
)
if isinstance(out_info, str) and out_info:
flat[_component_field_key(norm_key, f"outputs.{out_name}.info", out_info)] = out_info
# Tier 3 — starter project names & descriptions (auto-discovered from JSON files)
starter_count = 0
for project_file in sorted(STARTER_PROJECTS_DIR.glob("*.json")):
try:
with project_file.open(encoding="utf-8") as f:
project = json.load(f)
except Exception: # noqa: BLE001, S112
continue
name = project.get("name")
description = project.get("description", "")
if name and isinstance(name, str):
key = _safe_key(name)
flat[f"starter_flows.{key}.name"] = name
starter_count += 1
if description and isinstance(description, str):
flat[f"starter_flows.{key}.description"] = description
print(f"Found {starter_count} starter project(s) in {STARTER_PROJECTS_DIR.name}/")
# Tier 4 — note node descriptions in starter projects (keys baked by bake_note_keys.py)
note_count = 0
missing_keys: list[str] = []
for project_file in sorted(STARTER_PROJECTS_DIR.glob("*.json")):
try:
with project_file.open(encoding="utf-8") as f:
project = json.load(f)
except Exception: # noqa: BLE001, S112
continue
nodes = project.get("data", {}).get("nodes", [])
for node in nodes:
if node.get("type") != "noteNode":
continue
node_data = node.get("data", {}).get("node", {})
i18n_key = node_data.get("i18n_key")
description = node_data.get("description", "")
if not i18n_key:
missing_keys.append(project_file.name)
continue
if description and isinstance(description, str):
flat[i18n_key] = description
note_count += 1
if missing_keys:
print(
f"WARNING: {len(missing_keys)} noteNode(s) are missing i18n_key. "
"Run scripts/gp/bake_note_keys.py to assign keys."
)
print(f"Found {note_count} note node(s) across starter projects.")
# Shared tool-mode output — injected dynamically on every component when tool_mode is
# enabled, so it's never part of any component's static output list. Uses the sentinel
# norm "_toolmode" so the runtime translator can look it up with a single shared key.
# Constants are inlined (not imported from lfx.base) so this script can run without
# the full lfx package installed, matching the pattern used in bake_note_keys.py.
_tool_output_name = "component_as_tool"
_tool_output_display_name = "Toolset"
flat[_component_field_key("_toolmode", f"outputs.{_tool_output_name}.display_name", _tool_output_display_name)] = (
_tool_output_display_name
)
return dict(sorted(flat.items()))
def main() -> None:
parser = argparse.ArgumentParser(description="Extract backend component strings to locales/en.json")
parser.add_argument(
"--check",
action="store_true",
help="Diff mode: exit 1 if en.json would change (use in CI)",
)
args = parser.parse_args()
print("Scanning lfx.components for translatable strings...")
strings = collect_strings()
print(
f"Found {len(strings)} translatable keys across "
f"{sum(1 for k in strings if k.endswith('.display_name') and '.inputs.' not in k and '.outputs.' not in k)}"
" components."
)
new_content = json.dumps(strings, ensure_ascii=False, indent=2) + "\n"
if args.check:
if OUTPUT_PATH.exists():
existing = OUTPUT_PATH.read_text(encoding="utf-8")
if existing == new_content:
print("OK: locales/en.json is up to date.")
sys.exit(0)
else:
print("FAIL: locales/en.json is out of sync. Run extract_backend_strings.py to update it.")
sys.exit(1)
else:
print("FAIL: locales/en.json does not exist. Run extract_backend_strings.py to create it.")
sys.exit(1)
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
OUTPUT_PATH.write_text(new_content, encoding="utf-8")
print(f"Written to {OUTPUT_PATH}")
if __name__ == "__main__":
main()