"""Flödespersistens - läsa/skriva/lista/ta bort de JSON-flöden som ligger i data/flows/. Utbrutet ur webui så att både webui:t och MCP-servern (rpa/mcp_server.py) delar exakt samma regler (namnvalidering, schema-validering vid spara). Flöden lagras som nod/kant-grafen ({format:"graph", nodes, edges, start, ...}). Gamla {steps}-flöden läses fortfarande - de migreras vid inläsning och skrivs om till grafformat vid nästa sparning. """ from __future__ import annotations import json import re from pathlib import Path from . import config from .migrate import flow_to_graph from .schema import validate_graph NAME_RE = re.compile(r"^[a-zA-Z0-9_\-åäöÅÄÖ ]{1,80}$") _SLUG_RE = re.compile(r"[^a-z0-9]+") def _slug(text: str) -> str: s = _SLUG_RE.sub("-", str(text).strip().lower()).strip("-") return s or "sub" class InvalidFlowName(ValueError): pass class FlowNotFound(FileNotFoundError): pass class InvalidFlow(ValueError): """Flödet strider mot schemat (rpa/schema.py:validate_graph).""" def __init__(self, errors: list[str]): self.errors = errors super().__init__("Ogiltigt flöde:\n- " + "\n- ".join(errors)) def flow_path(name: str) -> Path: if not NAME_RE.match(name or ""): raise InvalidFlowName(f"Ogiltigt flödesnamn: {name!r}") return config.FLOWS_DIR / f"{name}.json" def list_flows() -> list[str]: return sorted(p.stem for p in config.FLOWS_DIR.glob("*.json")) def _normalize(flow: dict) -> dict: """Alltid grafform, med alla nycklar defaultade.""" flow = flow_to_graph(flow) # migrerar {steps} vid behov, no-op annars flow.setdefault("format", "graph") flow.setdefault("nodes", []) flow.setdefault("edges", []) flow.setdefault("start", None) flow.setdefault("settings", {}) flow.setdefault("layout", {}) flow.setdefault("inputs", []) flow.setdefault("input_sets", {}) flow["subflows"] = [_normalize_subflow(sf) for sf in flow.get("subflows") or [] if isinstance(sf, dict)] return flow def _normalize_subflow(sf: dict) -> dict: name = str(sf.get("name") or sf.get("id") or "Subflöde") return { "id": str(sf.get("id") or _slug(name)), "name": name, "nodes": sf.get("nodes", []), "edges": sf.get("edges", []), "start": sf.get("start"), "inputs": sf.get("inputs", []), } def load_flow(name: str) -> dict: path = flow_path(name) if not path.exists(): raise FlowNotFound(f"Flödet finns inte: {name}") return _normalize(json.loads(path.read_text(encoding="utf-8"))) def save_flow(name: str, flow: dict, *, strict: bool = False) -> None: """Sparar ett flöde (grafform). Kastar InvalidFlow om det strider mot schemat. strict=False (default) tillåter halvfärdiga flöden med tomma obligatoriska fält - webui:t autosparar under redigering. MCP-servern använder strict=True.""" path = flow_path(name) g = _normalize(flow) errors = validate_graph(g, strict=strict) if errors: raise InvalidFlow(errors) inputs = g.get("inputs") if isinstance(inputs, list) and inputs: bad = [i for i, x in enumerate(inputs) if not isinstance(x, dict) or not str(x.get("name", "")).strip()] if bad: raise InvalidFlow([f"inputs[{i}]: varje indata måste ha ett 'name'." for i in bad]) out: dict = { "format": "graph", "nodes": g["nodes"], "edges": g["edges"], "settings": g["settings"], } if g.get("start"): out["start"] = g["start"] for key in ("inputs", "input_sets", "layout", "subflows"): val = g.get(key) if val: out[key] = val path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") def delete_flow(name: str) -> bool: path = flow_path(name) if path.exists(): path.unlink() return True return False