mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-25 03:29:00 +08:00
- Extract safe_flow_key/normalize_component_key/content_hash/component_field_key into langflow/utils/i18n_keys.py so runtime translator and extract script share one source of truth (C1) - Validate Accept-Language against supported locales in set_locale middleware; unknown values fall back to "en" to prevent cache pollution (C2) - Delete dead `remaining = ...` block in check_backend_status.py that made a redundant network call and never used the result (I2) - Log logger.warning instead of silently swallowing OSError/JSONDecodeError in _load_translations (I3) - Rename test_strips_dedup_suffix → test_parentheses_in_name_become_underscores with corrected comment (I5) - Remove redundant `import copy as _copy` inside translate_flow_notes (R2) - Move translate_flow_notes/translate_starter_flows import to flows.py module top (R1) - Add # Why: comments on max_size=16, uncompressed cache, and verify=False (R5/I8/M3) - Wrap loadLanguage dynamic import in try/catch so unknown locales don't crash React with an unhandled rejection; add i18n.test.ts to cover the fallback
214 lines
9.0 KiB
Python
214 lines
9.0 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
|
|
|
|
from langflow.utils.i18n_keys import (
|
|
component_field_key as _component_field_key,
|
|
content_hash as _content_hash,
|
|
normalize_component_key as _normalize_component_key,
|
|
safe_flow_key as _safe_key,
|
|
)
|
|
|
|
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."""
|
|
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
|
|
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
|
|
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
|
|
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.")
|
|
|
|
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()
|