#!/usr/bin/env python3 """Turn a flowchart / decision-tree .drawio into a click-through HTML runbook. Parses the nodes and edges out of a .drawio and infers a node "type" from its shape style (ellipse -> start/end, rhombus -> decision, parallelogram -> io, else process). The ellipse with no incoming edges is taken as the start node. The output is a single self-contained HTML page: the current node's text front and center, one button per outgoing edge (labeled with the edge's choice text, or "Continue" when a node has a single unlabeled successor), a breadcrumb trail of visited nodes, Back/Restart controls, and an "end" state on terminal nodes (no outgoing edges). No draw.io CLI is needed -- the XML is read and the HTML is built directly, so the whole script is testable without any external tool. python3 runbook.py triage.drawio -o triage.html Usage: python3 runbook.py [-o out.html] """ import argparse import html import json import os import sys import xml.etree.ElementTree as ET def parse(path): """Return (nodes, edges, start_id). nodes: {id: {"label": str, "type": "start"|"end"|"decision"|"io"|"process"}} edges: [{"source": id, "target": id, "label": str}, ...] in document order. Cells are flattened across pages; UserObject/object wrappers are unwrapped (id on the wrapper, cell inside) -- mirrors drawiodiff.py parse(). """ try: tree = ET.parse(path) except (ET.ParseError, OSError) as exc: sys.exit(f"error: cannot parse {path}: {exc}") pages = tree.getroot().findall("diagram") or [tree.getroot()] cells, labels = [], {} for page in pages: model = page.find("mxGraphModel") root = model.find("root") if model is not None else None if root is None: if (page.text or "").strip(): sys.stderr.write(f"warning: {path}: a page is compressed, skipped\n") continue for child in root: if child.tag == "mxCell": cells.append(child) labels[child.get("id")] = child.get("value") or "" elif child.tag in ("UserObject", "object"): inner = child.find("mxCell") if inner is not None: inner.set("id", child.get("id", "")) cells.append(inner) labels[child.get("id")] = child.get("label") or child.get("value") or "" parents = {c.get("parent") for c in cells} # ids that have children order, styles, edges = [], {}, [] for c in cells: cid = c.get("id") if c.get("edge") == "1": s, t = c.get("source"), c.get("target") if s and t: edges.append({"source": s, "target": t, "label": labels.get(cid, "")}) elif c.get("vertex") == "1" and cid not in parents: # leaf vertices only style = c.get("style") or "" if "edgeLabel" in style: continue g = c.find("mxGeometry") if g is not None and g.get("relative") == "1": # edge-label child continue order.append(cid) styles[cid] = style indeg = {i: 0 for i in order} outdeg = {i: 0 for i in order} for e in edges: if e["source"] in outdeg: outdeg[e["source"]] += 1 if e["target"] in indeg: indeg[e["target"]] += 1 nodes = {} for nid in order: style = styles[nid] if "ellipse" in style: ntype = "end" if outdeg[nid] == 0 and indeg[nid] > 0 else "start" elif "rhombus" in style: ntype = "decision" elif "parallelogram" in style: ntype = "io" else: ntype = "process" nodes[nid] = {"label": labels.get(nid, ""), "type": ntype} edges = [e for e in edges if e["source"] in nodes and e["target"] in nodes] # Start node: the ellipse with no incoming edges; else the unique in-degree-0 # node; else the first node in document order. Warn to stderr if ambiguous. ellipse_zero_in = [nid for nid in order if "ellipse" in styles[nid] and indeg[nid] == 0] if len(ellipse_zero_in) == 1: start_id = ellipse_zero_in[0] elif len(ellipse_zero_in) > 1: sys.stderr.write("warning: multiple ellipse nodes with in-degree 0; picking the first\n") start_id = ellipse_zero_in[0] else: zero_in = [nid for nid in order if indeg[nid] == 0] if len(zero_in) == 1: start_id = zero_in[0] elif len(zero_in) > 1: sys.stderr.write("warning: no unique in-degree-0 node; picking the first\n") start_id = zero_in[0] elif order: sys.stderr.write("warning: no start node found by heuristics; using the first node\n") start_id = order[0] else: start_id = None return nodes, edges, start_id def build_html(title, nodes, edges, start_id): """One self-contained click-through page. nodes: {id:{label,type}}; edges: [{source,target,label}, ...]; start_id: node id to begin the walk at.""" adjacency = {} for e in edges: adjacency.setdefault(e["source"], []).append({"target": e["target"], "label": e["label"]}) payload = json.dumps({"nodes": nodes, "edges": adjacency, "start": start_id}).replace(" {html.escape(title)}

{html.escape(title)}

End of path -- nothing more to check.

""" def main(): ap = argparse.ArgumentParser(description="Turn a flowchart .drawio into a click-through HTML runbook.") ap.add_argument("file") ap.add_argument("-o", "--output", help="output .html (default: alongside input)") args = ap.parse_args() if not os.path.isfile(args.file): sys.exit(f"error: {args.file} not found") nodes, edges, start_id = parse(args.file) if not nodes: sys.exit(f"error: no nodes found in {args.file}") if start_id is None: sys.exit(f"error: no start node found in {args.file}") title = os.path.splitext(os.path.basename(args.file))[0] out = args.output or os.path.splitext(args.file)[0] + ".html" with open(out, "w", encoding="utf-8") as f: f.write(build_html(title, nodes, edges, start_id)) sys.stderr.write(f"wrote {out} ({len(nodes)} nodes, {len(edges)} edges)\n") if __name__ == "__main__": main()