"""Block-bibliotek: återanvändbara flödesdelar i grafform (`{name, description, nodes, edges, settings?}`). Medföljande block ligger i repo-mappen `blocks/` (läs-bara), användarens egna i `data/blocks/`. Spegel av rpa/flows.py. Gamla `{steps}`-block migreras vid inläsning. """ from __future__ import annotations import json import re from pathlib import Path from . import config from .migrate import steps_to_graph from .schema import validate_graph NAME_RE = re.compile(r"^[a-zA-Z0-9_\-åäöÅÄÖ ]{1,80}$") class InvalidBlockName(ValueError): pass class BlockNotFound(FileNotFoundError): pass class InvalidBlock(ValueError): def __init__(self, errors: list[str]): self.errors = errors super().__init__("Ogiltigt block:\n- " + "\n- ".join(errors)) def _check_name(name: str) -> str: if not NAME_RE.match(name or ""): raise InvalidBlockName(f"Ogiltigt blocknamn: {name!r}") return name def _read(path: Path, builtin: bool) -> dict: data = json.loads(path.read_text(encoding="utf-8")) if data.get("nodes") is None and "steps" in data: g = steps_to_graph(data.get("steps", [])) data = {**data, "nodes": g["nodes"], "edges": g["edges"], "start": g["start"]} return { "name": data.get("name") or path.stem, "description": data.get("description", ""), "nodes": data.get("nodes", []), "edges": data.get("edges", []), "start": data.get("start"), "settings": data.get("settings", {}), "builtin": builtin, } def list_blocks() -> list[dict]: """Metadata för alla block (medföljande + användarens; användarens vinner vid namnkrock).""" out: dict[str, dict] = {} for d, builtin in ((config.BUILTIN_BLOCKS_DIR, True), (config.BLOCKS_DIR, False)): if not d.exists(): continue for p in sorted(d.glob("*.json")): try: b = _read(p, builtin) except (json.JSONDecodeError, OSError): continue out[b["name"]] = { "name": b["name"], "description": b["description"], "step_count": len(b["nodes"]), "builtin": builtin, } return sorted(out.values(), key=lambda b: b["name"].lower()) def load_block(name: str) -> dict: _check_name(name) for d, builtin in ((config.BLOCKS_DIR, False), (config.BUILTIN_BLOCKS_DIR, True)): p = d / f"{name}.json" if p.exists(): return _read(p, builtin) raise BlockNotFound(f"Blocket finns inte: {name}") def save_block(name: str, block: dict) -> None: _check_name(name) if block.get("nodes") is None and "steps" in block: g = steps_to_graph(block.get("steps", [])) block = {**block, "nodes": g["nodes"], "edges": g["edges"]} normalized = { "name": name, "description": str(block.get("description", "")), "nodes": block.get("nodes", []), "edges": block.get("edges", []), "settings": block.get("settings", {}), } # ett block är ett fragment - ingen krav på exakt en start errors = [ e for e in validate_graph(normalized, strict=False) if "startnod" not in e.lower() ] if errors: raise InvalidBlock(errors) config.BLOCKS_DIR.mkdir(parents=True, exist_ok=True) (config.BLOCKS_DIR / f"{name}.json").write_text( json.dumps(normalized, ensure_ascii=False, indent=2), encoding="utf-8" ) def delete_block(name: str) -> bool: _check_name(name) p = config.BLOCKS_DIR / f"{name}.json" if p.exists(): p.unlink() return True return False