"""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