392 lines
15 KiB
Python
392 lines
15 KiB
Python
"""Derive a web-form schema from a Langflow export.
|
|
|
|
Looks for form nodes — ``CustomInput*`` (``data.type`` or the inner
|
|
``display_name`` starting with ``CustomInput``, see
|
|
``backend/components/kirche/form_inputs.py``) plus user-built
|
|
``CustomComponent`` nodes whose display name ends with ``"Form"``
|
|
(e.g. ``Mitarbeiter Einstellen Form``) — and maps their ``template``
|
|
(minus ``code``) to ``{json_schema, ui_schema}`` for RJSF:
|
|
|
|
* ``name`` → property key (freely renamable in the Langflow code editor;
|
|
kept verbatim so frontend payload keys match the runtime output —
|
|
only empty names are skipped and collisions get a ``_2``/``_3`` suffix),
|
|
``display_name`` → ``title``,
|
|
``info`` → ``description``, ``value`` → ``default``,
|
|
``options`` → ``enum``, ``required`` → required array
|
|
* ``show: false`` fields are plumbing (e.g. the run envelope input), not
|
|
form fields, and are skipped
|
|
* ``advanced`` fields render last via ``ui:order``
|
|
* ``_input_type`` → JSON type + widget (unknown types fall back to string
|
|
so the parser never crashes on new fields)
|
|
* ``[visible_if ...]`` markers in ``info`` → conditional branches
|
|
(``allOf`` with ``if``/``then`` per dependent field, arbitrarily nested;
|
|
see ``_extract_visibility``). The marker is stripped from the shown
|
|
description. Preferred: structured ``visible_if`` key (list of
|
|
``{field, value}`` or ``{field: value}``) — no quoting mini-language,
|
|
rename-refactorable, validated at sync time with warnings instead of
|
|
silent fail-open. Markers stay as deprecated fallback.
|
|
|
|
A flow without such a node gets ``{fallback: "textarea"}``: the frontend
|
|
renders one free-text box instead of a generated form.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
#: ``[visible_if gebaeude=Kirche & bestandteil="Glocke, Orgel"]`` in a
|
|
#: field's ``info``. Authored in the Langflow code editor; the parser strips
|
|
#: the marker from the shown description and turns the conditions into
|
|
#: ``allOf``/``if``/``then`` branches (draft-07 — the ``dependencies``/
|
|
#: ``if`` keywords this stack's RJSF + ajv build honors; ``dependentSchemas``
|
|
#: is ignored there, so it is never emitted).
|
|
_VISIBLE_RE = re.compile(r"\[visible_if\s+([^\]]+)\]")
|
|
|
|
|
|
def _form_templates(langflow_json: dict) -> list[dict]:
|
|
"""Inner node dicts (``data.node``) of all form nodes."""
|
|
if not isinstance(langflow_json, dict):
|
|
return []
|
|
data = langflow_json.get("data") or {}
|
|
nodes = data.get("nodes") or []
|
|
found = []
|
|
for node in nodes:
|
|
if not isinstance(node, dict):
|
|
continue
|
|
node_data = node.get("data") or {}
|
|
node_type = str(node_data.get("type") or "")
|
|
inner = node_data.get("node") or {}
|
|
display = str(inner.get("display_name") or "")
|
|
if node_type.startswith("CustomInput") or display.startswith("CustomInput"):
|
|
found.append(inner)
|
|
elif node_type == "CustomComponent" and display.endswith("Form"):
|
|
# User-built form components (e.g. "Mitarbeiter Einstellen
|
|
# Form"): same template contract, no kirche base needed.
|
|
found.append(inner)
|
|
return found
|
|
|
|
|
|
def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
|
|
"""(JSON-Schema property, uiSchema entry) for one template field."""
|
|
input_type = str(field.get("_input_type") or "")
|
|
prop: dict = {}
|
|
ui: dict = {}
|
|
title = field.get("display_name") or name
|
|
info = field.get("info") or ""
|
|
if title:
|
|
prop["title"] = title
|
|
if info:
|
|
prop["description"] = info
|
|
|
|
options = field.get("options") or []
|
|
if input_type in ("StrInput", "MessageTextInput"):
|
|
prop["type"] = "string"
|
|
elif input_type == "MultilineInput":
|
|
prop["type"] = "string"
|
|
ui["ui:widget"] = "textarea"
|
|
elif input_type == "SecretStrInput":
|
|
prop["type"] = "string"
|
|
ui["ui:widget"] = "password"
|
|
elif input_type == "IntInput":
|
|
prop["type"] = "integer"
|
|
elif input_type == "FloatInput":
|
|
prop["type"] = "number"
|
|
elif input_type == "BoolInput":
|
|
prop["type"] = "boolean"
|
|
elif input_type == "DropdownInput":
|
|
prop["type"] = "string"
|
|
if options:
|
|
prop["enum"] = list(options)
|
|
elif input_type == "MultiselectInput":
|
|
prop["type"] = "array"
|
|
items: dict = {"type": "string"}
|
|
if options:
|
|
items["enum"] = list(options)
|
|
prop["items"] = items
|
|
prop["uniqueItems"] = True
|
|
elif input_type == "SliderInput":
|
|
prop["type"] = "number"
|
|
spec = field.get("range_spec") or {}
|
|
if spec.get("min") is not None:
|
|
prop["minimum"] = spec["min"]
|
|
if spec.get("max") is not None:
|
|
prop["maximum"] = spec["max"]
|
|
if spec.get("step"):
|
|
prop["multipleOf"] = spec["step"]
|
|
ui["ui:widget"] = "range"
|
|
elif input_type == "FileInput":
|
|
# Stub for now: string reference only, real upload comes later.
|
|
prop["type"] = "string"
|
|
else:
|
|
prop["type"] = "string"
|
|
|
|
value = field.get("value")
|
|
if value is not None:
|
|
# Whitespace-only string values are the flow generator's sentinel
|
|
# for "required, filled at runtime" (see make_flow_fixtures.py) —
|
|
# not a real default for the web form.
|
|
if isinstance(value, str) and not value.strip():
|
|
return prop, ui
|
|
# A default outside its enum would fail validation on touch.
|
|
enum = prop.get("enum")
|
|
if not (enum is not None and value not in enum):
|
|
# bool is an int subclass; keep integer defaults clean.
|
|
if prop.get("type") == "integer" and isinstance(value, bool):
|
|
pass
|
|
else:
|
|
prop["default"] = value
|
|
return prop, ui
|
|
|
|
|
|
def _split_conditions(text: str) -> list[str]:
|
|
"""Split ``a=X & b="Y Z"`` on ``&`` outside double quotes."""
|
|
parts: list[str] = []
|
|
buf: list[str] = []
|
|
quoted = False
|
|
for char in text:
|
|
if char == '"':
|
|
quoted = not quoted
|
|
buf.append(char)
|
|
elif char == "&" and not quoted:
|
|
parts.append("".join(buf))
|
|
buf = []
|
|
else:
|
|
buf.append(char)
|
|
parts.append("".join(buf))
|
|
return [part.strip() for part in parts if part.strip()]
|
|
|
|
|
|
def _parse_condition(token: str) -> tuple[str, object] | None:
|
|
"""``feld=Wert`` → ``(feld, wert)`` (bool/number coerced, quotes stripped)."""
|
|
field, sep, value = token.partition("=")
|
|
field, value = field.strip(), value.strip()
|
|
if not sep or not field or not value:
|
|
return None
|
|
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
|
value = value[1:-1]
|
|
lowered = value.lower()
|
|
if lowered == "true":
|
|
return field, True
|
|
if lowered == "false":
|
|
return field, False
|
|
try:
|
|
return field, int(value)
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
return field, float(value)
|
|
except ValueError:
|
|
pass
|
|
return field, value
|
|
|
|
|
|
def _extract_visibility(
|
|
info: str, field: dict | None = None
|
|
) -> tuple[str, list[tuple[str, object]]]:
|
|
"""(marker-free description, conditions) for a field's ``info`` text.
|
|
|
|
Structured ``visible_if`` on the field wins (list of {field, value} or
|
|
dict); legacy ``[visible_if ...]`` markers in ``info`` stay as fallback.
|
|
"""
|
|
if field is not None:
|
|
structured = field.get("visible_if")
|
|
if structured:
|
|
conditions: list[tuple[str, object]] = []
|
|
items = structured.items() if isinstance(structured, dict) else structured
|
|
for item in items or []:
|
|
if isinstance(item, dict) and "field" in item:
|
|
conditions.append((item["field"], item.get("value")))
|
|
elif isinstance(item, (list, tuple)) and len(item) == 2:
|
|
conditions.append((item[0], item[1]))
|
|
if conditions:
|
|
clean = re.sub(r"\s+", " ", (info or "")).strip()
|
|
return clean, conditions
|
|
conditions = []
|
|
|
|
def _collect(match: re.Match) -> str:
|
|
for token in _split_conditions(match.group(1)):
|
|
parsed = _parse_condition(token)
|
|
if parsed is not None:
|
|
conditions.append(parsed)
|
|
return ""
|
|
|
|
clean = _VISIBLE_RE.sub(_collect, info or "")
|
|
clean = re.sub(r"\s+", " ", clean).strip()
|
|
return clean, conditions
|
|
|
|
|
|
def _match_schema(trigger_field: dict, value: object) -> dict | None:
|
|
"""JSON-Schema fragment matching one condition, or None (unsupported)."""
|
|
input_type = str(trigger_field.get("_input_type") or "")
|
|
if input_type == "MultiselectInput":
|
|
return {"contains": {"const": value}}
|
|
if input_type == "BoolInput":
|
|
return {"const": value} if isinstance(value, bool) else None
|
|
if input_type in (
|
|
"StrInput",
|
|
"MessageTextInput",
|
|
"MultilineInput",
|
|
"SecretStrInput",
|
|
"DropdownInput",
|
|
):
|
|
return {"const": value} if isinstance(value, str) else None
|
|
if input_type in ("IntInput", "FloatInput"):
|
|
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
return None
|
|
return {"const": value}
|
|
return None
|
|
|
|
|
|
def _cyclic_fields(edges: dict[str, set[str]]) -> set[str]:
|
|
"""Fields on a trigger→dependent cycle (fail-open candidates)."""
|
|
cyclic: set[str] = set()
|
|
visiting: list[str] = []
|
|
|
|
def _visit(node: str) -> None:
|
|
visiting.append(node)
|
|
for target in edges.get(node, ()):
|
|
if target in visiting:
|
|
cyclic.update(visiting[visiting.index(target) :])
|
|
elif target not in cyclic:
|
|
_visit(target)
|
|
visiting.pop()
|
|
|
|
for node in edges:
|
|
if node not in cyclic:
|
|
_visit(node)
|
|
return cyclic
|
|
|
|
|
|
def extract_form_schema(langflow_json: dict) -> dict:
|
|
"""``{json_schema, ui_schema}``, or with ``fallback: "textarea"``."""
|
|
templates = _form_templates(langflow_json)
|
|
if not templates:
|
|
return {
|
|
"json_schema": {"type": "object", "properties": {}},
|
|
"ui_schema": {},
|
|
"fallback": "textarea",
|
|
}
|
|
template = templates[0].get("template") or {}
|
|
# First pass: sanitize names (existing rules) and parse markers.
|
|
records: list[dict] = []
|
|
taken: set[str] = set()
|
|
for key, field in template.items():
|
|
if key == "code" or not isinstance(field, dict):
|
|
continue
|
|
if field.get("show") is False:
|
|
continue
|
|
raw_name = field.get("name") or key
|
|
name = str(raw_name).strip()
|
|
if not name:
|
|
continue
|
|
if name in taken:
|
|
suffix = 2
|
|
while f"{name}_{suffix}" in taken:
|
|
suffix += 1
|
|
name = f"{name}_{suffix}"
|
|
taken.add(name)
|
|
clean_info, conditions = _extract_visibility(str(field.get("info") or ""), field)
|
|
records.append(
|
|
{
|
|
"name": name,
|
|
"field": {**field, "info": clean_info},
|
|
"required": bool(field.get("required")),
|
|
"advanced": bool(field.get("advanced")),
|
|
"conditions": conditions,
|
|
}
|
|
)
|
|
by_name = {record["name"]: record for record in records}
|
|
# Validate conditions: known trigger + supported match. Anything else
|
|
# fails open (field stays always visible) so a stale marker can never
|
|
# hide data or break validation.
|
|
matches: dict[str, list[tuple[str, dict]]] = {}
|
|
for record in records:
|
|
resolved: list[tuple[str, dict]] = []
|
|
for trigger, value in record["conditions"]:
|
|
trigger_record = by_name.get(trigger)
|
|
match = _match_schema(trigger_record["field"], value) if trigger_record else None
|
|
if trigger_record is None or match is None or trigger == record["name"]:
|
|
resolved = []
|
|
break
|
|
resolved.append((trigger, match))
|
|
if resolved:
|
|
matches[record["name"]] = resolved
|
|
for name in _cyclic_fields(
|
|
{name: {trigger for trigger, _ in conds} for name, conds in matches.items()}
|
|
):
|
|
del matches[name]
|
|
# Emit: conditional fields live only in their if/then branch (that is
|
|
# what hides them in RJSF); everything else stays at root.
|
|
properties: dict = {}
|
|
required: list[str] = []
|
|
ui_schema: dict = {}
|
|
normal: list[str] = []
|
|
advanced: list[str] = []
|
|
for record in records:
|
|
name = record["name"]
|
|
prop, ui = _field_schema(name, record["field"])
|
|
if name not in matches:
|
|
properties[name] = prop
|
|
if record["required"]:
|
|
required.append(name)
|
|
else:
|
|
record["branch_prop"] = prop
|
|
if ui:
|
|
ui_schema[name] = ui
|
|
(advanced if record["advanced"] else normal).append(name)
|
|
json_schema: dict = {"type": "object", "properties": properties, "required": required}
|
|
if matches:
|
|
rules = []
|
|
for name, conds in matches.items():
|
|
record = by_name[name]
|
|
rules.append(
|
|
{
|
|
"if": {
|
|
"properties": {trigger: match for trigger, match in conds},
|
|
"required": [trigger for trigger, _ in conds],
|
|
},
|
|
"then": {
|
|
"properties": {name: record["branch_prop"]},
|
|
**({"required": [name]} if record["required"] else {}),
|
|
},
|
|
}
|
|
)
|
|
json_schema["allOf"] = rules
|
|
ui_schema["ui:order"] = normal + advanced + ["*"]
|
|
elif advanced:
|
|
ui_schema["ui:order"] = normal + advanced + ["*"]
|
|
return {"json_schema": json_schema, "ui_schema": ui_schema}
|
|
|
|
|
|
def schema_field_names(cached: dict) -> list[str]:
|
|
"""All asked field names of a cached form schema (root + branches).
|
|
|
|
Used for the run envelope's ``fields`` list so the runtime emits
|
|
defaults only for asked fields. Order: root properties first, then
|
|
branch discoveries.
|
|
"""
|
|
if not isinstance(cached, dict):
|
|
return []
|
|
json_schema = cached.get("json_schema") or {}
|
|
names: list[str] = []
|
|
|
|
def _walk(node: object) -> None:
|
|
if not isinstance(node, dict):
|
|
return
|
|
for subschema in (node.get("then"), node.get("else")):
|
|
if isinstance(subschema, dict):
|
|
for key in subschema.get("properties") or {}:
|
|
if key not in names:
|
|
names.append(key)
|
|
_walk(subschema)
|
|
for key in ("allOf", "oneOf", "anyOf"):
|
|
items = node.get(key) or []
|
|
if isinstance(items, list):
|
|
for item in items:
|
|
_walk(item)
|
|
|
|
for key in json_schema.get("properties") or {}:
|
|
if key not in names:
|
|
names.append(key)
|
|
_walk(json_schema)
|
|
return names
|