"""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. 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) -> tuple[str, list[tuple[str, object]]]: """(marker-free description, conditions) for a field's ``info`` text.""" conditions: list[tuple[str, object]] = [] 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 "")) 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