Files

165 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""Diff two .drawio diagrams into a colour-coded autolayout graph JSON.
Compares an OLD and a NEW .drawio and emits a single graph where every node and
edge is tinted by what happened to it:
added (in new only) -> green
removed (in old only) -> red, dashed
changed (matched, label moved) -> orange
same (matched, unchanged) -> grey
The output is a normal graph JSON — feed it to autolayout.py for one clean,
freshly laid-out "what changed" diagram:
python3 drawiodiff.py old.drawio new.drawio -o diff.json
python3 autolayout.py diff.json -o diff.drawio
Nodes are matched by cell **id** (the default) — perfect for diagrams the
bundled importers generate, whose ids are stable semantic keys
(`aws_instance.web`, `shop-db-1`), so two snapshots line up exactly. This makes
it the natural companion to the live-infra importers: snapshot `terraform show
-json` / `docker inspect` / `kubectl get -o json` twice and diff the two to see
drift. For hand-drawn diagrams whose ids are random, pass `--by-label` to match
on the visible label text instead.
Only leaf vertices and the edges between them are compared; container/group
cells and edge labels are skipped. The diff is a flat colour-coded view, so the
original icons/shapes are replaced by status colours (the label is kept).
Usage: python3 drawiodiff.py <old.drawio> <new.drawio> [-o diff.json]
[--direction TB|LR] [--by-label]
"""
import argparse
import json
import sys
import xml.etree.ElementTree as ET
STYLE = {
"added": "rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;",
"removed": "rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;dashed=1;",
"changed": "rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;",
"same": "rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;",
}
EDGE_STYLE = {
"added": "endArrow=classic;html=1;strokeColor=#82b366;strokeWidth=2;",
"removed": "endArrow=classic;html=1;strokeColor=#b85450;strokeWidth=2;dashed=1;",
"same": "endArrow=classic;html=1;strokeColor=#999999;",
}
def parse(path):
"""Return (nodes, edges) for a .drawio: nodes {id: (label, style)} for leaf
vertices, edges {(source_id, target_id)}. Cells are flattened across pages;
UserObject/object wrappers are unwrapped (id on the wrapper, cell inside)."""
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
nodes, edges = {}, set()
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.add((s, t))
elif c.get("vertex") == "1" and cid not in parents: # leaf vertices only
if "edgeLabel" in (c.get("style") or ""):
continue
g = c.find("mxGeometry")
if g is not None and g.get("relative") == "1": # edge-label child
continue
nodes[cid] = (labels.get(cid, ""), c.get("style") or "")
return nodes, edges
def main():
ap = argparse.ArgumentParser(description="Diff two .drawio files -> autolayout graph JSON.")
ap.add_argument("old", help="baseline .drawio")
ap.add_argument("new", help="updated .drawio")
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
ap.add_argument("--by-label", action="store_true",
help="match nodes by visible label instead of cell id "
"(for hand-drawn diagrams with non-stable ids)")
args = ap.parse_args()
old_n, old_e = parse(args.old)
new_n, new_e = parse(args.new)
def keyed(nodes):
"""Map match-key -> label. By id (default) the key is the cell id and the
value is its label; by label the key *is* the label."""
if args.by_label:
return {lbl: lbl for lbl, _ in nodes.values()}, {i: lbl for i, (lbl, _) in nodes.items()}
return {i: lbl for i, (lbl, _) in nodes.items()}, {i: i for i in nodes}
old_keys, old_id2key = keyed(old_n)
new_keys, new_id2key = keyed(new_n)
nodes, counts = [], {"added": 0, "removed": 0, "changed": 0, "same": 0}
for key in sorted(set(old_keys) | set(new_keys)):
if key in old_keys and key not in new_keys:
status, label = "removed", old_keys[key]
elif key in new_keys and key not in old_keys:
status, label = "added", new_keys[key]
elif old_keys[key] != new_keys[key]: # matched, label moved
status, label = "changed", new_keys[key]
else:
status, label = "same", new_keys[key]
counts[status] += 1
nodes.append({"id": key, "label": label or key, "style": STYLE[status],
"width": 160, "height": 60})
def edge_keys(edges, id2key):
out = set()
for s, t in edges:
if s in id2key and t in id2key:
out.add((id2key[s], id2key[t]))
return out
old_ek, new_ek = edge_keys(old_e, old_id2key), edge_keys(new_e, new_id2key)
node_keys = {n["id"] for n in nodes}
edges = []
for s, t in sorted(old_ek | new_ek):
if s not in node_keys or t not in node_keys:
continue
status = "same" if (s, t) in old_ek and (s, t) in new_ek else \
("added" if (s, t) in new_ek else "removed")
edges.append({"source": s, "target": t, "style": EDGE_STYLE[status]})
graph = {"direction": args.direction, "nodes": nodes, "edges": edges}
text = json.dumps(graph, indent=2)
if args.output:
with open(args.output, "w", encoding="utf-8") as f:
f.write(text)
sys.stderr.write(f"wrote {args.output}\n")
else:
sys.stdout.write(text)
sys.stderr.write(f"+{counts['added']} added, -{counts['removed']} removed, "
f"~{counts['changed']} changed, ={counts['same']} unchanged\n")
if __name__ == "__main__":
main()