feat: sync 12 new skills into catalog (total 31 skills), update manifest and README
This commit is contained in:
205
skills/drawio-skill/scripts/aiicons.py
Normal file
205
skills/drawio-skill/scripts/aiicons.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find AI / LLM brand logos (OpenAI, Claude, Gemini, ...) as draw.io styles.
|
||||
|
||||
draw.io's bundled shape libraries have no modern AI/LLM brand logos, so an
|
||||
"LLM app architecture" renders as generic boxes. This resolves a brand name to a
|
||||
draw.io `image` style that references the matching SVG from the lobe-icons set
|
||||
(https://github.com/lobehub/lobe-icons, MIT) on the unpkg CDN.
|
||||
|
||||
python3 aiicons.py "openai"
|
||||
python3 aiicons.py "claude" --json
|
||||
python3 aiicons.py "langchain" --variant mono --size 48
|
||||
|
||||
The icon is referenced by URL (data/lobe-icons.json carries only the name list,
|
||||
not the assets), so draw.io fetches it from the CDN when the diagram is rendered
|
||||
or opened. That means **network is required at render time**; an offline export
|
||||
draws a blank box. Use --embed to fetch the SVG once and inline it as a
|
||||
self-contained data URI instead (portable, no network at render time).
|
||||
|
||||
The logos are trademarks of their respective owners and are referenced here for
|
||||
identification only — the same basis on which draw.io ships AWS/Azure icons.
|
||||
|
||||
Usage: python3 aiicons.py <query> [--limit N] [--variant color|mono|text]
|
||||
[--size PX] [--embed] [--json] [--list]
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
|
||||
MANIFEST = os.path.join(os.path.dirname(__file__), "..", "data", "lobe-icons.json")
|
||||
STYLE = ("shape=image;html=1;imageAspect=0;aspect=fixed;"
|
||||
"verticalLabelPosition=bottom;verticalAlign=top;image=")
|
||||
_VARIANT = re.compile(r"-(?:color|text(?:-[a-z]{2})?|brand(?:-color)?)$")
|
||||
|
||||
# Common RAG/LLM data stores that lobe-icons lacks, mapped to simple-icons
|
||||
# slugs (https://simpleicons.org, CC0). Served from the simple-icons CDN. Each
|
||||
# slug below is verified to return HTTP 200 at https://cdn.simpleicons.org/<slug>.
|
||||
_SIMPLEICONS_CDN = "https://cdn.simpleicons.org/"
|
||||
_SUPPLEMENT = {
|
||||
"qdrant": "qdrant",
|
||||
"milvus": "milvus",
|
||||
"supabase": "supabase",
|
||||
"redis": "redis",
|
||||
"postgresql": "postgresql",
|
||||
"mongodb": "mongodb",
|
||||
"elasticsearch": "elasticsearch",
|
||||
"neo4j": "neo4j",
|
||||
"kafka": "apachekafka",
|
||||
"clickhouse": "clickhouse",
|
||||
"duckdb": "duckdb",
|
||||
"mysql": "mysql",
|
||||
"sqlite": "sqlite",
|
||||
"cassandra": "apachecassandra",
|
||||
"snowflake": "snowflake",
|
||||
"databricks": "databricks",
|
||||
"mariadb": "mariadb",
|
||||
"couchbase": "couchbase",
|
||||
}
|
||||
|
||||
|
||||
def families(icons):
|
||||
"""base brand name -> set of its variant filenames (without .svg)."""
|
||||
fam = {}
|
||||
for name in icons:
|
||||
base = _VARIANT.sub("", name)
|
||||
fam.setdefault(base, set()).add(name)
|
||||
return fam
|
||||
|
||||
|
||||
def squish(s):
|
||||
return re.sub(r"[^a-z0-9]", "", s.lower())
|
||||
|
||||
|
||||
def search(fam, query, limit):
|
||||
"""Rank brand bases against the query (squished + per-token matching)."""
|
||||
q = squish(query)
|
||||
tokens = [t for t in re.findall(r"[a-z0-9]+", query.lower()) if t]
|
||||
scored = {}
|
||||
for base in fam:
|
||||
b = squish(base)
|
||||
s = 0
|
||||
if q and q == b:
|
||||
s = 100
|
||||
elif q and b.startswith(q):
|
||||
s = 60
|
||||
elif q and q in b:
|
||||
s = 40
|
||||
for t in tokens:
|
||||
if t == b:
|
||||
s = max(s, 90)
|
||||
elif len(t) >= 3 and b.startswith(t):
|
||||
s = max(s, 50)
|
||||
elif len(t) >= 3 and t in b:
|
||||
s = max(s, 30)
|
||||
if s:
|
||||
scored[base] = s
|
||||
return sorted(scored, key=lambda base: (-scored[base], base))[:limit]
|
||||
|
||||
|
||||
def search_supplement(query):
|
||||
"""Fall back to the simple-icons supplement (exact or substring match)."""
|
||||
q = squish(query)
|
||||
if not q:
|
||||
return None
|
||||
if q in _SUPPLEMENT:
|
||||
return q
|
||||
for brand in _SUPPLEMENT:
|
||||
if q in brand or brand in q:
|
||||
return brand
|
||||
return None
|
||||
|
||||
|
||||
def pick_variant(base, variants, prefer):
|
||||
order = {"color": ["-color", "-brand-color", "", "-brand", "-text", "-text-cn"],
|
||||
"mono": ["", "-brand", "-color", "-brand-color", "-text", "-text-cn"],
|
||||
"text": ["-text", "-text-cn", "-brand", "-brand-color", "-color", ""]}[prefer]
|
||||
for suffix in order:
|
||||
cand = base + suffix
|
||||
if cand in variants:
|
||||
return cand
|
||||
return next(iter(sorted(variants)), None)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Find AI/LLM brand logos as draw.io styles (lobe-icons via CDN).")
|
||||
ap.add_argument("query", nargs="?", help='brand name, e.g. "openai" or "claude"')
|
||||
ap.add_argument("--limit", type=int, default=8)
|
||||
ap.add_argument("--variant", choices=["color", "mono", "text"], default="color")
|
||||
ap.add_argument("--size", type=int, default=48, help="cell width/height in px (icons are square)")
|
||||
ap.add_argument("--embed", action="store_true",
|
||||
help="inline the SVG as a data URI (fetches it now; portable, no network at render time)")
|
||||
ap.add_argument("--json", action="store_true")
|
||||
ap.add_argument("--list", action="store_true", help="list all brand names and exit")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.exists(MANIFEST):
|
||||
sys.exit(f"error: manifest not found at {MANIFEST}")
|
||||
with open(MANIFEST, encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
fam = families(manifest["icons"])
|
||||
cdn = manifest["cdn"]
|
||||
|
||||
if args.list:
|
||||
for base in sorted(fam):
|
||||
print(base)
|
||||
return
|
||||
if not args.query:
|
||||
ap.error("a query is required (or use --list)")
|
||||
|
||||
matches = search(fam, args.query, args.limit)
|
||||
|
||||
results = []
|
||||
if matches:
|
||||
for base in matches:
|
||||
file = pick_variant(base, fam[base], args.variant)
|
||||
url = f"{cdn}{file}.svg"
|
||||
if args.embed:
|
||||
try:
|
||||
svg = urllib.request.urlopen(url, timeout=15).read()
|
||||
except Exception as exc: # noqa: BLE001 - report and skip
|
||||
sys.stderr.write(f"warning: could not fetch {url} ({exc})\n")
|
||||
continue
|
||||
# Rewrite the 1em intrinsic size so draw.io scales the inlined SVG.
|
||||
svg = svg.replace(b'width="1em"', b'width="24"').replace(b'height="1em"', b'height="24"')
|
||||
# Marker-less base64: draw.io splits style values on ';', so a
|
||||
# ';base64,' marker would truncate the image= value (issue #80).
|
||||
image = "data:image/svg+xml," + base64.b64encode(svg).decode()
|
||||
else:
|
||||
image = url
|
||||
results.append({"brand": base, "file": file, "w": args.size, "h": args.size,
|
||||
"style": STYLE + image})
|
||||
else:
|
||||
# lobe has no logo for this brand; fall back to the simple-icons supplement.
|
||||
brand = search_supplement(args.query)
|
||||
if brand:
|
||||
slug = _SUPPLEMENT[brand]
|
||||
url = _SIMPLEICONS_CDN + slug
|
||||
image = url
|
||||
if args.embed:
|
||||
try:
|
||||
svg = urllib.request.urlopen(url, timeout=15).read()
|
||||
# Marker-less base64 (see issue #80 note above).
|
||||
image = "data:image/svg+xml," + base64.b64encode(svg).decode()
|
||||
except Exception as exc: # noqa: BLE001 - keep the CDN URL
|
||||
sys.stderr.write(f"warning: could not fetch {url} ({exc}); using CDN URL\n")
|
||||
results.append({"brand": brand, "file": f"simpleicons:{slug}",
|
||||
"w": args.size, "h": args.size, "style": STYLE + image})
|
||||
|
||||
if not results:
|
||||
sys.exit(f"no logo for {args.query!r} — for a data store try a cylinder "
|
||||
f"(shape=cylinder3) or shapesearch.py '{args.query} database'")
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
for r in results:
|
||||
shown = r["style"] if len(r["style"]) < 160 else r["style"][:157] + "..."
|
||||
print(f"{r['brand']} ({r['file']}, {r['w']}x{r['h']})\n {shown}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
410
skills/drawio-skill/scripts/autolayout.py
Normal file
410
skills/drawio-skill/scripts/autolayout.py
Normal file
@@ -0,0 +1,410 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-layout a logical graph into draw.io XML using Graphviz.
|
||||
|
||||
Minimal layout pass for the drawio skill: takes a graph (nodes + edges as
|
||||
JSON), runs `dot` to position the nodes, and emits a .drawio file with the
|
||||
mxGeometry x/y filled in. draw.io routes the edges itself (orthogonal style).
|
||||
This removes the manual-coordinate ceiling for medium/large diagrams.
|
||||
|
||||
Input JSON:
|
||||
{
|
||||
"direction": "TB", # TB (top-bottom, default) or LR (left-right)
|
||||
"nodes": [
|
||||
{"id": "a", "label": "Service A", "style": "rounded=1;...",
|
||||
"width": 120, "height": 60}
|
||||
],
|
||||
"edges": [
|
||||
{"source": "a", "target": "b", "label": "calls"}
|
||||
]
|
||||
}
|
||||
Only "id" is required per node; label defaults to id and style/width/height
|
||||
have defaults. Node ids must be unique and must not be "0" or "1" (reserved
|
||||
for the draw.io root cells). Requires Graphviz `dot` on PATH.
|
||||
|
||||
Usage: python3 autolayout.py graph.json [-o diagram.drawio]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
DEFAULT_W, DEFAULT_H = 120, 60
|
||||
NODE_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
|
||||
EDGE_STYLE = "html=1;rounded=0;"
|
||||
GROUP_STYLE = ("rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#999999;"
|
||||
"verticalAlign=top;fontStyle=2;dashed=1;")
|
||||
# Group colours come from the skill's own palette (styles/built-in/default.json)
|
||||
# so there is a single source of truth, not a second list baked in here. When a
|
||||
# grouped graph is laid out, each top-level group takes the next colour (cycled
|
||||
# in a fixed, harmonious role order) so related modules read as a coloured
|
||||
# cluster. Nodes that carry their own `style` keep it; only styleless grouped
|
||||
# nodes are tinted. Disable with --mono.
|
||||
_PALETTE_ORDER = ["primary", "success", "accent", "secondary", "warning", "danger", "neutral"]
|
||||
_PALETTE_FILE = os.path.join(os.path.dirname(__file__), "..", "styles", "built-in", "default.json")
|
||||
_FALLBACK_PALETTE = [("#dae8fc", "#6c8ebf"), ("#d5e8d4", "#82b366"), ("#ffe6cc", "#d79b00"),
|
||||
("#e1d5e7", "#9673a6"), ("#fff2cc", "#d6b656"), ("#f8cecc", "#b85450")]
|
||||
|
||||
|
||||
def load_palette():
|
||||
"""Ordered (fill, stroke) list from the default preset's palette; fall back
|
||||
to the same colours inline if the preset file can't be read."""
|
||||
try:
|
||||
with open(_PALETTE_FILE, encoding="utf-8") as fh:
|
||||
pal = json.load(fh)["palette"]
|
||||
colors = [(pal[r]["fillColor"], pal[r]["strokeColor"]) for r in _PALETTE_ORDER if r in pal]
|
||||
if colors:
|
||||
return colors
|
||||
except (OSError, KeyError, ValueError):
|
||||
pass
|
||||
return _FALLBACK_PALETTE
|
||||
|
||||
|
||||
PALETTE = load_palette()
|
||||
# Uniform container padding; the title sits in the top pad (verticalAlign=top).
|
||||
# dot's cluster margin is set to this same value so each container box equals
|
||||
# dot's cluster box — which dot guarantees never overlaps, at any nesting depth.
|
||||
GROUP_PAD = 24
|
||||
|
||||
|
||||
def attr(value):
|
||||
# Newlines in labels become 
 so draw.io renders a line break (a raw
|
||||
# newline inside an XML attribute is normalized to a space by parsers).
|
||||
return escape(str(value), {'"': """, "\n": "
"})
|
||||
|
||||
|
||||
def dot_quote(value):
|
||||
# Wrap as a DOT double-quoted string, escaping backslash and quote so ids
|
||||
# with those characters can't corrupt the Graphviz input.
|
||||
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
|
||||
|
||||
def snap(value, grid=10):
|
||||
# Align to the grid the skill uses everywhere (multiples of 10).
|
||||
return int(round(value / grid) * grid)
|
||||
|
||||
|
||||
def group_tree(nodes):
|
||||
"""Parse hierarchical `group` paths ("a/b") into a container tree.
|
||||
|
||||
Returns (gpath, direct, children, ordered):
|
||||
gpath[node_id] = tuple of path segments (the node's deepest container)
|
||||
direct[path] = node ids whose group is exactly this path
|
||||
children[path] = child container paths
|
||||
ordered = all container paths, shallow-to-deep (stable)
|
||||
"""
|
||||
gpath, direct, paths = {}, {}, set()
|
||||
for node in nodes:
|
||||
g = node.get("group")
|
||||
if g is None or str(g).strip("/") == "":
|
||||
continue
|
||||
t = tuple(str(g).strip("/").split("/"))
|
||||
gpath[node["id"]] = t
|
||||
direct.setdefault(t, []).append(node["id"])
|
||||
for k in range(1, len(t) + 1):
|
||||
paths.add(t[:k])
|
||||
children = {}
|
||||
for p in sorted(paths):
|
||||
if len(p) > 1:
|
||||
children.setdefault(p[:-1], []).append(p)
|
||||
ordered = sorted(paths, key=lambda p: (len(p), p))
|
||||
return gpath, direct, children, ordered
|
||||
|
||||
|
||||
def build_dot(graph):
|
||||
rankdir = "LR" if str(graph.get("direction", "TB")).upper() == "LR" else "TB"
|
||||
# Optional graph-level spacing (inches). Icon nodes render their label below
|
||||
# the shape, so importers emitting icons ask for extra rank/node separation.
|
||||
sep = "".join(f" {k}={float(graph[k]):.2f};" for k in ("ranksep", "nodesep") if k in graph)
|
||||
# splines=ortho makes dot route edges as orthogonal polylines; we replay
|
||||
# those bends as draw.io waypoints so edges go around nodes, not through them.
|
||||
lines = [f"digraph G {{ rankdir={rankdir};{sep} splines=ortho; node [shape=box fixedsize=true];"]
|
||||
# Group nodes into (possibly nested) clusters so dot keeps each group
|
||||
# together; a node's first appearance fixes its cluster, so list members
|
||||
# before the size attributes. The cluster margin reserves room for the
|
||||
# padded container boxes we draw below (extra on Y for the title strip) so
|
||||
# neighbouring boxes do not overlap.
|
||||
_, direct, children, ordered = group_tree(graph["nodes"])
|
||||
cidx = {p: i for i, p in enumerate(ordered)}
|
||||
|
||||
def emit_cluster(p, pad):
|
||||
lines.append(f'{pad}subgraph cluster_{cidx[p]} {{ margin={GROUP_PAD};')
|
||||
for c in children.get(p, []):
|
||||
emit_cluster(c, pad + " ")
|
||||
lines.extend(f'{pad} {dot_quote(m)};' for m in direct.get(p, []))
|
||||
lines.append(pad + "}")
|
||||
|
||||
for root in [p for p in ordered if len(p) == 1]:
|
||||
emit_cluster(root, "")
|
||||
for node in graph["nodes"]:
|
||||
# Pass our pixel sizes to dot as inches so it lays out at the real size.
|
||||
w = node.get("width", DEFAULT_W) / 72.0
|
||||
h = node.get("height", DEFAULT_H) / 72.0
|
||||
lines.append(f'{dot_quote(node["id"])} [width={w:.4f} height={h:.4f}];')
|
||||
for edge in graph.get("edges", []):
|
||||
lines.append(f'{dot_quote(edge["source"])} -> {dot_quote(edge["target"])};')
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def layout(dot_src):
|
||||
"""Run `dot -Tplain`; return (height_in, {id: (xc, yc)}, {(src, dst): [(x, y), ...]}).
|
||||
|
||||
Node coords are inches (bottom-left origin); each edge's value is the list
|
||||
of orthogonal control points dot computed for routing, endpoints included.
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["dot", "-Tplain"], input=dot_src,
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
sys.exit("error: Graphviz `dot` not found on PATH (brew install graphviz)")
|
||||
except subprocess.CalledProcessError as exc:
|
||||
sys.exit(f"error: dot failed: {exc.stderr.strip()}")
|
||||
height, pos, edges = 0.0, {}, {}
|
||||
for line in proc.stdout.splitlines():
|
||||
tok = shlex.split(line)
|
||||
if not tok:
|
||||
continue
|
||||
if tok[0] == "graph":
|
||||
height = float(tok[3]) # graph scale width height
|
||||
elif tok[0] == "node":
|
||||
pos[tok[1]] = (float(tok[2]), float(tok[3])) # node name x y ...
|
||||
elif tok[0] == "edge": # edge tail head n x1 y1 ... xn yn
|
||||
n = int(tok[3])
|
||||
edges[(tok[1], tok[2])] = [
|
||||
(float(tok[4 + 2 * i]), float(tok[5 + 2 * i])) for i in range(n)
|
||||
]
|
||||
return height, pos, edges
|
||||
|
||||
|
||||
def group_style(stroke):
|
||||
"""Container box styled with a group's colour (coloured border + title)."""
|
||||
return (f"rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor={stroke};"
|
||||
f"fontColor={stroke};verticalAlign=top;fontStyle=2;dashed=1;")
|
||||
|
||||
|
||||
def page_cells(graph, height, pos, edge_pts, color=True):
|
||||
"""The <root> child cells (everything after the two reserved cells) for one
|
||||
laid-out graph — reusable by multi-page generators (c4.py)."""
|
||||
nodes = graph["nodes"]
|
||||
# Absolute snapped rect for every placed node.
|
||||
rects = {}
|
||||
for node in nodes:
|
||||
nid = node["id"]
|
||||
if nid not in pos:
|
||||
continue
|
||||
w, h = node.get("width", DEFAULT_W), node.get("height", DEFAULT_H)
|
||||
xc, yc = pos[nid]
|
||||
x = snap(xc * 72 - w / 2)
|
||||
y = snap((height - yc) * 72 - h / 2) # flip: dot origin is bottom-left
|
||||
rects[nid] = (x, y, w, h)
|
||||
# Parse the (possibly nested) group tree and assign each container a
|
||||
# collision-free id and a title (the path's last segment, or a member's groupLabel).
|
||||
gpath, direct, children, ordered = group_tree(nodes)
|
||||
# Assign each top-level group a palette colour, in order of first appearance.
|
||||
top_order = []
|
||||
for node in nodes:
|
||||
t = gpath.get(node["id"])
|
||||
if t and t[0] not in top_order:
|
||||
top_order.append(t[0])
|
||||
|
||||
def gcolor(seg):
|
||||
return PALETTE[top_order.index(seg) % len(PALETTE)]
|
||||
|
||||
used = {n["id"] for n in nodes}
|
||||
label_override = {}
|
||||
for node in nodes:
|
||||
if node["id"] in gpath and "groupLabel" in node:
|
||||
label_override.setdefault(gpath[node["id"]], str(node["groupLabel"]))
|
||||
gid, glabel = {}, {}
|
||||
for i, p in enumerate(ordered):
|
||||
cid = f"group_{i}"
|
||||
while cid in used: # never collide with a node id
|
||||
cid += "_"
|
||||
used.add(cid)
|
||||
gid[p] = cid
|
||||
glabel[p] = label_override.get(p, p[-1])
|
||||
# Container bounding box (members + nested children + uniform padding),
|
||||
# computed deepest-first so a parent can wrap its already-sized children.
|
||||
gbox = {}
|
||||
for p in sorted(ordered, key=len, reverse=True):
|
||||
xs = [(rects[m][0], rects[m][1], rects[m][0] + rects[m][2], rects[m][1] + rects[m][3])
|
||||
for m in direct.get(p, []) if m in rects]
|
||||
xs += [(gbox[c][0], gbox[c][1], gbox[c][0] + gbox[c][2], gbox[c][1] + gbox[c][3])
|
||||
for c in children.get(p, []) if c in gbox]
|
||||
if not xs:
|
||||
continue
|
||||
x0 = min(b[0] for b in xs) - GROUP_PAD
|
||||
y0 = min(b[1] for b in xs) - GROUP_PAD
|
||||
x1 = max(b[2] for b in xs) + GROUP_PAD
|
||||
y1 = max(b[3] for b in xs) + GROUP_PAD
|
||||
gbox[p] = (x0, y0, x1 - x0, y1 - y0)
|
||||
|
||||
# Shift everything positive: a container's top padding can push its top edge
|
||||
# above the page origin. Only translates when something would be negative.
|
||||
absx = [r[0] for r in rects.values()] + [b[0] for b in gbox.values()]
|
||||
absy = [r[1] for r in rects.values()] + [b[1] for b in gbox.values()]
|
||||
dx = GROUP_PAD - min(absx) if absx and min(absx) < 0 else 0
|
||||
dy = GROUP_PAD - min(absy) if absy and min(absy) < 0 else 0
|
||||
|
||||
def rebase(x, y, parent_path):
|
||||
"""Absolute -> coordinates relative to parent_path's box (or shifted if top-level)."""
|
||||
if parent_path is None:
|
||||
return x + dx, y + dy, "1"
|
||||
px, py, _, _ = gbox[parent_path]
|
||||
return x - px, y - py, gid[parent_path]
|
||||
|
||||
cells = []
|
||||
# Containers shallow-first so each parent precedes its children.
|
||||
for p in ordered:
|
||||
if p not in gbox:
|
||||
continue
|
||||
gx, gy, gw, gh = gbox[p]
|
||||
x, y, parent = rebase(gx, gy, p[:-1] if len(p) > 1 else None)
|
||||
gstyle = group_style(gcolor(p[0])[1]) if color else GROUP_STYLE
|
||||
cells.append(
|
||||
f' <mxCell id="{attr(gid[p])}" value="{attr(glabel[p])}" '
|
||||
f'style="{gstyle}" vertex="1" parent="{attr(parent)}">\n'
|
||||
f' <mxGeometry x="{x}" y="{y}" width="{gw}" height="{gh}" as="geometry"/>\n'
|
||||
f" </mxCell>"
|
||||
)
|
||||
for node in nodes:
|
||||
nid = node["id"]
|
||||
if nid not in rects:
|
||||
continue
|
||||
rx, ry, w, h = rects[nid]
|
||||
x, y, parent = rebase(rx, ry, gpath.get(nid) if gpath.get(nid) in gbox else None)
|
||||
if node.get("style"):
|
||||
style = node["style"] # explicit style always wins
|
||||
elif color and nid in gpath:
|
||||
fill, stroke = gcolor(gpath[nid][0]) # tint styleless nodes by group
|
||||
style = f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};strokeColor={stroke};"
|
||||
else:
|
||||
style = NODE_STYLE
|
||||
body = (f'style="{attr(style)}" vertex="1" parent="{attr(parent)}">\n'
|
||||
f' <mxGeometry x="{x}" y="{y}" width="{w}" height="{h}" as="geometry"/>\n'
|
||||
f" </mxCell>")
|
||||
if node.get("link"):
|
||||
# Links ride on a UserObject wrapper (id + label move to it).
|
||||
cells.append(
|
||||
f' <UserObject label="{attr(node.get("label", nid))}" '
|
||||
f'link="{attr(node["link"])}" id="{attr(nid)}">\n'
|
||||
f" <mxCell " + body + "\n </UserObject>")
|
||||
else:
|
||||
cells.append(
|
||||
f' <mxCell id="{attr(nid)}" value="{attr(node.get("label", nid))}" '
|
||||
+ body)
|
||||
for i, edge in enumerate(graph.get("edges", [])):
|
||||
# Drop the first/last points (they sit on the node borders, where
|
||||
# draw.io attaches anyway) and replay the interior bends as waypoints.
|
||||
interior = edge_pts.get((edge["source"], edge["target"]), [])[1:-1]
|
||||
if interior:
|
||||
points = "".join(
|
||||
f'<mxPoint x="{snap(x * 72) + dx}" y="{snap((height - y) * 72) + dy}"/>'
|
||||
for x, y in interior
|
||||
)
|
||||
geom = (f'<mxGeometry relative="1" as="geometry">'
|
||||
f'<Array as="points">{points}</Array></mxGeometry>')
|
||||
else:
|
||||
geom = '<mxGeometry relative="1" as="geometry"/>'
|
||||
cells.append(
|
||||
f' <mxCell id="e{i}" value="{attr(edge.get("label", ""))}" '
|
||||
f'style="{attr(edge.get("style", EDGE_STYLE))}" edge="1" parent="1" '
|
||||
f'source="{attr(edge["source"])}" target="{attr(edge["target"])}">\n'
|
||||
f" {geom}\n"
|
||||
f" </mxCell>"
|
||||
)
|
||||
return "\n".join(cells)
|
||||
|
||||
|
||||
def wrap_page(cells, page_id="autolayout", name="Page-1"):
|
||||
"""One <diagram> page around pre-rendered root cells."""
|
||||
return (
|
||||
f' <diagram id="{attr(page_id)}" name="{attr(name)}">\n'
|
||||
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" '
|
||||
'tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" '
|
||||
'pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
|
||||
" <root>\n"
|
||||
' <mxCell id="0"/>\n'
|
||||
' <mxCell id="1" parent="0"/>\n'
|
||||
+ cells
|
||||
+ "\n </root>\n </mxGraphModel>\n </diagram>\n"
|
||||
)
|
||||
|
||||
|
||||
def to_drawio(graph, height, pos, edge_pts, color=True):
|
||||
return ("<mxfile>\n"
|
||||
+ wrap_page(page_cells(graph, height, pos, edge_pts, color=color))
|
||||
+ "</mxfile>\n")
|
||||
|
||||
|
||||
def route_score(graph, height, pos, edge_pts):
|
||||
"""Readability score for one dot layout (lower is better): weighted count
|
||||
of edge-through-vertex hits and edge-edge crossings, with total edge length
|
||||
as a tiebreak. Uses the same geometry predicates as validate.py."""
|
||||
import importlib.util
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "validate.py")
|
||||
spec = importlib.util.spec_from_file_location("validate", path)
|
||||
v = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(v)
|
||||
rects = {}
|
||||
for node in graph["nodes"]:
|
||||
if node["id"] in pos:
|
||||
w, h = node.get("width", DEFAULT_W), node.get("height", DEFAULT_H)
|
||||
xc, yc = pos[node["id"]]
|
||||
rects[node["id"]] = (xc * 72 - w / 2, (height - yc) * 72 - h / 2, w, h)
|
||||
routes = []
|
||||
for edge in graph.get("edges", []):
|
||||
pts = edge_pts.get((edge["source"], edge["target"]))
|
||||
if pts:
|
||||
routes.append(([(x * 72, (height - y) * 72) for x, y in pts],
|
||||
{edge["source"], edge["target"]}))
|
||||
through = sum(1 for pts, ends in routes for nid, box in rects.items()
|
||||
if nid not in ends and v.route_hits_rect(pts, box))
|
||||
cross = sum(1 for i in range(len(routes)) for j in range(i + 1, len(routes))
|
||||
if v.routes_cross(routes[i][0], routes[j][0]))
|
||||
length = sum(abs(b[0] - a[0]) + abs(b[1] - a[1])
|
||||
for pts, _ in routes for a, b in zip(pts, pts[1:]))
|
||||
return 20 * through + 10 * cross + length / 100000
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Auto-layout a graph JSON into draw.io XML.")
|
||||
ap.add_argument("input", help="graph JSON file")
|
||||
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
|
||||
ap.add_argument("--mono", action="store_true",
|
||||
help="don't colour groups by palette (monochrome boxes)")
|
||||
ap.add_argument("--tune", action="store_true",
|
||||
help="lay out in both directions (TB and LR), keep the more "
|
||||
"readable one (fewer crossings / through-vertex routes)")
|
||||
args = ap.parse_args()
|
||||
with open(args.input, encoding="utf-8") as f:
|
||||
graph = json.load(f)
|
||||
if args.tune:
|
||||
best = None
|
||||
for d in ("TB", "LR"):
|
||||
cand = dict(graph, direction=d)
|
||||
h, p, ep = layout(build_dot(cand))
|
||||
s = route_score(cand, h, p, ep)
|
||||
if best is None or s < best[0]:
|
||||
best = (s, d, h, p, ep)
|
||||
_, d, height, pos, edge_pts = best
|
||||
print(f"tuned: direction={d} (score {best[0]:.2f})", file=sys.stderr)
|
||||
else:
|
||||
height, pos, edge_pts = layout(build_dot(graph))
|
||||
xml = to_drawio(graph, height, pos, edge_pts, color=not args.mono)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
print(f"wrote {args.output} ({len(graph['nodes'])} nodes, "
|
||||
f"{len(graph.get('edges', []))} edges)", file=sys.stderr)
|
||||
else:
|
||||
sys.stdout.write(xml)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
331
skills/drawio-skill/scripts/buildup.py
Normal file
331
skills/drawio-skill/scripts/buildup.py
Normal file
@@ -0,0 +1,331 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Animate a static .drawio building itself, node by node -> HTML player.
|
||||
|
||||
Reveals a diagram's cells incrementally in dependency order — topological over
|
||||
its edges, so a source always appears before the targets it points to, with
|
||||
ties (and any leftover cycle members) falling back to document order — and
|
||||
assembles a self-contained HTML player (base64-embedded PNG frames, play /
|
||||
pause / step / scrub) of the diagram constructing itself, like a build
|
||||
time-lapse.
|
||||
|
||||
python3 buildup.py architecture.drawio
|
||||
# -> architecture.drawio's directory / buildup.html
|
||||
python3 buildup.py architecture.drawio -o build.html --gif build.gif
|
||||
|
||||
Each frame is a temp copy of the diagram with not-yet-revealed cells removed
|
||||
from <root> (not opacity — draw.io ignores that on headless export). An edge
|
||||
is only shown once BOTH its endpoints are revealed. Container/group cells are
|
||||
always shown (only leaf vertices and the edges between them build up one step
|
||||
at a time). The page size is pinned to the FULL diagram's bounding box on
|
||||
every frame so nothing jumps around as cells appear. Needs the draw.io CLI;
|
||||
`--gif` additionally needs Pillow (skipped with a warning if absent — the
|
||||
HTML is written regardless).
|
||||
|
||||
Usage: python3 buildup.py <file.drawio> [-o out.html] [--gif out.gif]
|
||||
[--fps N] [--hold N] [--keep-frames]
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def parse_page(path):
|
||||
"""First page of a .drawio -> (tree, cells).
|
||||
|
||||
cells: list of dicts {id, el, vertex, edge, parent, source, target, style,
|
||||
relative, x, y, w, h} in document order. `el` is the TOP-LEVEL <root>
|
||||
child (mxCell / UserObject / object) so it can be removed directly;
|
||||
vertex/edge/geometry attributes are read off the inner mxCell for wrapped
|
||||
cells (UserObject/object), same unwrapping as drawiodiff.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")
|
||||
if not pages:
|
||||
sys.exit(f"error: no <diagram> pages in {path}")
|
||||
if len(pages) > 1:
|
||||
sys.stderr.write(f"warning: {path} has {len(pages)} pages, animating the first only\n")
|
||||
model = pages[0].find("mxGraphModel")
|
||||
root = model.find("root") if model is not None else None
|
||||
if root is None:
|
||||
sys.exit(f"error: {path}: page is compressed, cannot buildup")
|
||||
|
||||
cells = []
|
||||
for el in root:
|
||||
inner = el if el.tag == "mxCell" else el.find("mxCell")
|
||||
if inner is None:
|
||||
continue
|
||||
g = inner.find("mxGeometry")
|
||||
relative = g is not None and g.get("relative") == "1"
|
||||
if g is not None and not relative and g.get("x") is not None and g.get("width") is not None:
|
||||
x, y = float(g.get("x")), float(g.get("y", 0))
|
||||
w, h = float(g.get("width")), float(g.get("height", 0))
|
||||
else:
|
||||
x = y = w = h = None
|
||||
cells.append({
|
||||
"id": el.get("id"), "el": el,
|
||||
"vertex": inner.get("vertex") == "1", "edge": inner.get("edge") == "1",
|
||||
"parent": inner.get("parent"), "source": inner.get("source"),
|
||||
"target": inner.get("target"), "style": inner.get("style") or "",
|
||||
"relative": relative, "x": x, "y": y, "w": w, "h": h,
|
||||
})
|
||||
return tree, cells
|
||||
|
||||
|
||||
def classify(cells):
|
||||
"""cells -> (leaf_vertex_ids in doc order, container_ids, edges[(id,source,target)]).
|
||||
|
||||
Mirrors drawiodiff.parse(): a vertex that is some other cell's `parent` is
|
||||
a container/group (always shown, never an individual reveal step); an
|
||||
edge-label sub-cell (relative geometry or an `edgeLabel` style) is neither
|
||||
a node nor revealed on its own — it rides along once its parent edge is.
|
||||
"""
|
||||
parents = {c["parent"] for c in cells if c["parent"]}
|
||||
leaves, containers, edges = [], set(), []
|
||||
for c in cells:
|
||||
if c["edge"]:
|
||||
if c["source"] and c["target"]:
|
||||
edges.append((c["id"], c["source"], c["target"]))
|
||||
elif c["vertex"]:
|
||||
if c["relative"] or "edgeLabel" in c["style"]:
|
||||
continue
|
||||
if c["id"] in parents:
|
||||
containers.add(c["id"])
|
||||
else:
|
||||
leaves.append(c["id"])
|
||||
return leaves, containers, edges
|
||||
|
||||
|
||||
def bounding_box(cells, margin=40):
|
||||
"""(width, height) of the full diagram from every absolute cell geometry,
|
||||
with a margin — used to pin pageWidth/pageHeight so frames don't jump."""
|
||||
xs = [c["x"] + c["w"] for c in cells if c["x"] is not None]
|
||||
ys = [c["y"] + c["h"] for c in cells if c["y"] is not None]
|
||||
if not xs or not ys:
|
||||
return 850, 1100
|
||||
return int(max(xs)) + margin, int(max(ys)) + margin
|
||||
|
||||
|
||||
def reveal_order(node_ids, edges):
|
||||
"""Kahn topological order over node_ids given directed (source, target)
|
||||
edges. Ties among ready nodes, and any nodes left over from a cycle, fall
|
||||
back to document order (node_ids' input order)."""
|
||||
doc = list(dict.fromkeys(node_ids)) # de-dup, keep doc order
|
||||
idx = {nid: i for i, nid in enumerate(doc)}
|
||||
adj = {nid: [] for nid in doc}
|
||||
indeg = {nid: 0 for nid in doc}
|
||||
for s, t in edges:
|
||||
if s in idx and t in idx and s != t:
|
||||
adj[s].append(t)
|
||||
indeg[t] += 1
|
||||
|
||||
import heapq
|
||||
ready = list({idx[n] for n in doc if indeg[n] == 0})
|
||||
heapq.heapify(ready)
|
||||
order, seen = [], set()
|
||||
while ready:
|
||||
nid = doc[heapq.heappop(ready)]
|
||||
seen.add(nid)
|
||||
order.append(nid)
|
||||
for nxt in adj[nid]:
|
||||
indeg[nxt] -= 1
|
||||
if indeg[nxt] == 0:
|
||||
heapq.heappush(ready, idx[nxt])
|
||||
for nid in doc: # cycle remnants, document order
|
||||
if nid not in seen:
|
||||
order.append(nid)
|
||||
return order
|
||||
|
||||
|
||||
def reveal_steps(node_order, edges):
|
||||
"""-> (node_step {id: int}, edge_step {edge_id: int}). An edge's step is
|
||||
the LATER of its two endpoints' steps, so it only appears once both are
|
||||
revealed (endpoints outside node_order, e.g. a container, count as step 0
|
||||
— already shown)."""
|
||||
node_step = {nid: i for i, nid in enumerate(node_order)}
|
||||
edge_step = {eid: max(node_step.get(s, 0), node_step.get(t, 0)) for eid, s, t in edges}
|
||||
return node_step, edge_step
|
||||
|
||||
|
||||
def label_of(el):
|
||||
"""Visible text of a root child (mxCell or UserObject/object wrapper)."""
|
||||
if el.tag == "mxCell":
|
||||
return el.get("value") or el.get("id") or ""
|
||||
return el.get("label") or el.get("value") or el.get("id") or ""
|
||||
|
||||
|
||||
def build_html(frames, title):
|
||||
"""Self-contained HTML player. frames: [(png_bytes, label, step, total)]."""
|
||||
data = [{"img": "data:image/png;base64," + base64.b64encode(png).decode(),
|
||||
"label": label, "step": step, "total": total}
|
||||
for png, label, step, total in frames]
|
||||
payload = json.dumps(data).replace("</", "<\\/")
|
||||
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title}</title><style>
|
||||
:root{{color-scheme:light dark}}
|
||||
*{{box-sizing:border-box}}
|
||||
body{{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
|
||||
background:#f6f7f9;color:#1a1a1a}}
|
||||
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
|
||||
header{{padding:16px 20px 4px}}h1{{margin:0;font-size:17px;font-weight:600}}
|
||||
main{{max-width:1100px;margin:0 auto;padding:8px 16px 28px}}
|
||||
#stage{{background:#fff;border:1px solid #0001;border-radius:10px;
|
||||
min-height:60vh;display:flex;align-items:center;justify-content:center;padding:12px}}
|
||||
@media(prefers-color-scheme:dark){{#stage{{background:#1e2226;border-color:#fff2}}}}
|
||||
#stage img{{max-width:100%;max-height:74vh;object-fit:contain}}
|
||||
.cap{{display:flex;gap:14px;flex-wrap:wrap;align-items:baseline;
|
||||
padding:12px 4px 6px;color:#556;font-size:13px}}
|
||||
@media(prefers-color-scheme:dark){{.cap{{color:#9aa}}}}
|
||||
.cap .lbl{{color:#1a1a1a;font-weight:600}}
|
||||
@media(prefers-color-scheme:dark){{.cap .lbl{{color:#e8e8e8}}}}
|
||||
.bar{{height:6px;border-radius:3px;background:#0d99ff;transition:width .3s}}
|
||||
.barwrap{{height:6px;background:#0001;border-radius:3px;margin:2px 4px 12px}}
|
||||
.ctl{{display:flex;gap:10px;align-items:center;padding:4px}}
|
||||
button{{font:inherit;padding:6px 12px;border:1px solid #0002;border-radius:8px;
|
||||
background:#fff;cursor:pointer;color:inherit}}
|
||||
@media(prefers-color-scheme:dark){{button{{background:#262b31;border-color:#fff2}}}}
|
||||
button:hover{{border-color:#0d99ff}}
|
||||
input[type=range]{{flex:1;accent-color:#0d99ff}}
|
||||
</style></head><body>
|
||||
<header><h1>{title}</h1></header>
|
||||
<main>
|
||||
<div id="stage"><img id="img" alt="build-up frame"></div>
|
||||
<div class="cap">
|
||||
<span><b id="idx"></b></span>
|
||||
<span>+ <span class="lbl" id="label"></span></span>
|
||||
</div>
|
||||
<div class="barwrap"><div class="bar" id="bar"></div></div>
|
||||
<div class="ctl">
|
||||
<button id="prev">‹ Prev</button>
|
||||
<button id="play">▶ Play</button>
|
||||
<button id="next">Next ›</button>
|
||||
<input type="range" id="scrub" min="0" value="0">
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const F={payload};
|
||||
let i=0,timer=null;
|
||||
const $=id=>document.getElementById(id);
|
||||
$("scrub").max=F.length-1;
|
||||
function show(k){{
|
||||
i=(k+F.length)%F.length;const f=F[i];
|
||||
$("img").src=f.img;$("idx").textContent=`Step ${{f.step}} / ${{f.total}}`;
|
||||
$("label").textContent=f.label;
|
||||
$("bar").style.width=(6+94*f.step/f.total)+"%";$("scrub").value=i;
|
||||
}}
|
||||
function stop(){{clearInterval(timer);timer=null;$("play").textContent="▶ Play";}}
|
||||
$("prev").onclick=()=>{{stop();show(i-1);}};
|
||||
$("next").onclick=()=>{{stop();show(i+1);}};
|
||||
$("scrub").oninput=e=>{{stop();show(+e.target.value);}};
|
||||
$("play").onclick=()=>{{
|
||||
if(timer){{stop();return;}}
|
||||
$("play").textContent="⏸ Pause";
|
||||
timer=setInterval(()=>{{if(i>=F.length-1){{show(0);}}else{{show(i+1);}}}},700);
|
||||
}};
|
||||
show(0);
|
||||
</script></body></html>"""
|
||||
|
||||
|
||||
def make_gif(pngs, out_path, fps, hold):
|
||||
"""Assemble PNG frame bytes into an animated GIF via Pillow. Skips with a
|
||||
stderr warning (not fatal) if Pillow isn't installed."""
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
sys.stderr.write("warning: Pillow not installed, skipping --gif (pip install Pillow)\n")
|
||||
return
|
||||
frames = [Image.open(io.BytesIO(p)).convert("RGB") for p in pngs]
|
||||
duration = [int(1000 / fps)] * (len(frames) - 1) + [int(hold * 1000)]
|
||||
frames[0].save(out_path, save_all=True, append_images=frames[1:],
|
||||
duration=duration, loop=0)
|
||||
sys.stderr.write(f"wrote {out_path} ({len(frames)} frames)\n")
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Animate a .drawio building itself -> self-contained HTML player.")
|
||||
ap.add_argument("file", help="input .drawio (uncompressed)")
|
||||
ap.add_argument("-o", "--output", help="output .html (default: buildup.html alongside input)")
|
||||
ap.add_argument("--gif", help="also assemble frames into an animated GIF (needs Pillow)")
|
||||
ap.add_argument("--fps", type=float, default=2.0, help="GIF frames per second (default 2)")
|
||||
ap.add_argument("--hold", type=float, default=1.5, help="seconds to hold the final GIF frame")
|
||||
ap.add_argument("--keep-frames", action="store_true", help="also write the PNG frames next to the output")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(args.file):
|
||||
sys.exit(f"error: {args.file} not found")
|
||||
if not shutil.which("drawio"):
|
||||
sys.exit("error: draw.io CLI not found on PATH (is the draw.io CLI installed?)")
|
||||
|
||||
tree, cells = parse_page(args.file)
|
||||
leaves, containers, edge_list = classify(cells)
|
||||
if not leaves:
|
||||
sys.exit(f"error: no revealable vertices found in {args.file}")
|
||||
|
||||
order = reveal_order(leaves, [(s, t) for _, s, t in edge_list])
|
||||
node_step, edge_step = reveal_steps(order, edge_list)
|
||||
width, height = bounding_box(cells)
|
||||
labels = {c["id"]: label_of(c["el"]) for c in cells}
|
||||
n_total = len(order)
|
||||
|
||||
out = args.output or os.path.join(
|
||||
os.path.dirname(os.path.abspath(args.file)) or ".", "buildup.html")
|
||||
|
||||
frames = []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for k in range(n_total):
|
||||
revealed_nodes = set(order[:k + 1])
|
||||
revealed_edges = {eid for eid, _, _ in edge_list if edge_step[eid] <= k}
|
||||
keep = {"0", "1"} | containers | revealed_nodes | revealed_edges
|
||||
keep |= {c["id"] for c in cells if c["id"] not in keep and c["parent"] in keep}
|
||||
|
||||
frame_tree = copy.deepcopy(tree)
|
||||
model = frame_tree.getroot().find("diagram").find("mxGraphModel")
|
||||
model.set("pageWidth", str(width))
|
||||
model.set("pageHeight", str(height))
|
||||
froot = model.find("root")
|
||||
for child in list(froot):
|
||||
if child.get("id") not in keep:
|
||||
froot.remove(child)
|
||||
|
||||
src = os.path.join(tmp, f"step{k:03d}.drawio")
|
||||
frame_tree.write(src, encoding="utf-8", xml_declaration=False)
|
||||
png_path = os.path.join(tmp, f"step{k:03d}.png")
|
||||
r = subprocess.run(["drawio", "-x", "-f", "png", "--page-index", "1",
|
||||
"--width", "2000", "-o", png_path, src], capture_output=True)
|
||||
if r.returncode != 0 or not os.path.exists(png_path):
|
||||
sys.stderr.write(f"warning: step {k + 1}/{n_total} export failed — skipped\n")
|
||||
continue
|
||||
with open(png_path, "rb") as f:
|
||||
png = f.read()
|
||||
label = labels.get(order[k], order[k])
|
||||
frames.append((png, label, k + 1, n_total))
|
||||
if args.keep_frames:
|
||||
with open(f"{os.path.splitext(out)[0]}-frame{k + 1:03d}.png", "wb") as f:
|
||||
f.write(png)
|
||||
sys.stderr.write(f"[{k + 1}/{n_total}] revealed {label!r}\n")
|
||||
|
||||
if not frames:
|
||||
sys.exit("error: no frames exported (is the draw.io CLI installed?)")
|
||||
|
||||
title = os.path.splitext(os.path.basename(args.file))[0] + " — build-up"
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write(build_html(frames, title))
|
||||
sys.stderr.write(f"wrote {out} ({len(frames)} frames)\n")
|
||||
|
||||
if args.gif:
|
||||
make_gif([f[0] for f in frames], args.gif, args.fps, args.hold)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
163
skills/drawio-skill/scripts/c4.py
Normal file
163
skills/drawio-skill/scripts/c4.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""C4 model diagrams: levels JSON -> one multi-page .drawio with drill-down.
|
||||
|
||||
Generates a C4 architecture diagram set (System Context -> Containers ->
|
||||
Components, as many levels as you define) in a single `.drawio` file: one
|
||||
page per level, official draw.io C4 shapes and colors, Graphviz placement
|
||||
per page (via autolayout), and **drill-down links** — an element with a
|
||||
`"children"` key becomes clickable and jumps to that level's page in
|
||||
draw.io / the diagrams.net viewer.
|
||||
|
||||
python3 c4.py c4.json -o architecture.drawio
|
||||
|
||||
Input JSON:
|
||||
{
|
||||
"title": "Internet Banking",
|
||||
"levels": [
|
||||
{
|
||||
"name": "System Context",
|
||||
"elements": [
|
||||
{"id": "customer", "type": "person", "label": "Personal Customer",
|
||||
"desc": "A customer of the bank"},
|
||||
{"id": "ibs", "type": "system", "label": "Internet Banking System",
|
||||
"desc": "Lets customers manage accounts", "children": "Containers"},
|
||||
{"id": "email", "type": "external", "label": "E-mail System",
|
||||
"desc": "Microsoft Exchange"}
|
||||
],
|
||||
"relations": [
|
||||
{"from": "customer", "to": "ibs", "label": "Uses"},
|
||||
{"from": "ibs", "to": "email", "label": "Sends e-mail via"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Containers",
|
||||
"elements": [
|
||||
{"id": "spa", "type": "container", "label": "Single-Page App",
|
||||
"tech": "React", "desc": "Banking UI in the browser"},
|
||||
{"id": "api", "type": "container", "label": "API Application",
|
||||
"tech": "Java/Spring", "children": "Components"},
|
||||
{"id": "db", "type": "database", "label": "Database",
|
||||
"tech": "PostgreSQL"}
|
||||
],
|
||||
"relations": [
|
||||
{"from": "spa", "to": "api", "label": "JSON/HTTPS"},
|
||||
{"from": "api", "to": "db", "label": "JDBC"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Element types: person, system, external (greyed external system), container,
|
||||
component, database. `tech` renders as the [Type: Tech] line, `desc` as the
|
||||
description line — the standard C4 label. Element ids must be unique across
|
||||
ALL levels (pages share one link namespace). Requires Graphviz `dot`.
|
||||
|
||||
Usage: python3 c4.py <c4.json> [-o out.drawio] [--direction TB|LR]
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Official draw.io C4 template styles (colors from c4model.com).
|
||||
_BASE = "html=1;whiteSpace=wrap;fontSize=12;fontColor=#ffffff;align=center;"
|
||||
STYLES = {
|
||||
"person": ("shape=mxgraph.c4.person2;" + _BASE +
|
||||
"fillColor=#083F75;strokeColor=#06315C;", 200, 180),
|
||||
"system": ("rounded=1;arcSize=10;" + _BASE +
|
||||
"fillColor=#1061B0;strokeColor=#0D5091;", 240, 120),
|
||||
"external": ("rounded=1;arcSize=10;" + _BASE +
|
||||
"fillColor=#8C8496;strokeColor=#736782;", 240, 120),
|
||||
"container": ("rounded=1;arcSize=10;" + _BASE +
|
||||
"fillColor=#23A2D9;strokeColor=#0E7DAD;", 240, 120),
|
||||
"component": ("rounded=1;arcSize=10;" + _BASE +
|
||||
"fillColor=#63BEF2;strokeColor=#2086C9;", 240, 120),
|
||||
"database": ("shape=cylinder3;size=15;boundedLbl=1;" + _BASE +
|
||||
"fillColor=#23A2D9;strokeColor=#0E7DAD;", 240, 120),
|
||||
}
|
||||
TYPE_WORD = {"person": "Person", "system": "Software System",
|
||||
"external": "Software System", "container": "Container",
|
||||
"component": "Component", "database": "Container"}
|
||||
EDGE = ("endArrow=blockThin;endFill=1;endSize=10;html=1;fontSize=11;"
|
||||
"fontColor=#404040;strokeColor=#828282;labelBackgroundColor=#ffffff;"
|
||||
"rounded=0;")
|
||||
|
||||
|
||||
def load_autolayout():
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "autolayout.py")
|
||||
spec = importlib.util.spec_from_file_location("autolayout", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def slug(name):
|
||||
return re.sub(r"[^a-z0-9]+", "-", str(name).lower()).strip("-") or "page"
|
||||
|
||||
|
||||
def c4_label(el):
|
||||
"""Standard C4 element label: Name / [Type: Tech] / description."""
|
||||
kind = TYPE_WORD.get(el.get("type", "system"), "Software System")
|
||||
bracket = f"[{kind}: {el['tech']}]" if el.get("tech") else f"[{kind}]"
|
||||
lines = [el.get("label", el["id"]), bracket]
|
||||
if el.get("desc"):
|
||||
lines.append(el["desc"])
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="C4 levels JSON -> multi-page draw.io.")
|
||||
ap.add_argument("input", help="C4 JSON file")
|
||||
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
args = ap.parse_args()
|
||||
with open(args.input, encoding="utf-8") as f:
|
||||
spec = json.load(f)
|
||||
levels = spec.get("levels") or []
|
||||
if not levels:
|
||||
sys.exit("error: no levels in input")
|
||||
al = load_autolayout()
|
||||
|
||||
page_ids = {lv["name"]: slug(lv["name"]) for lv in levels}
|
||||
seen = set()
|
||||
pages = []
|
||||
for lv in levels:
|
||||
nodes = []
|
||||
for el in lv.get("elements", []):
|
||||
if el["id"] in seen:
|
||||
sys.exit(f"error: duplicate element id {el['id']!r} "
|
||||
"(ids must be unique across all levels)")
|
||||
seen.add(el["id"])
|
||||
style, w, h = STYLES.get(el.get("type", "system"), STYLES["system"])
|
||||
node = {"id": el["id"], "label": c4_label(el), "style": style,
|
||||
"width": w, "height": h}
|
||||
child = el.get("children")
|
||||
if child:
|
||||
if child not in page_ids:
|
||||
sys.exit(f"error: element {el['id']!r} drills down to "
|
||||
f"unknown level {child!r}")
|
||||
node["link"] = f"data:page/id,{page_ids[child]}"
|
||||
nodes.append(node)
|
||||
edges = [{"source": r["from"], "target": r["to"],
|
||||
"label": r.get("label", ""), "style": EDGE}
|
||||
for r in lv.get("relations", [])]
|
||||
graph = {"direction": args.direction, "nodes": nodes, "edges": edges,
|
||||
"ranksep": 0.9, "nodesep": 0.5}
|
||||
height, pos, edge_pts = al.layout(al.build_dot(graph))
|
||||
pages.append(al.wrap_page(al.page_cells(graph, height, pos, edge_pts, color=False),
|
||||
page_id=page_ids[lv["name"]], name=lv["name"]))
|
||||
|
||||
xml = "<mxfile>\n" + "".join(pages) + "</mxfile>\n"
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
print(f"wrote {args.output} ({len(pages)} pages, {len(seen)} elements)",
|
||||
file=sys.stderr)
|
||||
else:
|
||||
sys.stdout.write(xml)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
182
skills/drawio-skill/scripts/ciimports.py
Normal file
182
skills/drawio-skill/scripts/ciimports.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a CI pipeline (GitHub Actions / GitLab CI) as autolayout graph JSON.
|
||||
|
||||
GitHub Actions: every job becomes a node (label: name, runner, matrix size,
|
||||
reusable-workflow target), `needs:` become edges, and each workflow gets a
|
||||
trigger node (its `on:` events) feeding the jobs that have no `needs`. Given a
|
||||
repo root, all of `.github/workflows/*.yml|yaml` are read and each workflow is
|
||||
boxed in its own container.
|
||||
|
||||
GitLab CI (`.gitlab-ci.yml`, auto-detected): jobs become nodes grouped by
|
||||
stage; edges come from `needs:`, and jobs without `needs` inherit the stage
|
||||
DAG (every job of the previous stage), matching GitLab's execution order.
|
||||
|
||||
python3 ciimports.py . # repo root -> all workflows
|
||||
python3 ciimports.py .github/workflows/ci.yml -o graph.json
|
||||
python3 autolayout.py graph.json -o pipeline.drawio
|
||||
|
||||
Requires PyYAML (pip install pyyaml).
|
||||
|
||||
Usage: python3 ciimports.py <repo-root | workflow.yml ...> [-o graph.json]
|
||||
[--direction TB|LR]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
JOB_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
|
||||
REUSE_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;"
|
||||
TRIGGER_STYLE = "ellipse;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;"
|
||||
|
||||
GITLAB_RESERVED = {"stages", "variables", "workflow", "default", "include", "image",
|
||||
"services", "before_script", "after_script", "cache", "pages"}
|
||||
|
||||
|
||||
def find_workflows(path):
|
||||
"""Workflow files for a path: file(s) as-is, a repo root via .github/workflows."""
|
||||
if os.path.isfile(path):
|
||||
return [path]
|
||||
wfdir = os.path.join(path, ".github", "workflows")
|
||||
files = sorted(os.path.join(wfdir, f) for f in os.listdir(wfdir)
|
||||
if f.endswith((".yml", ".yaml"))) if os.path.isdir(wfdir) else []
|
||||
gitlab = os.path.join(path, ".gitlab-ci.yml")
|
||||
if os.path.isfile(gitlab):
|
||||
files.append(gitlab)
|
||||
if not files:
|
||||
sys.exit(f"error: no workflow files under {path}")
|
||||
return files
|
||||
|
||||
|
||||
def matrix_size(strategy):
|
||||
n = 1
|
||||
matrix = (strategy or {}).get("matrix") or {}
|
||||
if not isinstance(matrix, dict):
|
||||
return 0 # dynamic (fromJSON) — unknown
|
||||
for key, vals in matrix.items():
|
||||
if key not in ("include", "exclude") and isinstance(vals, list):
|
||||
n *= len(vals)
|
||||
n += len(matrix.get("include") or []) - len(matrix.get("exclude") or [])
|
||||
return max(n, 1)
|
||||
|
||||
|
||||
def parse_actions(spec, wf_id, wf_name, group):
|
||||
"""One GitHub Actions workflow -> (nodes, edges)."""
|
||||
nodes, edges = [], []
|
||||
# YAML 1.1 quirk: bare `on:` parses as boolean True
|
||||
on = spec.get("on", spec.get(True, {}))
|
||||
events = sorted(on) if isinstance(on, dict) else \
|
||||
([on] if isinstance(on, str) else sorted(on or []))
|
||||
trig_id = f"{wf_id}//trigger"
|
||||
nodes.append({"id": trig_id, "label": "on: " + (", ".join(events) or "?"),
|
||||
"style": TRIGGER_STYLE, "width": 160, "height": 50, "group": group})
|
||||
jobs = spec.get("jobs") or {}
|
||||
for jid, job in jobs.items():
|
||||
job = job or {}
|
||||
lines = [job.get("name") or jid]
|
||||
if job.get("uses"):
|
||||
lines.append("uses: " + os.path.basename(str(job["uses"])))
|
||||
style = REUSE_STYLE
|
||||
else:
|
||||
style = JOB_STYLE
|
||||
runner = job.get("runs-on")
|
||||
if runner:
|
||||
lines.append(str(runner if isinstance(runner, str) else ", ".join(runner)))
|
||||
n = matrix_size(job.get("strategy"))
|
||||
if n > 1:
|
||||
lines.append(f"matrix ×{n}")
|
||||
elif n == 0:
|
||||
lines.append("matrix (dynamic)")
|
||||
nodes.append({"id": f"{wf_id}//{jid}", "label": "\n".join(lines),
|
||||
"style": style, "width": 180, "height": 60, "group": group})
|
||||
needs = job.get("needs") or []
|
||||
needs = [needs] if isinstance(needs, str) else needs
|
||||
for dep in needs:
|
||||
if dep in jobs:
|
||||
edges.append({"source": f"{wf_id}//{dep}", "target": f"{wf_id}//{jid}"})
|
||||
if not needs:
|
||||
edges.append({"source": trig_id, "target": f"{wf_id}//{jid}"})
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def parse_gitlab(spec, wf_id, group_prefix):
|
||||
"""A .gitlab-ci.yml -> (nodes, edges); jobs grouped by stage."""
|
||||
stages = spec.get("stages") or ["build", "test", "deploy"]
|
||||
jobs = {k: v for k, v in spec.items()
|
||||
if isinstance(v, dict) and k not in GITLAB_RESERVED and not k.startswith(".")
|
||||
and ("script" in v or "trigger" in v or "extends" in v or "stage" in v)}
|
||||
nodes, edges = [], []
|
||||
by_stage = {}
|
||||
for jid, job in jobs.items():
|
||||
stage = job.get("stage") or "test"
|
||||
by_stage.setdefault(stage, []).append(jid)
|
||||
nodes.append({"id": f"{wf_id}//{jid}", "label": jid, "style": JOB_STYLE,
|
||||
"width": 160, "height": 50,
|
||||
"group": f"{group_prefix}{stage}"})
|
||||
order = [s for s in stages if s in by_stage]
|
||||
for jid, job in jobs.items():
|
||||
needs = [(n.get("job") if isinstance(n, dict) else n) for n in job.get("needs") or []]
|
||||
needs = [n for n in needs if n in jobs]
|
||||
if needs:
|
||||
edges.extend({"source": f"{wf_id}//{n}", "target": f"{wf_id}//{jid}"} for n in needs)
|
||||
else: # stage DAG: all jobs of the previous stage
|
||||
stage = job.get("stage") or "test"
|
||||
i = order.index(stage) if stage in order else 0
|
||||
if i > 0:
|
||||
edges.extend({"source": f"{wf_id}//{p}", "target": f"{wf_id}//{jid}"}
|
||||
for p in by_stage[order[i - 1]])
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="CI pipeline -> autolayout graph JSON.")
|
||||
ap.add_argument("paths", nargs="+",
|
||||
help="repo root, or workflow file(s) (.github/workflows/*.yml, .gitlab-ci.yml)")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="LR", choices=["TB", "LR"])
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("error: PyYAML is required (pip install pyyaml)")
|
||||
|
||||
files = [f for p in args.paths for f in find_workflows(p)]
|
||||
nodes, edges = [], []
|
||||
for path in files:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
try:
|
||||
spec = yaml.safe_load(f) or {}
|
||||
except yaml.YAMLError as e:
|
||||
sys.stderr.write(f"warning: skipping {path}: {e}\n")
|
||||
continue
|
||||
wf_id = os.path.splitext(os.path.basename(path))[0]
|
||||
if os.path.basename(path) == ".gitlab-ci.yml" or (
|
||||
"jobs" not in spec and "stages" in spec):
|
||||
n, e = parse_gitlab(spec, wf_id, "stage: " if len(files) == 1
|
||||
else f"{wf_id} / stage: ")
|
||||
elif spec.get("jobs"):
|
||||
wf_name = spec.get("name") or wf_id
|
||||
group = wf_name if len(files) > 1 else None
|
||||
n, e = parse_actions(spec, wf_id, wf_name, group)
|
||||
else:
|
||||
sys.stderr.write(f"warning: {path} has no jobs — skipped\n")
|
||||
continue
|
||||
nodes.extend(n)
|
||||
edges.extend(e)
|
||||
if not nodes:
|
||||
sys.exit("error: no CI jobs found")
|
||||
|
||||
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"{len(nodes)} nodes, {len(edges)} edges from {len(files)} file(s)\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
118
skills/drawio-skill/scripts/composeimports.py
Normal file
118
skills/drawio-skill/scripts/composeimports.py
Normal file
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a docker-compose file's service graph as autolayout graph JSON.
|
||||
|
||||
Services become rounded boxes (labeled name + image), named volumes become
|
||||
cylinders, and edges come from real wiring: `depends_on` (list or mapping
|
||||
form), `links`, `volumes_from`, and named-volume mounts (short "vol:/path"
|
||||
and long {type: volume, source: ...} syntax). The output feeds autolayout.py:
|
||||
|
||||
python3 composeimports.py docker-compose.yml -o graph.json
|
||||
python3 autolayout.py graph.json -o stack.drawio
|
||||
|
||||
Given a directory, the usual compose file names are tried
|
||||
(compose.yaml/compose.yml/docker-compose.yml/docker-compose.yaml).
|
||||
Requires PyYAML (pip install pyyaml). `--group` boxes services by their
|
||||
first network.
|
||||
|
||||
Usage: python3 composeimports.py <compose-file-or-dir> [-o graph.json]
|
||||
[--direction TB|LR] [--group]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
SERVICE_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
|
||||
VOLUME_STYLE = ("shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;size=15;"
|
||||
"fillColor=#f5f5f5;strokeColor=#666666;")
|
||||
|
||||
|
||||
def find_compose(path):
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
for name in ("compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"):
|
||||
cand = os.path.join(path, name)
|
||||
if os.path.isfile(cand):
|
||||
return cand
|
||||
sys.exit(f"error: no compose file found under {path}")
|
||||
|
||||
|
||||
def volume_mounts(svc):
|
||||
"""Named volumes a service mounts (short and long syntax)."""
|
||||
for v in svc.get("volumes") or []:
|
||||
if isinstance(v, str):
|
||||
src = v.split(":", 1)[0]
|
||||
if src and not src.startswith((".", "/", "~", "$")):
|
||||
yield src
|
||||
elif isinstance(v, dict) and v.get("type", "volume") == "volume" and v.get("source"):
|
||||
yield v["source"]
|
||||
|
||||
|
||||
def dependencies(svc):
|
||||
dep = svc.get("depends_on") or []
|
||||
deps = list(dep) if isinstance(dep, (list, dict)) else []
|
||||
for link in svc.get("links") or []:
|
||||
deps.append(str(link).split(":", 1)[0])
|
||||
for vf in svc.get("volumes_from") or []:
|
||||
deps.append(str(vf).split(":", 1)[0])
|
||||
return deps
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="docker-compose -> autolayout graph JSON.")
|
||||
ap.add_argument("path", help="compose file, or directory containing one")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="group services into containers by their first network")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("error: PyYAML is required (pip install pyyaml)")
|
||||
path = find_compose(args.path)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
spec = yaml.safe_load(f) or {}
|
||||
services = spec.get("services") or {}
|
||||
if not services:
|
||||
sys.exit(f"error: no services in {path}")
|
||||
declared_volumes = set(spec.get("volumes") or {})
|
||||
|
||||
nodes, edges = [], set()
|
||||
for name, svc in services.items():
|
||||
svc = svc or {}
|
||||
image = svc.get("image") or ("build: " + str((svc.get("build") or {}).get("context", ".")
|
||||
if isinstance(svc.get("build"), dict)
|
||||
else svc.get("build", ".")))
|
||||
node = {"id": name, "label": f"{name}\n{image}", "style": SERVICE_STYLE,
|
||||
"width": 160, "height": 60}
|
||||
nets = svc.get("networks")
|
||||
first_net = (sorted(nets)[0] if isinstance(nets, dict) else nets[0]) if nets else None
|
||||
if args.group and first_net:
|
||||
node["group"] = str(first_net)
|
||||
nodes.append(node)
|
||||
for dep in dependencies(svc):
|
||||
if dep in services and dep != name:
|
||||
edges.add((name, dep))
|
||||
for vol in volume_mounts(svc):
|
||||
if vol in declared_volumes:
|
||||
edges.add((name, f"vol:{vol}"))
|
||||
for vol in sorted({t[4:] for _, t in edges if t.startswith("vol:")}):
|
||||
nodes.append({"id": f"vol:{vol}", "label": vol, "style": VOLUME_STYLE,
|
||||
"width": 120, "height": 70})
|
||||
|
||||
graph = {"direction": args.direction, "nodes": nodes,
|
||||
"edges": [{"source": s, "target": t} for s, t in sorted(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"{len(nodes)} nodes, {len(edges)} edges\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
257
skills/drawio-skill/scripts/compress.py
Normal file
257
skills/drawio-skill/scripts/compress.py
Normal file
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collapse a big .drawio into a boardroom-friendly executive summary.
|
||||
|
||||
Detects clusters in a large diagram with a deterministic pure-Python label
|
||||
propagation pass (no networkx), replaces each cluster with ONE labeled group
|
||||
node, keeps aggregated inter-cluster edges, and emits a 2-page .drawio: page 1
|
||||
is the executive view (auto-laid-out via autolayout.py), page 2 is the
|
||||
original full diagram, copied verbatim. Each executive node is wrapped in a
|
||||
draw.io UserObject `data:page/id,...` drill-down link to page 2, so clicking
|
||||
"Auth (5)" jumps straight into the full detail.
|
||||
|
||||
Community detection is unsupervised — it finds however many clusters the
|
||||
graph naturally has; `--clusters` is only a soft hint and may be ignored.
|
||||
Clusters are named after the longest common leading token shared by their
|
||||
members' labels (falling back to the highest-degree member's label), with the
|
||||
member count appended, e.g. "Auth (5)". Rename them by hand afterward for a
|
||||
more semantic label — label propagation does not know what your system does.
|
||||
|
||||
Requires Graphviz `dot` on PATH (shells out to autolayout.py to place the
|
||||
executive nodes).
|
||||
|
||||
python3 compress.py big-system.drawio -o exec-view.drawio
|
||||
|
||||
Usage: python3 compress.py <diagram.drawio> [-o out.drawio] [--clusters N]
|
||||
"""
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
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).
|
||||
Copied from drawiodiff.parse() — see SHARED CONVENTIONS."""
|
||||
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 label_propagation(node_ids, edges, max_passes=20):
|
||||
"""Deterministic pure-Python label propagation for community detection.
|
||||
|
||||
Edges are treated as undirected for clustering. Each pass computes every
|
||||
node's new label synchronously from the PREVIOUS pass's labels (most
|
||||
frequent label among neighbours, ties -> smallest label), then applies
|
||||
them all at once — this keeps a thin bridge between two dense clusters
|
||||
from cascading a merge within a single pass. Stops early once no label
|
||||
changes, else after `max_passes`. Returns {node_id: community_label}.
|
||||
"""
|
||||
nodes = sorted(set(node_ids))
|
||||
neighbours = {n: set() for n in nodes}
|
||||
for s, t in edges:
|
||||
if s in neighbours and t in neighbours and s != t:
|
||||
neighbours[s].add(t)
|
||||
neighbours[t].add(s)
|
||||
labels = {n: n for n in nodes}
|
||||
for _ in range(max_passes):
|
||||
new_labels = {}
|
||||
for n in nodes:
|
||||
if not neighbours[n]:
|
||||
new_labels[n] = labels[n]
|
||||
continue
|
||||
counts = {}
|
||||
for nb in neighbours[n]:
|
||||
lbl = labels[nb]
|
||||
counts[lbl] = counts.get(lbl, 0) + 1
|
||||
best = max(counts.values())
|
||||
new_labels[n] = min(lbl for lbl, c in counts.items() if c == best)
|
||||
if new_labels == labels:
|
||||
break
|
||||
labels = new_labels
|
||||
return labels
|
||||
|
||||
|
||||
def compute_degree(node_ids, edges):
|
||||
"""Undirected degree per node (used as the naming tiebreak)."""
|
||||
degree = {n: 0 for n in node_ids}
|
||||
for s, t in edges:
|
||||
if s in degree:
|
||||
degree[s] += 1
|
||||
if t in degree:
|
||||
degree[t] += 1
|
||||
return degree
|
||||
|
||||
|
||||
def aggregate_edges(edges, community_of):
|
||||
"""Roll original edges up to inter-community edges: for every edge whose
|
||||
endpoints fall in two different communities, count crossings by
|
||||
(source_community, target_community) and dedupe into one entry per pair.
|
||||
Same-community (internal) edges are dropped. Returns
|
||||
{(src_community, tgt_community): crossing_count}."""
|
||||
counts = {}
|
||||
for s, t in edges:
|
||||
cs, ct = community_of.get(s), community_of.get(t)
|
||||
if cs is None or ct is None or cs == ct:
|
||||
continue
|
||||
counts[(cs, ct)] = counts.get((cs, ct), 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def cluster_name(member_ids, node_labels, degree):
|
||||
"""Heuristic community name: the longest common leading token shared by
|
||||
every member's label (split on whitespace), else the highest-degree
|
||||
member's label. The member count is appended, e.g. "Auth (5)"."""
|
||||
token_lists = [str(node_labels.get(m, m)).split() for m in member_ids]
|
||||
common = []
|
||||
if token_lists and all(token_lists):
|
||||
for tokens in zip(*token_lists):
|
||||
if len(set(tokens)) == 1:
|
||||
common.append(tokens[0])
|
||||
else:
|
||||
break
|
||||
if common:
|
||||
base = " ".join(common)
|
||||
else:
|
||||
top = max(member_ids, key=lambda m: (degree.get(m, 0), m))
|
||||
base = node_labels.get(top) or top
|
||||
return f"{base} ({len(member_ids)})"
|
||||
|
||||
|
||||
def layout_exec_page(graph):
|
||||
"""Shell out to autolayout.py to place the executive nodes; return the
|
||||
rendered <diagram>...</diagram> page, renamed to a friendlier id/title."""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
gpath = os.path.join(d, "exec.json")
|
||||
with open(gpath, "w", encoding="utf-8") as f:
|
||||
json.dump(graph, f)
|
||||
opath = os.path.join(d, "exec.drawio")
|
||||
r = subprocess.run(
|
||||
[sys.executable, os.path.join(HERE, "autolayout.py"), gpath, "-o", opath],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0 or not os.path.exists(opath):
|
||||
sys.exit(f"error: autolayout failed: {r.stderr.strip()}")
|
||||
with open(opath, encoding="utf-8") as f:
|
||||
xml = f.read()
|
||||
m = re.search(r"(<diagram\b.*?</diagram>)", xml, re.S)
|
||||
if not m:
|
||||
sys.exit("error: autolayout produced no page")
|
||||
page = m.group(1).replace('id="autolayout"', 'id="exec-view"', 1)
|
||||
page = page.replace('name="Page-1"', 'name="Executive View"', 1)
|
||||
return page + "\n"
|
||||
|
||||
|
||||
def copy_original_page(path, page2_id):
|
||||
"""Copy the source's first page verbatim (cells untouched) into a new
|
||||
<diagram> with id=page2_id, so exec-node drill-down links resolve to it."""
|
||||
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()]
|
||||
page = copy.deepcopy(pages[0])
|
||||
if page.find("mxGraphModel/root") is None:
|
||||
sys.exit(f"error: {path}: page is compressed (no <root>), cannot copy verbatim")
|
||||
page.set("id", page2_id)
|
||||
page.set("name", "Full Diagram")
|
||||
return ET.tostring(page, encoding="unicode") + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Collapse a big .drawio into an executive-summary view with drill-down.")
|
||||
ap.add_argument("input", help="source .drawio")
|
||||
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
|
||||
ap.add_argument("--clusters", type=int,
|
||||
help="soft hint for cluster count; label propagation picks the "
|
||||
"count automatically and may ignore this")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.clusters:
|
||||
sys.stderr.write("note: --clusters is a soft hint; label propagation "
|
||||
"determines the actual cluster count automatically\n")
|
||||
|
||||
nodes, edges = parse(args.input)
|
||||
if not nodes:
|
||||
sys.exit(f"error: no leaf vertices found in {args.input}")
|
||||
|
||||
community_of = label_propagation(nodes.keys(), edges)
|
||||
communities = {}
|
||||
for nid in sorted(nodes):
|
||||
communities.setdefault(community_of[nid], []).append(nid)
|
||||
|
||||
degree = compute_degree(nodes.keys(), edges)
|
||||
node_labels = {nid: label for nid, (label, _style) in nodes.items()}
|
||||
names = {c: cluster_name(members, node_labels, degree) for c, members in communities.items()}
|
||||
|
||||
crossings = aggregate_edges(edges, community_of)
|
||||
|
||||
page2_id = "full-diagram"
|
||||
exec_nodes = [{"id": f"c_{c}", "label": names[c], "link": f"data:page/id,{page2_id}"}
|
||||
for c in communities]
|
||||
exec_edges = [{"source": f"c_{s}", "target": f"c_{t}", "label": str(n) if n > 1 else ""}
|
||||
for (s, t), n in sorted(crossings.items())]
|
||||
exec_graph = {"direction": "TB", "nodes": exec_nodes, "edges": exec_edges}
|
||||
|
||||
page1 = layout_exec_page(exec_graph)
|
||||
page2 = copy_original_page(args.input, page2_id)
|
||||
xml = "<mxfile>\n" + page1 + page2 + "</mxfile>\n"
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
sys.stderr.write(f"wrote {args.output} ({len(nodes)} nodes -> "
|
||||
f"{len(communities)} clusters)\n")
|
||||
else:
|
||||
sys.stdout.write(xml)
|
||||
sys.stderr.write(f"{len(nodes)} nodes -> {len(communities)} clusters\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
148
skills/drawio-skill/scripts/dockerimports.py
Normal file
148
skills/drawio-skill/scripts/dockerimports.py
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draw the containers that are ACTUALLY running from `docker inspect` output.
|
||||
|
||||
Where composeimports.py reads the *declared* stack (compose file), this reads
|
||||
the *live* one: pipe `docker inspect` of the running containers and it maps the
|
||||
real topology — every container, the user networks they are attached to, the
|
||||
named volumes they mount, and the container->container edges recorded in
|
||||
`links` / compose `depends_on` labels. The output feeds autolayout.py:
|
||||
|
||||
docker inspect $(docker ps -q) | python3 dockerimports.py - -o graph.json
|
||||
python3 autolayout.py graph.json -o running.drawio
|
||||
|
||||
Input is the JSON array `docker inspect` prints (a file path, or `-` for
|
||||
stdin). Containers become rounded boxes (name + image), user networks become
|
||||
green ellipses, named volumes become cylinders — visually matching the compose
|
||||
importer so declared and live diagrams read alike. `--group` boxes containers
|
||||
by their compose project (falling back to their first user network).
|
||||
|
||||
Usage: docker inspect $(docker ps -q) | python3 dockerimports.py - [-o graph.json]
|
||||
[--direction TB|LR] [--group]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
CONTAINER_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;"
|
||||
NETWORK_STYLE = "ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;"
|
||||
VOLUME_STYLE = ("shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;size=15;"
|
||||
"fillColor=#f5f5f5;strokeColor=#666666;")
|
||||
# Docker's built-in networks are topology noise — a compose stack's own
|
||||
# networks are what tell the architecture story.
|
||||
BUILTIN_NETS = {"bridge", "host", "none", "ingress"}
|
||||
|
||||
|
||||
def cname(obj):
|
||||
"""A container's short name (strip docker's leading slash)."""
|
||||
return (obj.get("Name") or obj.get("Id", "")[:12]).lstrip("/")
|
||||
|
||||
|
||||
def links_of(obj):
|
||||
"""Container names this one links to (HostConfig.Links + per-network Links)."""
|
||||
out = set()
|
||||
raw = list((obj.get("HostConfig") or {}).get("Links") or [])
|
||||
for net in ((obj.get("NetworkSettings") or {}).get("Networks") or {}).values():
|
||||
raw.extend((net or {}).get("Links") or [])
|
||||
for link in raw:
|
||||
# "/db:/web/db" -> target container is the part before the first colon.
|
||||
target = str(link).lstrip("/").split(":", 1)[0]
|
||||
if target:
|
||||
out.add(target)
|
||||
return out
|
||||
|
||||
|
||||
def depends_on(obj):
|
||||
"""Compose service names this container depends on (label form)."""
|
||||
label = (obj.get("Config") or {}).get("Labels", {}).get("com.docker.compose.depends_on")
|
||||
if not label:
|
||||
return set()
|
||||
# "db:service_healthy:false,cache:service_started:false" -> {db, cache}
|
||||
return {part.split(":", 1)[0] for part in label.split(",") if part.strip()}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="`docker inspect` output -> autolayout graph JSON.")
|
||||
ap.add_argument("input", help="`docker inspect` JSON file, or - for stdin")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="group containers by compose project (else first network)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.input == "-":
|
||||
text = sys.stdin.read()
|
||||
else:
|
||||
with open(args.input, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.exit(f"error: input is not valid JSON ({exc}) — feed `docker inspect ...`")
|
||||
containers = data if isinstance(data, list) else [data]
|
||||
containers = [c for c in containers if isinstance(c, dict) and c.get("Id")]
|
||||
if not containers:
|
||||
sys.exit("error: no containers found (feed `docker inspect $(docker ps -q)`)")
|
||||
|
||||
names = {cname(c) for c in containers}
|
||||
# compose service label -> container name, so depends_on (which names
|
||||
# services) can resolve to the real container node.
|
||||
svc_to_name = {}
|
||||
for c in containers:
|
||||
svc = (c.get("Config") or {}).get("Labels", {}).get("com.docker.compose.service")
|
||||
if svc:
|
||||
svc_to_name[svc] = cname(c)
|
||||
|
||||
nodes, edges, nets, vols = [], set(), set(), set()
|
||||
for c in containers:
|
||||
name = cname(c)
|
||||
image = (c.get("Config") or {}).get("Image") or "?"
|
||||
labels = (c.get("Config") or {}).get("Labels", {}) or {}
|
||||
node = {"id": name, "label": f"{name}\n{image}", "style": CONTAINER_STYLE,
|
||||
"width": 160, "height": 60}
|
||||
|
||||
attached = [n for n in ((c.get("NetworkSettings") or {}).get("Networks") or {})
|
||||
if n not in BUILTIN_NETS]
|
||||
if args.group:
|
||||
project = labels.get("com.docker.compose.project")
|
||||
grp = project or (attached[0] if attached else None)
|
||||
if grp:
|
||||
node["group"] = str(grp)
|
||||
nodes.append(node)
|
||||
|
||||
for net in attached:
|
||||
nets.add(net)
|
||||
edges.add((name, f"net:{net}"))
|
||||
for m in c.get("Mounts") or []:
|
||||
if m.get("Type") == "volume" and m.get("Name"):
|
||||
vols.add(m["Name"])
|
||||
edges.add((name, f"vol:{m['Name']}"))
|
||||
for target in links_of(c):
|
||||
if target in names and target != name:
|
||||
edges.add((name, target))
|
||||
for dep in depends_on(c):
|
||||
target = svc_to_name.get(dep, dep)
|
||||
if target in names and target != name:
|
||||
edges.add((name, target))
|
||||
|
||||
for net in sorted(nets):
|
||||
nodes.append({"id": f"net:{net}", "label": net, "style": NETWORK_STYLE,
|
||||
"width": 120, "height": 70})
|
||||
for vol in sorted(vols):
|
||||
nodes.append({"id": f"vol:{vol}", "label": vol, "style": VOLUME_STYLE,
|
||||
"width": 120, "height": 70})
|
||||
|
||||
graph = {"direction": args.direction, "nodes": nodes,
|
||||
"edges": [{"source": s, "target": t} for s, t in sorted(edges)]}
|
||||
out = json.dumps(graph, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(out)
|
||||
sys.stderr.write(f"wrote {args.output}\n")
|
||||
else:
|
||||
sys.stdout.write(out)
|
||||
sys.stderr.write(f"{len(nodes)} nodes ({len(containers)} containers, "
|
||||
f"{len(nets)} networks, {len(vols)} volumes), {len(edges)} edges\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
153
skills/drawio-skill/scripts/drawio2mermaid.py
Normal file
153
skills/drawio-skill/scripts/drawio2mermaid.py
Normal file
@@ -0,0 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert a .drawio into Mermaid flowchart text (diagrams-as-code).
|
||||
|
||||
The other reverse tool, `explain.py`, turns a diagram into prose; this turns it
|
||||
into a **Mermaid `flowchart`** you can paste into a Markdown file and have
|
||||
GitHub / GitLab / docs render natively — handy when you want the diagram to live
|
||||
as maintainable text next to the code. Containers become `subgraph`s, edge
|
||||
labels are kept, and a few shapes map to Mermaid node forms (cylinder → database
|
||||
`[( )]`, rhombus → decision `{ }`, else `[ ]`).
|
||||
|
||||
python3 drawio2mermaid.py architecture.drawio # Mermaid to stdout
|
||||
python3 drawio2mermaid.py c4.drawio --fenced -o out.md # ```mermaid fenced
|
||||
|
||||
Multi-page files emit one flowchart per page. This is a structural conversion —
|
||||
styling, colours and vendor icons do not survive (Mermaid has no equivalent);
|
||||
for a faithful, richly-styled diagram keep the `.drawio`.
|
||||
|
||||
Usage: python3 drawio2mermaid.py <file.drawio> [-o out] [--direction TD|LR] [--fenced]
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def clean(text):
|
||||
"""Strip the HTML draw.io stores in labels; keep line breaks as <br/>."""
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"<br\s*/?>", "\n", text, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = html.unescape(text)
|
||||
return re.sub(r"[ \t]+", " ", text).strip()
|
||||
|
||||
|
||||
def esc(label):
|
||||
"""Mermaid-safe quoted label: escape quotes, newlines -> <br/>."""
|
||||
label = label.replace('"', """).replace("\n", "<br/>")
|
||||
return label or " "
|
||||
|
||||
|
||||
def node_form(safe_id, label, style):
|
||||
"""Mermaid node declaration, shape chosen from the draw.io style."""
|
||||
lbl = f'"{esc(label)}"'
|
||||
if "shape=cylinder" in style or "shape=datastore" in style:
|
||||
return f"{safe_id}[({lbl})]" # database
|
||||
if "rhombus" in style:
|
||||
return f"{safe_id}{{{lbl}}}" # decision
|
||||
if "ellipse" in style or "shape=cloud" in style:
|
||||
return f"{safe_id}(({lbl}))" # circle-ish
|
||||
return f"{safe_id}[{lbl}]" # default box
|
||||
|
||||
|
||||
def cells_of(page):
|
||||
"""(cell, id, label) for a page, unwrapping UserObject/object wrappers."""
|
||||
model = page.find("mxGraphModel")
|
||||
root = model.find("root") if model is not None else None
|
||||
if root is None:
|
||||
return None
|
||||
out = []
|
||||
for child in root:
|
||||
if child.tag == "mxCell":
|
||||
out.append((child, child.get("id"), clean(child.get("value"))))
|
||||
elif child.tag in ("UserObject", "object"):
|
||||
inner = child.find("mxCell")
|
||||
if inner is not None:
|
||||
inner.set("id", child.get("id", ""))
|
||||
out.append((inner, child.get("id"),
|
||||
clean(child.get("label") or child.get("value"))))
|
||||
return out
|
||||
|
||||
|
||||
def page_to_mermaid(page, direction):
|
||||
cells = cells_of(page)
|
||||
if cells is None:
|
||||
return "%% (compressed page — skipped)"
|
||||
label = {cid: lbl for _, cid, lbl in cells}
|
||||
style = {cid: (c.get("style") or "") for c, cid, _ in cells}
|
||||
parents = {c.get("parent") for c, _, _ in cells if c.get("parent")}
|
||||
|
||||
verts = [(c, cid) for c, cid, _ in cells if c.get("vertex") == "1"]
|
||||
containers = {cid for c, cid in verts if cid in parents}
|
||||
leaves = [(c, cid) for c, cid in verts
|
||||
if cid not in containers and "edgeLabel" not in style.get(cid, "")]
|
||||
|
||||
sid = {cid: f"n{i}" for i, (_, cid) in enumerate(leaves)} # mermaid-safe ids
|
||||
lines = [f"flowchart {direction}"]
|
||||
|
||||
# Nodes, grouped into subgraphs by their container.
|
||||
by_container = {}
|
||||
for c, cid in leaves:
|
||||
parent = c.get("parent")
|
||||
key = parent if parent in containers else None
|
||||
by_container.setdefault(key, []).append(cid)
|
||||
|
||||
def emit_node(cid, indent):
|
||||
lines.append(indent + node_form(sid[cid], label.get(cid) or cid, style.get(cid, "")))
|
||||
|
||||
for cid in by_container.get(None, []):
|
||||
emit_node(cid, " ")
|
||||
for cont, members in by_container.items():
|
||||
if cont is None:
|
||||
continue
|
||||
lines.append(f' subgraph {sid.get(cont, "g_" + cont)}["{esc(label.get(cont) or "")}"]')
|
||||
for cid in members:
|
||||
emit_node(cid, " ")
|
||||
lines.append(" end")
|
||||
|
||||
# Edges (only between leaves we emitted).
|
||||
for c, _, _ in cells:
|
||||
if c.get("edge") != "1":
|
||||
continue
|
||||
s, t = c.get("source"), c.get("target")
|
||||
if s in sid and t in sid:
|
||||
lbl = clean(c.get("value"))
|
||||
arrow = f'-->|"{esc(lbl)}"|' if lbl else "-->"
|
||||
lines.append(f" {sid[s]} {arrow} {sid[t]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Convert a .drawio to Mermaid flowchart text.")
|
||||
ap.add_argument("file")
|
||||
ap.add_argument("-o", "--output", help="output path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TD", choices=["TD", "LR", "TB", "RL", "BT"])
|
||||
ap.add_argument("--fenced", action="store_true", help="wrap each graph in a ```mermaid fence")
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
root = ET.parse(args.file).getroot()
|
||||
except (ET.ParseError, OSError) as exc:
|
||||
sys.exit(f"error: cannot parse {args.file}: {exc}")
|
||||
pages = root.findall("diagram") or [root]
|
||||
|
||||
blocks = []
|
||||
for i, page in enumerate(pages, 1):
|
||||
graph = page_to_mermaid(page, args.direction)
|
||||
name = page.get("name")
|
||||
if len(pages) > 1 and name:
|
||||
graph = f"%% Page {i}: {name}\n{graph}"
|
||||
blocks.append(f"```mermaid\n{graph}\n```" if args.fenced else graph)
|
||||
|
||||
text = ("\n\n".join(blocks)).rstrip() + "\n"
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
sys.stderr.write(f"wrote {args.output} ({len(pages)} page{'s' if len(pages) != 1 else ''})\n")
|
||||
else:
|
||||
sys.stdout.write(text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
112
skills/drawio-skill/scripts/drawio2pptx.py
Normal file
112
skills/drawio-skill/scripts/drawio2pptx.py
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn a .drawio into a PowerPoint deck — one slide per page.
|
||||
|
||||
Exports each page of a (multi-page) .drawio to a PNG via the draw.io CLI and
|
||||
lays them into a 16:9 .pptx, one page per slide, scaled to fit with the page
|
||||
name as the slide title. A C4 model (Context / Container / Component) becomes a
|
||||
ready-to-present deck; any multi-page diagram becomes a slide sequence.
|
||||
|
||||
python3 drawio2pptx.py c4.drawio -o c4.pptx
|
||||
python3 drawio2pptx.py architecture.drawio # -> architecture.pptx
|
||||
|
||||
Needs the draw.io CLI (for the PNG export) and the `python-pptx` package
|
||||
(`pip install python-pptx`) for writing the deck. Slides are 13.333in × 7.5in
|
||||
(16:9); each image is centred and scaled to fit inside a small margin.
|
||||
|
||||
Usage: python3 drawio2pptx.py <file.drawio> [-o out.pptx] [--scale N]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def page_names(path):
|
||||
"""Names of the <diagram> pages, in order (None where unnamed)."""
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except (ET.ParseError, OSError) as exc:
|
||||
sys.exit(f"error: cannot parse {path}: {exc}")
|
||||
diagrams = root.findall("diagram")
|
||||
return [d.get("name") for d in diagrams] if diagrams else [None]
|
||||
|
||||
|
||||
def png_size(path):
|
||||
"""(width, height) in pixels from a PNG's IHDR header."""
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(24)
|
||||
return struct.unpack(">II", head[16:24])
|
||||
|
||||
|
||||
def export_page(drawio_file, index, out_png, scale):
|
||||
"""Export one page (1-based index) to PNG via the draw.io CLI."""
|
||||
r = subprocess.run(["drawio", "-x", "-f", "png", "--page-index", str(index),
|
||||
"-s", str(scale), "-o", out_png, drawio_file],
|
||||
capture_output=True)
|
||||
return r.returncode == 0 and os.path.exists(out_png)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Export a .drawio to a PowerPoint deck (one slide per page).")
|
||||
ap.add_argument("file")
|
||||
ap.add_argument("-o", "--output", help="output .pptx (default: alongside input)")
|
||||
ap.add_argument("--scale", type=float, default=2.0, help="PNG export scale (default 2)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(args.file):
|
||||
sys.exit(f"error: {args.file} not found")
|
||||
try:
|
||||
from pptx import Presentation
|
||||
from pptx.util import Emu, Pt
|
||||
except ImportError:
|
||||
sys.exit("error: python-pptx is required (pip install python-pptx)")
|
||||
|
||||
names = page_names(args.file)
|
||||
out = args.output or os.path.splitext(args.file)[0] + ".pptx"
|
||||
|
||||
prs = Presentation()
|
||||
prs.slide_width = Emu(12192000) # 13.333in — 16:9
|
||||
prs.slide_height = Emu(6858000) # 7.5in
|
||||
blank = prs.slide_layouts[6] # the built-in "Blank" layout
|
||||
sw, sh = int(prs.slide_width), int(prs.slide_height)
|
||||
margin = Emu(457200) # 0.5in
|
||||
title_h = Emu(500000)
|
||||
|
||||
made = 0
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for i, name in enumerate(names, 1): # draw.io --page-index is 1-based
|
||||
png = os.path.join(tmp, f"page{i}.png")
|
||||
if not export_page(args.file, i, png, args.scale):
|
||||
sys.stderr.write(f"warning: page {i} export failed — skipped\n")
|
||||
continue
|
||||
slide = prs.slides.add_slide(blank)
|
||||
top_pad = margin
|
||||
if name:
|
||||
box = slide.shapes.add_textbox(margin, Emu(180000),
|
||||
Emu(sw - 2 * int(margin)), title_h)
|
||||
tf = box.text_frame
|
||||
tf.text = name
|
||||
tf.paragraphs[0].runs[0].font.size = Pt(20)
|
||||
tf.paragraphs[0].runs[0].font.bold = True
|
||||
top_pad = Emu(180000) + title_h
|
||||
|
||||
cw, ch = sw - 2 * int(margin), sh - int(top_pad) - int(margin)
|
||||
pw, ph = png_size(png)
|
||||
scale = min(cw / pw, ch / ph) # fit, preserve aspect
|
||||
iw, ih = int(pw * scale), int(ph * scale)
|
||||
left = Emu(int((sw - iw) / 2))
|
||||
top = Emu(int(top_pad) + int((ch - ih) / 2))
|
||||
slide.shapes.add_picture(png, left, top, width=Emu(iw), height=Emu(ih))
|
||||
made += 1
|
||||
|
||||
if not made:
|
||||
sys.exit("error: no pages exported (is the draw.io CLI installed?)")
|
||||
prs.save(out)
|
||||
sys.stderr.write(f"wrote {out} ({made} slide{'s' if made != 1 else ''})\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
164
skills/drawio-skill/scripts/drawiodiff.py
Normal file
164
skills/drawio-skill/scripts/drawiodiff.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/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()
|
||||
230
skills/drawio-skill/scripts/drawiohtml.py
Normal file
230
skills/drawio-skill/scripts/drawiohtml.py
Normal file
@@ -0,0 +1,230 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Publish a .drawio as a single interactive HTML viewer.
|
||||
|
||||
Exports every page to SVG via the draw.io CLI and inlines them into ONE
|
||||
self-contained .html with pan (drag), zoom (wheel / buttons), page tabs,
|
||||
node search, and working links — external links open normally and internal
|
||||
page links ("data:page/id,…", e.g. a C4 model's drill-down) switch tabs
|
||||
inside the viewer. Share the file with anyone: no draw.io, no server,
|
||||
no external requests.
|
||||
|
||||
python3 drawiohtml.py architecture.drawio -o architecture.html
|
||||
python3 drawiohtml.py c4.drawio # -> c4.html, drill-down works
|
||||
|
||||
Search matches node text (draw.io wraps every cell in <g data-cell-id>);
|
||||
matches glow, Enter cycles through them and centres each. Internal page
|
||||
links survive export by being rewritten to "#page-<id>" fragments first
|
||||
(draw.io drops raw data:page/id links from SVG).
|
||||
|
||||
Usage: python3 drawiohtml.py <file.drawio> [-o out.html]
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
PAGE_LINK = "data:page/id,"
|
||||
|
||||
|
||||
def pages_of(path):
|
||||
"""[(id, name)] of the <diagram> pages, in order."""
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except (ET.ParseError, OSError) as exc:
|
||||
sys.exit(f"error: cannot parse {path}: {exc}")
|
||||
return [(d.get("id") or f"p{i}", d.get("name") or f"Page {i + 1}")
|
||||
for i, d in enumerate(root.findall("diagram"))]
|
||||
|
||||
|
||||
def rewrite_page_links(tree):
|
||||
"""data:page/id,X links -> #page-X (fragments survive SVG export). Returns count."""
|
||||
n = 0
|
||||
for el in tree.getroot().iter():
|
||||
link = el.get("link")
|
||||
if link and link.startswith(PAGE_LINK):
|
||||
el.set("link", "#page-" + link[len(PAGE_LINK):])
|
||||
n += 1
|
||||
return n
|
||||
|
||||
|
||||
def export_svg(drawio_file, index, out_svg):
|
||||
"""Export one page (1-based index) to SVG via the draw.io CLI."""
|
||||
r = subprocess.run(["drawio", "-x", "-f", "svg", "--embed-svg-images",
|
||||
"--page-index", str(index),
|
||||
"-o", out_svg, drawio_file], capture_output=True)
|
||||
return r.returncode == 0 and os.path.exists(out_svg)
|
||||
|
||||
|
||||
def strip_prolog(svg):
|
||||
"""Drop any XML declaration / doctype so the SVG can be inlined in HTML."""
|
||||
return re.sub(r"^\s*(<\?xml[^>]*\?>\s*|<!DOCTYPE[^>]*>\s*)*", "", svg)
|
||||
|
||||
|
||||
def build_html(title, page_meta, svgs):
|
||||
"""One self-contained viewer page. page_meta = [(id, name)] aligned with svgs."""
|
||||
sections = "\n".join(
|
||||
f'<div class="page" data-pgid="{html.escape(pid, quote=True)}">{svg}</div>'
|
||||
for (pid, _), svg in zip(page_meta, svgs))
|
||||
tabs = json.dumps([{"id": pid, "name": name} for pid, name in page_meta]) \
|
||||
.replace("</", "<\\/")
|
||||
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{html.escape(title)}</title><style>
|
||||
:root{{color-scheme:light dark}}
|
||||
*{{box-sizing:border-box}}
|
||||
body{{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
|
||||
background:#f6f7f9;color:#1a1a1a;height:100vh;display:flex;flex-direction:column}}
|
||||
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
|
||||
header{{padding:10px 16px 8px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}}
|
||||
h1{{margin:0;font-size:15px;font-weight:600}}
|
||||
nav{{display:flex;gap:6px;flex-wrap:wrap}}
|
||||
button,input{{font:inherit;color:inherit}}
|
||||
nav button,.ctl button{{padding:4px 10px;border:1px solid #0002;border-radius:8px;
|
||||
background:#fff;cursor:pointer}}
|
||||
@media(prefers-color-scheme:dark){{nav button,.ctl button{{background:#262b31;border-color:#fff2}}}}
|
||||
nav button.on{{border-color:#0d99ff;color:#0d99ff;font-weight:600}}
|
||||
.ctl{{display:flex;gap:8px;align-items:center;margin-left:auto}}
|
||||
.ctl input{{padding:4px 10px;border:1px solid #0002;border-radius:8px;background:#fff;width:180px}}
|
||||
@media(prefers-color-scheme:dark){{.ctl input{{background:#262b31;border-color:#fff2}}}}
|
||||
#hits{{font-size:12px;color:#889;min-width:56px}}
|
||||
#stage{{flex:1;overflow:hidden;position:relative;background:#fff;
|
||||
border-top:1px solid #0001;cursor:grab;touch-action:none}}
|
||||
@media(prefers-color-scheme:dark){{#stage{{background:#1e2226;border-color:#fff2}}}}
|
||||
#stage.drag{{cursor:grabbing}}
|
||||
.page{{position:absolute;transform-origin:0 0;display:none}}
|
||||
.page.on{{display:block}}
|
||||
.page svg{{display:block}}
|
||||
.hit{{filter:drop-shadow(0 0 3px #ff9800) drop-shadow(0 0 6px #ff980088)}}
|
||||
.hit.cursel{{filter:drop-shadow(0 0 4px #f44336) drop-shadow(0 0 9px #f44336aa)}}
|
||||
</style></head><body>
|
||||
<header><h1>{html.escape(title)}</h1><nav id="tabs"></nav>
|
||||
<div class="ctl">
|
||||
<input id="q" type="search" placeholder="Search nodes… Enter = next">
|
||||
<span id="hits"></span>
|
||||
<button id="zout" title="zoom out">−</button><button id="zin" title="zoom in">+</button>
|
||||
<button id="fit">Fit</button>
|
||||
</div></header>
|
||||
<main id="stage">
|
||||
{sections}
|
||||
</main>
|
||||
<script>
|
||||
const META={tabs};
|
||||
const stage=document.getElementById('stage');
|
||||
const pages=[...document.querySelectorAll('.page')];
|
||||
const view=pages.map(()=>({{x:0,y:0,s:1}}));
|
||||
let cur=0,hits=[],hi=-1;
|
||||
const tabs=document.getElementById('tabs');
|
||||
if(META.length>1)META.forEach((m,i)=>{{
|
||||
const b=document.createElement('button');b.textContent=m.name;
|
||||
b.onclick=()=>show(i);tabs.appendChild(b);}});
|
||||
function apply(){{const v=view[cur];
|
||||
pages[cur].style.transform=`translate(${{v.x}}px,${{v.y}}px) scale(${{v.s}})`;}}
|
||||
function svgSize(i){{const s=pages[i].querySelector('svg');
|
||||
return[parseFloat(s.getAttribute('width'))||800,
|
||||
parseFloat(s.getAttribute('height'))||600];}}
|
||||
function fit(){{const[w,h]=svgSize(cur),r=stage.getBoundingClientRect(),
|
||||
s=Math.min((r.width-40)/w,(r.height-40)/h,4);
|
||||
view[cur]={{s,x:(r.width-w*s)/2,y:(r.height-h*s)/2}};apply();}}
|
||||
function show(i){{cur=i;
|
||||
pages.forEach((p,j)=>p.classList.toggle('on',j===i));
|
||||
[...tabs.children].forEach((b,j)=>b.classList.toggle('on',j===i));
|
||||
if(!pages[i].dataset.seen){{pages[i].dataset.seen=1;fit();}}else apply();
|
||||
search();}}
|
||||
stage.addEventListener('wheel',e=>{{e.preventDefault();const v=view[cur],
|
||||
r=stage.getBoundingClientRect(),mx=e.clientX-r.left,my=e.clientY-r.top,
|
||||
k=Math.exp(-e.deltaY*0.0015),s=Math.min(Math.max(v.s*k,0.05),8);
|
||||
v.x=mx-(mx-v.x)*s/v.s;v.y=my-(my-v.y)*s/v.s;v.s=s;apply();}},{{passive:false}});
|
||||
let drag=null;
|
||||
stage.addEventListener('pointerdown',e=>{{if(e.target.closest('a'))return;
|
||||
drag={{x:e.clientX,y:e.clientY}};stage.classList.add('drag');
|
||||
stage.setPointerCapture(e.pointerId);}});
|
||||
stage.addEventListener('pointermove',e=>{{if(!drag)return;const v=view[cur];
|
||||
v.x+=e.clientX-drag.x;v.y+=e.clientY-drag.y;
|
||||
drag={{x:e.clientX,y:e.clientY}};apply();}});
|
||||
stage.addEventListener('pointerup',()=>{{drag=null;stage.classList.remove('drag');}});
|
||||
function zoom(k){{const v=view[cur],r=stage.getBoundingClientRect(),
|
||||
mx=r.width/2,my=r.height/2,s=Math.min(Math.max(v.s*k,0.05),8);
|
||||
v.x=mx-(mx-v.x)*s/v.s;v.y=my-(my-v.y)*s/v.s;v.s=s;apply();}}
|
||||
document.getElementById('zin').onclick=()=>zoom(1.25);
|
||||
document.getElementById('zout').onclick=()=>zoom(0.8);
|
||||
document.getElementById('fit').onclick=fit;
|
||||
// Internal page links: any anchor whose target ends in #page-<id> switches tabs.
|
||||
document.addEventListener('click',e=>{{const a=e.target.closest('a');
|
||||
if(!a)return;const href=a.getAttribute('xlink:href')||a.getAttribute('href')||'';
|
||||
const m=href.match(/#page-(.+)$/);if(!m)return;e.preventDefault();
|
||||
const i=META.findIndex(p=>p.id===decodeURIComponent(m[1]));if(i>=0)show(i);}},true);
|
||||
const q=document.getElementById('q'),hitEl=document.getElementById('hits');
|
||||
function search(){{
|
||||
hits.forEach(g=>g.classList.remove('hit','cursel'));hits=[];hi=-1;
|
||||
const t=q.value.trim().toLowerCase();
|
||||
if(t){{
|
||||
const all=[...pages[cur].querySelectorAll('g[data-cell-id]')]
|
||||
.filter(g=>!['0','1'].includes(g.dataset.cellId))
|
||||
.filter(g=>g.textContent.toLowerCase().includes(t));
|
||||
hits=all.filter(g=>!all.some(o=>o!==g&&g.contains(o))); // innermost only
|
||||
hits.forEach(g=>g.classList.add('hit'));
|
||||
}}
|
||||
hitEl.textContent=t?hits.length+' hit'+(hits.length===1?'':'s'):'';}}
|
||||
function centre(g){{const v=view[cur],r=stage.getBoundingClientRect(),
|
||||
b=g.getBoundingClientRect();
|
||||
v.x+=r.left+r.width/2-(b.left+b.width/2);
|
||||
v.y+=r.top+r.height/2-(b.top+b.height/2);apply();}}
|
||||
q.addEventListener('input',search);
|
||||
q.addEventListener('keydown',e=>{{
|
||||
if(e.key==='Escape'){{q.value='';search();q.blur();}}
|
||||
if(e.key!=='Enter'||!hits.length)return;
|
||||
if(hi>=0)hits[hi].classList.remove('cursel');
|
||||
hi=(hi+1)%hits.length;hits[hi].classList.add('cursel');centre(hits[hi]);}});
|
||||
show(0);
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Export a .drawio to a self-contained interactive HTML viewer.")
|
||||
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")
|
||||
meta = pages_of(args.file)
|
||||
if not meta:
|
||||
sys.exit(f"error: no <diagram> pages in {args.file}")
|
||||
|
||||
tree = ET.parse(args.file)
|
||||
relinked = rewrite_page_links(tree)
|
||||
|
||||
svgs, kept = [], []
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
src = args.file
|
||||
if relinked: # export the rewritten copy instead
|
||||
src = os.path.join(tmp, "relinked.drawio")
|
||||
tree.write(src, encoding="utf-8", xml_declaration=False)
|
||||
for i, (pid, name) in enumerate(meta, 1): # draw.io --page-index is 1-based
|
||||
out = os.path.join(tmp, f"p{i}.svg")
|
||||
if not export_svg(src, i, out):
|
||||
sys.stderr.write(f"warning: page {i} ({name}) export failed — skipped\n")
|
||||
continue
|
||||
with open(out, encoding="utf-8") as f:
|
||||
svgs.append(strip_prolog(f.read()))
|
||||
kept.append((pid, name))
|
||||
|
||||
if not svgs:
|
||||
sys.exit("error: no pages exported (is the draw.io CLI installed?)")
|
||||
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, kept, svgs))
|
||||
sys.stderr.write(f"wrote {out} ({len(svgs)} page{'s' if len(svgs) != 1 else ''}"
|
||||
+ (f", {relinked} drill-down link{'s' if relinked != 1 else ''}" if relinked else "")
|
||||
+ ")\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
211
skills/drawio-skill/scripts/edgeports.py
Normal file
211
skills/drawio-skill/scripts/edgeports.py
Normal file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Assign edge connection points (ports) on an existing .drawio file.
|
||||
|
||||
draw.io's floating connections attach every edge of a node to the middle of
|
||||
whichever side faces the other endpoint. When a node has several connections
|
||||
leaving the same side they all land on the same point, so the lines stack and
|
||||
overlap — the usual complaint on swimlane / cross-functional flowcharts, where
|
||||
handoff edges between lanes share long orthogonal corridors.
|
||||
|
||||
This pass pins ``exitX/exitY`` and ``entryX/entryY`` instead:
|
||||
|
||||
1. Resolve every vertex to absolute coordinates (through swimlane/container
|
||||
parents, via ``validate.abs_rect``).
|
||||
2. For each edge end, pick the side of the node that faces the other endpoint
|
||||
(whichever of dx/dy dominates, measured centre-to-centre).
|
||||
3. Group the ends by (node, side) and sort each group along the side by the far
|
||||
endpoint's position across that axis. Sorting by the far endpoint is what
|
||||
removes crossings: two edges leaving the same side keep their relative order
|
||||
instead of swapping over each other.
|
||||
4. Spread the group evenly over the side — k ends get slots 1/(k+1) .. k/(k+1).
|
||||
|
||||
Only sides with 2+ ends are touched. Edges that already pin a port are left
|
||||
alone, so hand-tuned geometry survives a re-run. Idempotent: running it twice
|
||||
produces the same file.
|
||||
|
||||
This is a *port* assignment, not a router — it fixes lines stacking at the
|
||||
shape boundary, not an edge crossing an unrelated shape in the middle of its
|
||||
run. For that, add waypoints (see references/xml-authoring.md).
|
||||
|
||||
Usage:
|
||||
python3 edgeports.py diagram.drawio # in place
|
||||
python3 edgeports.py diagram.drawio -o routed.drawio
|
||||
python3 edgeports.py diagram.drawio --dry-run # report only
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"validate", os.path.join(os.path.dirname(os.path.abspath(__file__)), "validate.py"))
|
||||
validate = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(validate)
|
||||
|
||||
# Port coordinates per side. Each entry is (fixed_axis_value, varies_along_x).
|
||||
# 'varies_along_x' says which coordinate the evenly-spaced slot fills in.
|
||||
SIDES = {
|
||||
"N": (0.0, True), # top edge: y=0, x varies
|
||||
"S": (1.0, True), # bottom edge: y=1, x varies
|
||||
"W": (0.0, False), # left edge: x=0, y varies
|
||||
"E": (1.0, False), # right edge: x=1, y varies
|
||||
}
|
||||
|
||||
|
||||
def centre(r):
|
||||
x, y, w, h = r
|
||||
return (x + w / 2.0, y + h / 2.0)
|
||||
|
||||
|
||||
def side_facing(src_rect, dst_rect):
|
||||
"""Which side of src faces dst: whichever of dx/dy dominates."""
|
||||
sx, sy = centre(src_rect)
|
||||
dx_, dy_ = centre(dst_rect)
|
||||
dx, dy = dx_ - sx, dy_ - sy
|
||||
if abs(dx) >= abs(dy):
|
||||
return "E" if dx >= 0 else "W"
|
||||
return "S" if dy >= 0 else "N"
|
||||
|
||||
|
||||
def has_port(style, end):
|
||||
"""True if the edge already pins this end's port (hand-tuned — leave it)."""
|
||||
prefix = "exit" if end == "source" else "entry"
|
||||
return (validate.style_num(style, prefix + "X") is not None
|
||||
and validate.style_num(style, prefix + "Y") is not None)
|
||||
|
||||
|
||||
def set_style(style, end, px, py):
|
||||
"""Return style with this end's port keys set, other keys order-preserved."""
|
||||
prefix = "exit" if end == "source" else "entry"
|
||||
drop = {prefix + "X", prefix + "Y", prefix + "Dx", prefix + "Dy"}
|
||||
parts = [p for p in (style or "").split(";")
|
||||
if p and p.split("=", 1)[0] not in drop]
|
||||
# Dx/Dy are perpendicular offsets in px; reset them so a re-run is stable.
|
||||
parts += [f"{prefix}X={px:g}", f"{prefix}Y={py:g}",
|
||||
f"{prefix}Dx=0", f"{prefix}Dy=0"]
|
||||
return ";".join(parts) + ";"
|
||||
|
||||
|
||||
def assign(cells, by_id):
|
||||
"""Compute {(edge_elem, end): (px, py)} for every end worth pinning."""
|
||||
rects = {}
|
||||
for c in cells:
|
||||
if c.get("vertex") == "1" and not validate.is_edge_label(c):
|
||||
r = validate.abs_rect(c, by_id)
|
||||
if r and not any(v != v for v in r): # NaN width/height guard
|
||||
rects[c.get("id")] = r
|
||||
|
||||
# Collect ends: one entry per (edge, end) whose node and peer are known.
|
||||
groups = {}
|
||||
for e in cells:
|
||||
if e.get("edge") != "1":
|
||||
continue
|
||||
style = e.get("style") or ""
|
||||
src, dst = e.get("source"), e.get("target")
|
||||
if src not in rects or dst not in rects:
|
||||
continue # dangling — validate.py's job
|
||||
for end, me, peer in (("source", src, dst), ("target", dst, src)):
|
||||
if has_port(style, end):
|
||||
continue
|
||||
side = side_facing(rects[me], rects[peer])
|
||||
groups.setdefault((me, side), []).append((e, end, rects[peer]))
|
||||
|
||||
ports = {}
|
||||
for (node_id, side), ends in groups.items():
|
||||
if len(ends) < 2:
|
||||
continue # single edge: centre is fine
|
||||
fixed, along_x = SIDES[side]
|
||||
# Sort by the far endpoint's position across the axis we spread along.
|
||||
# Tie-break on the other axis, then edge id, so the order is total and
|
||||
# the output is deterministic.
|
||||
ends.sort(key=lambda t: (centre(t[2])[0] if along_x else centre(t[2])[1],
|
||||
centre(t[2])[1] if along_x else centre(t[2])[0],
|
||||
t[0].get("id") or ""))
|
||||
for i, (edge, end, _) in enumerate(ends):
|
||||
slot = (i + 1) / float(len(ends) + 1)
|
||||
ports[(edge, end)] = (slot, fixed) if along_x else (fixed, slot)
|
||||
return ports
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("file", help="input .drawio")
|
||||
ap.add_argument("-o", "--output", help="output path (default: edit in place)")
|
||||
ap.add_argument("--dry-run", action="store_true",
|
||||
help="report what would change, write nothing")
|
||||
args = ap.parse_args()
|
||||
|
||||
try:
|
||||
tree = ET.parse(args.file)
|
||||
except ET.ParseError as exc:
|
||||
sys.exit(f"error: {args.file} is not parseable XML ({exc}). "
|
||||
"Compressed .drawio files must be saved uncompressed first.")
|
||||
|
||||
total = 0
|
||||
for model in tree.getroot().iter("mxGraphModel"):
|
||||
cells = list(model.iter("mxCell"))
|
||||
by_id = {c.get("id"): c for c in cells if c.get("id")}
|
||||
ports = assign(cells, by_id)
|
||||
for (edge, end), (px, py) in ports.items():
|
||||
edge.set("style", set_style(edge.get("style") or "", end, px, py))
|
||||
total += len(ports)
|
||||
|
||||
if args.dry_run:
|
||||
print(f"{total} edge end(s) would be pinned in {args.file}")
|
||||
return
|
||||
out = args.output or args.file
|
||||
tree.write(out, encoding="utf-8", xml_declaration=False)
|
||||
print(f"{total} edge end(s) pinned -> {out}")
|
||||
|
||||
|
||||
def demo():
|
||||
"""Self-check: three edges leaving one node's east side get distinct,
|
||||
non-crossing ports, and a re-run is a no-op."""
|
||||
xml = """<mxfile><diagram><mxGraphModel><root>
|
||||
<mxCell id="0"/><mxCell id="1" parent="0"/>
|
||||
<mxCell id="lane" vertex="1" parent="1">
|
||||
<mxGeometry x="100" y="0" width="400" height="400" as="geometry"/></mxCell>
|
||||
<mxCell id="hub" vertex="1" parent="lane">
|
||||
<mxGeometry x="0" y="150" width="80" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="a" vertex="1" parent="1">
|
||||
<mxGeometry x="600" y="300" width="80" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="b" vertex="1" parent="1">
|
||||
<mxGeometry x="600" y="100" width="80" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="c" vertex="1" parent="1">
|
||||
<mxGeometry x="600" y="200" width="80" height="40" as="geometry"/></mxCell>
|
||||
<mxCell id="e1" edge="1" parent="1" source="hub" target="a" style="rounded=1;"/>
|
||||
<mxCell id="e2" edge="1" parent="1" source="hub" target="b" style="rounded=1;"/>
|
||||
<mxCell id="e3" edge="1" parent="1" source="hub" target="c" style="rounded=1;"/>
|
||||
<mxCell id="e4" edge="1" parent="1" source="hub" target="a"
|
||||
style="rounded=1;exitX=1;exitY=0.9;"/>
|
||||
</root></mxGraphModel></diagram></mxfile>"""
|
||||
root = ET.fromstring(xml)
|
||||
cells = list(root.iter("mxCell"))
|
||||
by_id = {c.get("id"): c for c in cells if c.get("id")}
|
||||
|
||||
# hub sits inside 'lane' (x=100), so its absolute x is 100, not 0. Without
|
||||
# parent resolution every target would look like it was to the west.
|
||||
assert validate.abs_rect(by_id["hub"], by_id)[0] == 100.0
|
||||
|
||||
ports = assign(cells, by_id)
|
||||
exits = {e.get("id"): p for (e, end), p in ports.items() if end == "source"}
|
||||
assert set(exits) == {"e1", "e2", "e3"}, exits # e4 pre-pinned, untouched
|
||||
assert all(x == 1.0 for x, _ in exits.values()) # all leave the east side
|
||||
ys = [exits[i][1] for i in ("e2", "e3", "e1")] # targets ordered top->bottom
|
||||
assert ys == sorted(ys), ys # ports follow => no crossing
|
||||
assert len(set(ys)) == 3, ys # and no two stack
|
||||
|
||||
for (edge, end), (px, py) in ports.items():
|
||||
edge.set("style", set_style(edge.get("style"), end, px, py))
|
||||
assert not assign(cells, by_id), "second run must be a no-op"
|
||||
print("ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--demo" in sys.argv:
|
||||
demo()
|
||||
else:
|
||||
main()
|
||||
58
skills/drawio-skill/scripts/encode_drawio_url.py
Normal file
58
skills/drawio-skill/scripts/encode_drawio_url.py
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Encode a .drawio XML file into a diagrams.net browser URL.
|
||||
|
||||
Used as the browser fallback when the draw.io desktop CLI is unavailable.
|
||||
The diagram XML is carried in the URL fragment (after `#`), so nothing is
|
||||
uploaded to any server.
|
||||
|
||||
Two modes:
|
||||
(default) read-only viewer -> https://viewer.diagrams.net/...#R<payload>
|
||||
--edit editable editor -> https://app.diagrams.net/...#create=<payload>
|
||||
|
||||
Usage: python3 encode_drawio_url.py [--edit] <path/to/input.drawio>
|
||||
"""
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import urllib.parse
|
||||
import zlib
|
||||
|
||||
|
||||
def _deflate_b64(xml: str) -> str:
|
||||
# draw.io's loader runs JS decodeURIComponent on the inflated string, so the
|
||||
# XML MUST be percent-encoded (encodeURIComponent) BEFORE deflate — otherwise
|
||||
# a literal `%` or any non-ASCII (e.g. CJK) label makes the browser throw
|
||||
# "URI malformed" and the diagram never opens. encodeURIComponent leaves
|
||||
# only A-Za-z0-9 and -_.!~*'() unescaped, which `quote` reproduces here.
|
||||
pre = urllib.parse.quote(xml, safe="!~*'()")
|
||||
c = zlib.compressobj(9, zlib.DEFLATED, -zlib.MAX_WBITS)
|
||||
compressed = c.compress(pre.encode("utf-8")) + c.flush()
|
||||
# Standard base64 (atob rejects url-safe -/_); strip newlines.
|
||||
return base64.b64encode(compressed).decode("utf-8").replace("\n", "")
|
||||
|
||||
|
||||
def encode(xml: str) -> str:
|
||||
"""Read-only viewer URL (mxGraph `#R` raw-inflate format)."""
|
||||
return (
|
||||
"https://viewer.diagrams.net/?tags=%7B%7D&lightbox=1&edit=_blank#R"
|
||||
+ urllib.parse.quote(_deflate_b64(xml), safe="")
|
||||
)
|
||||
|
||||
|
||||
def edit_url(xml: str) -> str:
|
||||
"""Editable editor URL — opens directly in the draw.io editor."""
|
||||
payload = json.dumps({"type": "xml", "compressed": True, "data": _deflate_b64(xml)})
|
||||
return (
|
||||
"https://app.diagrams.net/?grid=0&pv=0&border=10&edit=_blank#create="
|
||||
+ urllib.parse.quote(payload, safe="")
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = [a for a in sys.argv[1:] if a != "--edit"]
|
||||
if len(args) != 1:
|
||||
print("usage: encode_drawio_url.py [--edit] <path>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
with open(args[0], "r", encoding="utf-8") as f:
|
||||
xml = f.read()
|
||||
print(edit_url(xml) if "--edit" in sys.argv[1:] else encode(xml))
|
||||
156
skills/drawio-skill/scripts/explain.py
Normal file
156
skills/drawio-skill/scripts/explain.py
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read a .drawio and describe it as structured Markdown.
|
||||
|
||||
The inverse of the skill's generators: instead of data -> diagram, this turns a
|
||||
diagram -> documentation. It lists the components (grouped by their container /
|
||||
swimlane / tier), the relations between them (edge labels become the relation
|
||||
verb), and a per-page breakdown for multi-page files (e.g. a C4 model). Handy
|
||||
for dropping an architecture summary into a README or PR, or for a text-only
|
||||
description of a diagram someone handed you.
|
||||
|
||||
python3 explain.py architecture.drawio # Markdown to stdout
|
||||
python3 explain.py c4.drawio -o architecture.md
|
||||
|
||||
Components are the leaf vertices; a vertex that contains others is treated as a
|
||||
container and becomes a grouping heading. Relations read `source -> target`,
|
||||
annotated with the edge label when present. A handful of common shapes are
|
||||
named (data store, actor, decision, queue, cloud, and AWS/Azure/GCP/Kubernetes
|
||||
vendor icons). UserObject/object wrappers are unwrapped; compressed pages are
|
||||
reported but cannot be described (this skill always writes uncompressed XML).
|
||||
|
||||
Usage: python3 explain.py <file.drawio> [-o out.md]
|
||||
"""
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
# style fragment -> human noun. First match wins; order matters (specific first).
|
||||
SHAPE_TYPES = [
|
||||
("mxgraph.aws", "AWS"), ("img/lib/azure", "Azure"), ("mxgraph.gcp", "GCP"),
|
||||
("mxgraph.kubernetes", "Kubernetes"), ("umlActor", "actor"), ("shape=actor", "actor"),
|
||||
("shape=cylinder", "data store"), ("shape=datastore", "data store"),
|
||||
("shape=cloud", "cloud"), ("rhombus", "decision"), ("mscae", "Azure"),
|
||||
("shape=process", "process"), ("shape=hexagon", "queue"),
|
||||
]
|
||||
|
||||
|
||||
def clean(text):
|
||||
"""Strip HTML tags/entities draw.io stores in labels; collapse whitespace."""
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"<br\s*/?>", " ", text, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
return re.sub(r"\s+", " ", html.unescape(text)).strip()
|
||||
|
||||
|
||||
def shape_of(style):
|
||||
for frag, noun in SHAPE_TYPES:
|
||||
if frag in (style or ""):
|
||||
return noun
|
||||
return None
|
||||
|
||||
|
||||
def cells_of(page):
|
||||
"""[(cell, id, label)] for a page, unwrapping UserObject/object wrappers."""
|
||||
model = page.find("mxGraphModel")
|
||||
root = model.find("root") if model is not None else None
|
||||
if root is None:
|
||||
return None # compressed / empty page
|
||||
out = []
|
||||
for child in root:
|
||||
if child.tag == "mxCell":
|
||||
out.append((child, child.get("id"), clean(child.get("value"))))
|
||||
elif child.tag in ("UserObject", "object"):
|
||||
inner = child.find("mxCell")
|
||||
if inner is not None:
|
||||
inner.set("id", child.get("id", ""))
|
||||
out.append((inner, child.get("id"),
|
||||
clean(child.get("label") or child.get("value"))))
|
||||
return out
|
||||
|
||||
|
||||
def describe_page(page):
|
||||
"""Markdown body lines for one <diagram> page (no page heading)."""
|
||||
cells = cells_of(page)
|
||||
if cells is None:
|
||||
return ["_(compressed page — cannot describe)_"]
|
||||
label = {cid: lbl for _, cid, lbl in cells}
|
||||
style = {cid: (c.get("style") or "") for c, cid, _ in cells}
|
||||
parents = {c.get("parent") for c, _, _ in cells if c.get("parent")}
|
||||
|
||||
vertices = [(c, cid) for c, cid, _ in cells if c.get("vertex") == "1"]
|
||||
containers = {cid for c, cid in vertices if cid in parents} # holds other cells
|
||||
leaves = [(c, cid) for c, cid in vertices
|
||||
if cid not in containers and "edgeLabel" not in style.get(cid, "")]
|
||||
|
||||
# Group leaves by their container's label (else "Ungrouped").
|
||||
groups, order = {}, []
|
||||
for c, cid in leaves:
|
||||
parent = c.get("parent")
|
||||
gname = label.get(parent) or "" if parent in containers else ""
|
||||
gname = gname or "Ungrouped"
|
||||
if gname not in groups:
|
||||
groups[gname] = []
|
||||
order.append(gname)
|
||||
typ = shape_of(style.get(cid, ""))
|
||||
name = label.get(cid) or f"(unlabeled {cid})"
|
||||
groups[gname].append(f"{name}" + (f" _{typ}_" if typ else ""))
|
||||
|
||||
lines = [f"### Components ({len(leaves)})", ""]
|
||||
single = len(order) == 1 and order[0] == "Ungrouped"
|
||||
for gname in order:
|
||||
if not single:
|
||||
lines.append(f"- **{gname}**")
|
||||
lines += [f" - {item}" for item in groups[gname]]
|
||||
else:
|
||||
lines += [f"- {item}" for item in groups[gname]]
|
||||
lines.append("")
|
||||
|
||||
edges = [c for c, _, _ in cells if c.get("edge") == "1"]
|
||||
rels = []
|
||||
for e in edges:
|
||||
s, t = label.get(e.get("source")), label.get(e.get("target"))
|
||||
if not s or not t: # dangling endpoint — skip
|
||||
continue
|
||||
verb = clean(e.get("value"))
|
||||
rels.append(f"- {s} —{verb}→ {t}" if verb else f"- {s} → {t}")
|
||||
lines.append(f"### Relations ({len(rels)})")
|
||||
lines.append("")
|
||||
lines += rels or ["_(none)_"]
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Describe a .drawio diagram as Markdown.")
|
||||
ap.add_argument("file")
|
||||
ap.add_argument("-o", "--output", help="output Markdown path (default: stdout)")
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
tree = ET.parse(args.file)
|
||||
except (ET.ParseError, OSError) as exc:
|
||||
sys.exit(f"error: cannot parse {args.file}: {exc}")
|
||||
pages = tree.getroot().findall("diagram") or [tree.getroot()]
|
||||
|
||||
title = args.file.rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
lines = [f"# {title}", ""]
|
||||
for i, page in enumerate(pages, 1):
|
||||
name = page.get("name")
|
||||
if len(pages) > 1:
|
||||
lines.append(f"## Page {i}: {name}" if name else f"## Page {i}")
|
||||
lines.append("")
|
||||
lines += describe_page(page)
|
||||
|
||||
text = "\n".join(lines).rstrip() + "\n"
|
||||
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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
144
skills/drawio-skill/scripts/goimports.py
Normal file
144
skills/drawio-skill/scripts/goimports.py
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a Go module's package-import graph as autolayout graph JSON.
|
||||
|
||||
The Go counterpart to pyimports.py / jsimports.py. Reads the module path from
|
||||
go.mod, walks the module, treats each directory of .go files as one package
|
||||
(node = its import path), and records the intra-module package imports. Stdlib
|
||||
and third-party imports are ignored. Transitive reduction is on by default so
|
||||
the diagram stays readable.
|
||||
|
||||
python3 goimports.py ./mymodule -o graph.json
|
||||
python3 autolayout.py graph.json -o diagram.drawio
|
||||
|
||||
Parsing is regex-based over `import` statements (single and block form),
|
||||
which is enough for a structural package graph. *_test.go files and vendor/
|
||||
are skipped.
|
||||
|
||||
Usage: python3 goimports.py <module_dir> [-o graph.json] [--direction TB|LR]
|
||||
[--group] [--no-reduce]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
MODULE = re.compile(r"^module\s+(\S+)", re.MULTILINE)
|
||||
BLOCK = re.compile(r"import\s*\((.*?)\)", re.DOTALL)
|
||||
SINGLE = re.compile(r'import\s+(?:[\w.]+\s+|_\s+)?"([^"]+)"')
|
||||
QUOTED = re.compile(r'"([^"]+)"')
|
||||
|
||||
|
||||
def module_path(root):
|
||||
"""Read the `module` path from go.mod at the module root, or None."""
|
||||
gomod = os.path.join(root, "go.mod")
|
||||
if not os.path.exists(gomod):
|
||||
return None
|
||||
with open(gomod, encoding="utf-8", errors="ignore") as f:
|
||||
m = MODULE.search(f.read())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def discover(root, modpath):
|
||||
"""Map package import path -> list of .go files (one entry per directory)."""
|
||||
root = os.path.abspath(root)
|
||||
pkgs = {}
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs
|
||||
if d not in ("vendor", "testdata") and not d.startswith(".")]
|
||||
gofiles = [os.path.join(dirpath, f) for f in files
|
||||
if f.endswith(".go") and not f.endswith("_test.go")]
|
||||
if not gofiles:
|
||||
continue
|
||||
rel = os.path.relpath(dirpath, root).replace(os.sep, "/")
|
||||
ip = modpath if rel == "." else f"{modpath}/{rel}"
|
||||
pkgs[ip] = gofiles
|
||||
return pkgs
|
||||
|
||||
|
||||
def imports_of(files, modpath, pkgs):
|
||||
"""Intra-module package import paths referenced by a package's files."""
|
||||
found = set()
|
||||
for path in files:
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
src = f.read()
|
||||
except OSError:
|
||||
continue
|
||||
specs = []
|
||||
for block in BLOCK.findall(src):
|
||||
specs += QUOTED.findall(block)
|
||||
specs += SINGLE.findall(src)
|
||||
for spec in specs:
|
||||
if (spec == modpath or spec.startswith(modpath + "/")) and spec in pkgs:
|
||||
found.add(spec)
|
||||
return found
|
||||
|
||||
|
||||
def transitive_reduce(nodes, edges):
|
||||
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
||||
idx = {n: i for i, n in enumerate(nodes)}
|
||||
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
||||
try:
|
||||
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
||||
text=True, check=True).stdout
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
||||
return edges
|
||||
rev = {i: n for n, i in idx.items()}
|
||||
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Go import graph -> autolayout graph JSON.")
|
||||
ap.add_argument("module", help="module directory (contains go.mod)")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="group nodes into containers by top-level package dir")
|
||||
ap.add_argument("--no-reduce", action="store_true",
|
||||
help="keep every edge (skip transitive reduction)")
|
||||
args = ap.parse_args()
|
||||
|
||||
modpath = module_path(args.module)
|
||||
if not modpath:
|
||||
sys.exit(f"error: no go.mod with a module path found in {args.module}")
|
||||
pkgs = discover(args.module, modpath)
|
||||
if not pkgs:
|
||||
sys.exit(f"error: no Go packages found under {args.module}")
|
||||
edges = sorted({(ip, t) for ip, files in pkgs.items()
|
||||
for t in imports_of(files, modpath, pkgs) if t != ip})
|
||||
raw = len(edges)
|
||||
if not args.no_reduce:
|
||||
edges = transitive_reduce(list(pkgs), edges)
|
||||
# Drop the module prefix from labels for readability; ids stay full.
|
||||
strip = modpath + "/"
|
||||
label = lambda ip: ip[len(strip):] if ip.startswith(strip) else os.path.basename(ip)
|
||||
|
||||
def node(ip):
|
||||
d = {"id": ip, "label": label(ip)}
|
||||
if args.group:
|
||||
rest = label(ip).split("/")
|
||||
if len(rest) > 1: # nested under a sub-package
|
||||
d["group"] = "/".join(rest[:-1]) # full sub-package path -> nested boxes
|
||||
return d
|
||||
|
||||
graph = {
|
||||
"direction": args.direction,
|
||||
"nodes": [node(ip) for ip in pkgs],
|
||||
"edges": [{"source": s, "target": t} for s, t in 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)
|
||||
note = "" if args.no_reduce else f" (reduced from {raw})"
|
||||
sys.stderr.write(f"{len(pkgs)} packages, {len(edges)} edges{note}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
226
skills/drawio-skill/scripts/heatmap.py
Normal file
226
skills/drawio-skill/scripts/heatmap.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Colour a diagram by data — turn a .drawio into a metric heatmap.
|
||||
|
||||
Reads a .drawio and a metrics file (CSV `key,value` or JSON `{key: value}`),
|
||||
matches each metric key to a node by cell id or by its label text, and recolours
|
||||
that node along a gradient from the lowest value to the highest. Optionally
|
||||
scales node size by value (`--size`) and drops in a legend. The result is a new
|
||||
.drawio you can export like any other — an architecture diagram that now shows
|
||||
cost / latency / traffic / error-rate as a heat map.
|
||||
|
||||
python3 heatmap.py architecture.drawio --metrics latency.csv -o hot.drawio
|
||||
python3 heatmap.py architecture.drawio -m cost.json --palette cool --size
|
||||
|
||||
Metrics match on cell id first, then on the (HTML-stripped) label, case-
|
||||
insensitively. Unmatched nodes keep their original style. `--palette` picks the
|
||||
colour ramp (heat|cool|warm), `--reverse` flips it (so low = hot).
|
||||
|
||||
Usage: python3 heatmap.py <file.drawio> --metrics <file.csv|json> [-o out.drawio]
|
||||
[--palette heat|cool|warm] [--reverse] [--size] [--no-legend]
|
||||
"""
|
||||
import argparse
|
||||
import csv
|
||||
import html
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
# Sequential ramps as (low, mid, high) anchor colours; value is lerped across them.
|
||||
PALETTES = {
|
||||
"heat": ("#57bb8a", "#ffd666", "#e67c73"), # green -> yellow -> red
|
||||
"cool": ("#deebf7", "#6baed6", "#08519c"), # light -> deep blue
|
||||
"warm": ("#fff7bc", "#fec44f", "#d95f0e"), # pale -> deep amber
|
||||
}
|
||||
|
||||
|
||||
def clean(text):
|
||||
"""Strip the HTML tags/entities draw.io stores in labels; collapse whitespace."""
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r"<br\s*/?>", " ", text, flags=re.I)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
return re.sub(r"\s+", " ", html.unescape(text)).strip()
|
||||
|
||||
|
||||
def _num(x):
|
||||
try:
|
||||
return float(x)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def load_metrics(path):
|
||||
"""{key: float} from a JSON object/list or a CSV/TSV (first col key, last numeric col value)."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
if path.lower().endswith(".json"):
|
||||
data = json.loads(text)
|
||||
if isinstance(data, dict):
|
||||
return {str(k): _num(v) for k, v in data.items() if _num(v) is not None}
|
||||
out = {}
|
||||
for row in data:
|
||||
key = row.get("id") or row.get("key") or row.get("name") or row.get("label")
|
||||
val = _num(row.get("value", row.get("val", row.get("metric"))))
|
||||
if key is not None and val is not None:
|
||||
out[str(key)] = val
|
||||
return out
|
||||
delim = "\t" if text.splitlines() and "\t" in text.splitlines()[0] else ","
|
||||
out = {}
|
||||
for row in csv.reader(text.splitlines(), delimiter=delim):
|
||||
if len(row) < 2:
|
||||
continue
|
||||
v = _num(row[-1])
|
||||
if v is not None: # skip header / non-numeric rows
|
||||
out[row[0].strip()] = v
|
||||
return out
|
||||
|
||||
|
||||
def _rgb(c):
|
||||
c = c.lstrip("#")
|
||||
return tuple(int(c[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def ramp(anchors, t):
|
||||
"""t in [0,1] -> #rrggbb across a 3-stop (low, mid, high) ramp."""
|
||||
lo, mid, hi = (_rgb(c) for c in anchors)
|
||||
a, b, u = (lo, mid, t * 2) if t < 0.5 else (mid, hi, (t - 0.5) * 2)
|
||||
return "#%02x%02x%02x" % tuple(round(a[i] + (b[i] - a[i]) * u) for i in range(3))
|
||||
|
||||
|
||||
def darker(hexcolor, f=0.6):
|
||||
return "#%02x%02x%02x" % tuple(round(c * f) for c in _rgb(hexcolor))
|
||||
|
||||
|
||||
def set_style(style, fill, stroke):
|
||||
"""Replace/insert fillColor & strokeColor in a draw.io style string."""
|
||||
s = re.sub(r"(fillColor|strokeColor)=[^;]*;?", "", style or "").strip("; ")
|
||||
return (s + ";" if s else "") + f"fillColor={fill};strokeColor={stroke};"
|
||||
|
||||
|
||||
def vertices(root):
|
||||
"""Yield (cell, id, label) for every vertex, unwrapping UserObject/object wrappers."""
|
||||
for child in root:
|
||||
if child.tag == "mxCell" and child.get("vertex") == "1":
|
||||
yield child, child.get("id"), clean(child.get("value"))
|
||||
elif child.tag in ("UserObject", "object"):
|
||||
inner = child.find("mxCell")
|
||||
if inner is not None and inner.get("vertex") == "1":
|
||||
yield inner, child.get("id"), clean(child.get("label") or child.get("value"))
|
||||
|
||||
|
||||
def scale_geom(cell, factor):
|
||||
"""Grow/shrink a cell about its centre by `factor`."""
|
||||
g = cell.find("mxGeometry")
|
||||
if g is None:
|
||||
return
|
||||
w, h = _num(g.get("width")), _num(g.get("height"))
|
||||
if w is None or h is None:
|
||||
return
|
||||
nw, nh = w * factor, h * factor
|
||||
g.set("width", "%g" % nw)
|
||||
g.set("height", "%g" % nh)
|
||||
for attr, delta in (("x", (nw - w) / 2), ("y", (nh - h) / 2)):
|
||||
v = _num(g.get(attr))
|
||||
if v is not None:
|
||||
g.set(attr, "%g" % (v - delta))
|
||||
|
||||
|
||||
def color_for(val, lo, hi, anchors, reverse):
|
||||
t = (val - lo) / (hi - lo) if hi > lo else 0.5
|
||||
fill = ramp(anchors, 1 - t if reverse else t)
|
||||
return fill, darker(fill), t
|
||||
|
||||
|
||||
def content_bounds(root):
|
||||
"""(min_x, min_y) of the page's top-level cells, so the legend sits clear of them."""
|
||||
xs, ys = [], []
|
||||
for child in root:
|
||||
cell = child if child.tag == "mxCell" else child.find("mxCell")
|
||||
if cell is None or cell.get("parent") != "1": # skip nested (relative-coord) cells
|
||||
continue
|
||||
g = cell.find("mxGeometry")
|
||||
x, y = (_num(g.get("x")), _num(g.get("y"))) if g is not None else (None, None)
|
||||
if x is not None and y is not None:
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
return (min(xs) if xs else 20, min(ys) if ys else 20)
|
||||
|
||||
|
||||
def add_legend(root, anchors, lo, hi, reverse):
|
||||
"""Drop a small min/mid/max swatch legend just left of the page's content."""
|
||||
w, h = 90, 26
|
||||
cx, cy = content_bounds(root)
|
||||
x0, y0 = cx - w - 40, cy # to the left of the diagram
|
||||
|
||||
def cell(cid, value, style, gy, gh):
|
||||
c = ET.SubElement(root, "mxCell", {"id": cid, "value": value, "style": style,
|
||||
"vertex": "1", "parent": "1"})
|
||||
ET.SubElement(c, "mxGeometry", {"x": "%g" % x0, "y": "%g" % gy,
|
||||
"width": "%g" % w, "height": "%g" % gh, "as": "geometry"})
|
||||
|
||||
cell("hm-title", "Heatmap",
|
||||
"text;html=1;fontStyle=1;align=left;verticalAlign=middle;", y0, 20)
|
||||
for i, val in enumerate((hi, (lo + hi) / 2, lo)):
|
||||
fill, stroke, _ = color_for(val, lo, hi, anchors, reverse)
|
||||
cell("hm-%d" % i, "%g" % val,
|
||||
set_style("rounded=0;whiteSpace=wrap;html=1;", fill, stroke),
|
||||
y0 + 24 + i * (h + 4), h)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Recolour a .drawio into a metric heatmap.")
|
||||
ap.add_argument("file", help="input .drawio (uncompressed)")
|
||||
ap.add_argument("-m", "--metrics", required=True, help="metrics .csv or .json")
|
||||
ap.add_argument("-o", "--output", help="output .drawio (default: <name>-heat.drawio)")
|
||||
ap.add_argument("--palette", default="heat", choices=list(PALETTES))
|
||||
ap.add_argument("--reverse", action="store_true", help="flip the ramp (low = hot)")
|
||||
ap.add_argument("--size", action="store_true", help="also scale node size by value")
|
||||
ap.add_argument("--no-legend", dest="legend", action="store_false", help="skip the legend")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(args.file):
|
||||
sys.exit(f"error: {args.file} not found")
|
||||
if not os.path.isfile(args.metrics):
|
||||
sys.exit(f"error: {args.metrics} not found")
|
||||
metrics = load_metrics(args.metrics)
|
||||
if not metrics:
|
||||
sys.exit(f"error: no numeric metrics parsed from {args.metrics}")
|
||||
low_map = {k.lower(): v for k, v in metrics.items()}
|
||||
anchors = PALETTES[args.palette]
|
||||
lo, hi = min(metrics.values()), max(metrics.values())
|
||||
|
||||
tree = ET.parse(args.file)
|
||||
matched, first_root = 0, None
|
||||
for diagram in tree.getroot().iter("diagram"):
|
||||
model = diagram.find("mxGraphModel")
|
||||
root = model.find("root") if model is not None else None
|
||||
if root is None: # compressed / empty page
|
||||
continue
|
||||
if first_root is None:
|
||||
first_root = root
|
||||
for cell, cid, label in list(vertices(root)):
|
||||
val = metrics.get(cid)
|
||||
if val is None and label:
|
||||
val = metrics.get(label, low_map.get(label.lower()))
|
||||
if val is None:
|
||||
continue
|
||||
fill, stroke, t = color_for(val, lo, hi, anchors, args.reverse)
|
||||
cell.set("style", set_style(cell.get("style"), fill, stroke))
|
||||
if args.size and hi > lo:
|
||||
scale_geom(cell, 0.7 + 0.8 * t)
|
||||
matched += 1
|
||||
|
||||
if args.legend and first_root is not None and matched:
|
||||
add_legend(first_root, anchors, lo, hi, args.reverse)
|
||||
|
||||
out = args.output or os.path.splitext(args.file)[0] + "-heat.drawio"
|
||||
tree.write(out, encoding="utf-8", xml_declaration=False)
|
||||
if matched == 0:
|
||||
sys.stderr.write("warning: no nodes matched any metric key (check ids/labels)\n")
|
||||
sys.stderr.write(f"wrote {out} ({matched}/{len(metrics)} metrics matched)\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
164
skills/drawio-skill/scripts/jsimports.py
Normal file
164
skills/drawio-skill/scripts/jsimports.py
Normal file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a JS/TS project's module-import graph as autolayout graph JSON.
|
||||
|
||||
The JavaScript/TypeScript counterpart to pyimports.py: walks a source
|
||||
directory, scans each module for static and dynamic import specifiers
|
||||
(`import`/`export ... from`, `require()`, `import()`), keeps only the
|
||||
intra-project edges (relative specifiers that resolve to a file under the
|
||||
root), and applies transitive reduction so the diagram stays readable.
|
||||
|
||||
python3 jsimports.py src -o graph.json
|
||||
python3 autolayout.py graph.json -o diagram.drawio
|
||||
|
||||
Resolution is path-based: relative specifiers are resolved against the
|
||||
importing file's directory, trying the .ts/.tsx/.js/.jsx/.mjs/.cjs extensions
|
||||
and directory index files. Bare specifiers (node_modules packages such as
|
||||
"react") are ignored. Scanning is regex-based rather than a full parser, so a
|
||||
specifier inside a comment or string literal is counted in rare cases.
|
||||
|
||||
Usage: python3 jsimports.py <src_dir> [-o graph.json] [--direction TB|LR]
|
||||
[--group] [--no-reduce]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
EXTS = (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs")
|
||||
SPEC = re.compile(
|
||||
r"(?:import|export)\b[^'\";]*?\bfrom\s*['\"]([^'\"]+)['\"]" # import/export ... from "x"
|
||||
r"|import\s*['\"]([^'\"]+)['\"]" # import "x" (side effect)
|
||||
r"|require\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # require("x")
|
||||
r"|import\s*\(\s*['\"]([^'\"]+)['\"]\s*\)" # import("x") dynamic
|
||||
)
|
||||
|
||||
|
||||
def modid(path, root):
|
||||
"""Module id = path relative to root, extension stripped, posix separators."""
|
||||
rel = os.path.relpath(path, root)
|
||||
for ext in EXTS:
|
||||
if rel.endswith(ext):
|
||||
rel = rel[: -len(ext)]
|
||||
break
|
||||
return rel.replace(os.sep, "/")
|
||||
|
||||
|
||||
def discover(root):
|
||||
"""Map module id -> absolute file path for every source file under root."""
|
||||
root = os.path.abspath(root)
|
||||
modules = {}
|
||||
for dirpath, dirs, files in os.walk(root):
|
||||
dirs[:] = [d for d in dirs if d != "node_modules" and not d.startswith(".")]
|
||||
for fn in files:
|
||||
if fn.endswith(EXTS) and not fn.endswith(".d.ts"):
|
||||
full = os.path.join(dirpath, fn)
|
||||
modules[modid(full, root)] = full
|
||||
return modules, root
|
||||
|
||||
|
||||
def resolve(spec, importer, root, modules):
|
||||
"""Resolve a relative specifier to a known module id, or None (external)."""
|
||||
if not spec.startswith("."):
|
||||
return None
|
||||
base = os.path.normpath(os.path.join(os.path.dirname(importer), spec))
|
||||
candidates = ([base + e for e in EXTS]
|
||||
+ [os.path.join(base, "index" + e) for e in EXTS]
|
||||
+ [base])
|
||||
for cand in candidates:
|
||||
mid = modid(cand, root)
|
||||
if mid in modules and modules[mid] != importer:
|
||||
return mid
|
||||
return None
|
||||
|
||||
|
||||
def edges_of(mid, path, root, modules):
|
||||
"""Intra-project modules imported by module `mid`."""
|
||||
found = set()
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
src = f.read()
|
||||
except OSError:
|
||||
return found
|
||||
for m in SPEC.finditer(src):
|
||||
spec = m.group(1) or m.group(2) or m.group(3) or m.group(4)
|
||||
target = resolve(spec, path, root, modules)
|
||||
if target and target != mid:
|
||||
found.add(target)
|
||||
return found
|
||||
|
||||
|
||||
def transitive_reduce(nodes, edges):
|
||||
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
||||
idx = {n: i for i, n in enumerate(nodes)}
|
||||
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
||||
try:
|
||||
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
||||
text=True, check=True).stdout
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
||||
return edges
|
||||
rev = {i: n for n, i in idx.items()}
|
||||
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
||||
|
||||
|
||||
def common_dir(ids):
|
||||
"""Longest shared leading path segment across module ids (e.g. 'src/')."""
|
||||
common = []
|
||||
for parts in zip(*[m.split("/") for m in ids]):
|
||||
if len(set(parts)) == 1:
|
||||
common.append(parts[0])
|
||||
else:
|
||||
break
|
||||
return "/".join(common) + "/" if common else ""
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="JS/TS import graph -> autolayout graph JSON.")
|
||||
ap.add_argument("src", help="source directory")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="group nodes into containers by top-level directory")
|
||||
ap.add_argument("--no-reduce", action="store_true",
|
||||
help="keep every edge (skip transitive reduction)")
|
||||
args = ap.parse_args()
|
||||
|
||||
modules, root = discover(args.src)
|
||||
if not modules:
|
||||
sys.exit(f"error: no JS/TS modules found under {args.src}")
|
||||
edges = sorted({(m, t) for m, p in modules.items()
|
||||
for t in edges_of(m, p, root, modules)})
|
||||
raw = len(edges)
|
||||
if not args.no_reduce:
|
||||
edges = transitive_reduce(list(modules), edges)
|
||||
strip = common_dir(list(modules))
|
||||
label = lambda m: (m[len(strip):] if strip and m.startswith(strip) else m) or m
|
||||
|
||||
def node(m):
|
||||
d = {"id": m, "label": label(m)}
|
||||
if args.group:
|
||||
rest = label(m).split("/")
|
||||
if len(rest) > 1: # has a sub-directory
|
||||
d["group"] = "/".join(rest[:-1]) # full directory path -> nested boxes
|
||||
return d
|
||||
|
||||
graph = {
|
||||
"direction": args.direction,
|
||||
"nodes": [node(m) for m in modules],
|
||||
"edges": [{"source": s, "target": t} for s, t in 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)
|
||||
note = "" if args.no_reduce else f" (reduced from {raw})"
|
||||
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
242
skills/drawio-skill/scripts/k8simports.py
Normal file
242
skills/drawio-skill/scripts/k8simports.py
Normal file
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a Kubernetes manifest set's object graph as autolayout graph JSON.
|
||||
|
||||
Reads manifest files (a directory, or explicit .yaml/.yml/.json paths), maps
|
||||
each object kind to its official draw.io Kubernetes icon (mxgraph.kubernetes
|
||||
set, resolved from the bundled shape index), and derives the reference edges
|
||||
that make an architecture readable:
|
||||
|
||||
Ingress -> Service (spec backend service names)
|
||||
Service -> workload (selector labels match the pod template)
|
||||
workload -> ConfigMap/Secret (env / envFrom / volumes)
|
||||
workload -> PVC (persistentVolumeClaim volumes)
|
||||
HPA -> target workload (scaleTargetRef)
|
||||
|
||||
Workloads: Deployment, StatefulSet, DaemonSet, ReplicaSet, Job, CronJob, Pod.
|
||||
Edges only land on objects that are themselves in the manifest set. The output
|
||||
feeds autolayout.py:
|
||||
|
||||
python3 k8simports.py ./manifests -o graph.json
|
||||
python3 autolayout.py graph.json -o cluster.drawio
|
||||
|
||||
JSON input (single object, or a `kind: List` as produced by
|
||||
`kubectl get ... -o json`) parses with the stdlib alone; .yaml/.yml files
|
||||
need PyYAML (`pip install pyyaml`). Pass `-` to read a live cluster snapshot
|
||||
from stdin: `kubectl get all,ing,cm,secret,pvc -o json | k8simports.py -`.
|
||||
|
||||
Usage: python3 k8simports.py <dir-or-manifest...|-> [-o graph.json]
|
||||
[--direction TB|LR] [--group] [--no-icons]
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Object kind -> prIcon name inside the mxgraph.kubernetes.icon2 shape set.
|
||||
KIND_ICON = {
|
||||
"ClusterRole": "c-role", "ClusterRoleBinding": "crb", "ConfigMap": "cm",
|
||||
"CronJob": "cronjob", "CustomResourceDefinition": "crd", "DaemonSet": "ds",
|
||||
"Deployment": "deploy", "Endpoints": "ep", "HorizontalPodAutoscaler": "hpa",
|
||||
"Ingress": "ing", "Job": "job", "Namespace": "ns", "NetworkPolicy": "netpol",
|
||||
"Node": "node", "PersistentVolume": "pv", "PersistentVolumeClaim": "pvc",
|
||||
"Pod": "pod", "ReplicaSet": "rs", "Role": "role", "RoleBinding": "rb",
|
||||
"Secret": "secret", "Service": "svc", "ServiceAccount": "sa",
|
||||
"StatefulSet": "sts", "StorageClass": "sc",
|
||||
}
|
||||
WORKLOADS = {"Deployment", "StatefulSet", "DaemonSet", "ReplicaSet", "Job", "CronJob", "Pod"}
|
||||
_INDEX = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data",
|
||||
"shape-index.json.gz")
|
||||
|
||||
|
||||
def icon_styles():
|
||||
"""prIcon name -> (style, w, h) from the bundled official shape index."""
|
||||
with gzip.open(_INDEX, "rt", encoding="utf-8") as f:
|
||||
shapes = json.load(f)
|
||||
out = {}
|
||||
for s in shapes:
|
||||
st = s["style"]
|
||||
m = re.search(r"prIcon=([\w-]+)", st)
|
||||
# Skip the kubernetesLabel=1 variants (they paint the kind name into
|
||||
# the icon; our node label already names the object below it).
|
||||
if "mxgraph.kubernetes.icon2" in st and m and "kubernetesLabel" not in st:
|
||||
out.setdefault(m.group(1), (st, s["w"], s["h"]))
|
||||
return out
|
||||
|
||||
|
||||
def load_manifests(paths):
|
||||
"""Parse all given files into a list of k8s objects."""
|
||||
files = []
|
||||
for p in paths:
|
||||
if os.path.isdir(p):
|
||||
for ext in ("yaml", "yml", "json"):
|
||||
files.extend(glob.glob(os.path.join(p, "**", f"*.{ext}"), recursive=True))
|
||||
else:
|
||||
files.append(p)
|
||||
objs = []
|
||||
for path in sorted(set(files)):
|
||||
if path == "-": # `kubectl get ... -o json | k8simports.py -`
|
||||
docs = [json.loads(sys.stdin.read())]
|
||||
elif path.endswith(".json"):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
docs = [json.loads(f.read())]
|
||||
else:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit(f"error: {path} is YAML but PyYAML is not installed "
|
||||
"(pip install pyyaml) — or feed JSON from `kubectl get ... -o json`")
|
||||
docs = list(yaml.safe_load_all(text))
|
||||
for doc in docs:
|
||||
if not isinstance(doc, dict):
|
||||
continue
|
||||
if doc.get("kind") == "List":
|
||||
objs.extend(i for i in doc.get("items", []) if isinstance(i, dict))
|
||||
else:
|
||||
objs.append(doc)
|
||||
return [o for o in objs if o.get("kind") and o.get("metadata", {}).get("name")]
|
||||
|
||||
|
||||
def pod_spec(obj):
|
||||
spec = obj.get("spec") or {}
|
||||
if obj["kind"] == "Pod":
|
||||
return spec
|
||||
if obj["kind"] == "CronJob":
|
||||
spec = (spec.get("jobTemplate") or {}).get("spec") or {}
|
||||
return (spec.get("template") or {}).get("spec") or {}
|
||||
|
||||
|
||||
def pod_labels(obj):
|
||||
if obj["kind"] == "Pod":
|
||||
return (obj.get("metadata") or {}).get("labels") or {}
|
||||
spec = obj.get("spec") or {}
|
||||
if obj["kind"] == "CronJob":
|
||||
spec = (spec.get("jobTemplate") or {}).get("spec") or {}
|
||||
return ((spec.get("template") or {}).get("metadata") or {}).get("labels") or {}
|
||||
|
||||
|
||||
def mounted_refs(pspec):
|
||||
"""(kind, name) pairs a pod spec references via env/envFrom/volumes."""
|
||||
refs = set()
|
||||
for c in (pspec.get("containers") or []) + (pspec.get("initContainers") or []):
|
||||
for e in c.get("env") or []:
|
||||
vf = e.get("valueFrom") or {}
|
||||
for key, kind in (("configMapKeyRef", "ConfigMap"), ("secretKeyRef", "Secret")):
|
||||
if vf.get(key, {}).get("name"):
|
||||
refs.add((kind, vf[key]["name"]))
|
||||
for e in c.get("envFrom") or []:
|
||||
for key, kind in (("configMapRef", "ConfigMap"), ("secretRef", "Secret")):
|
||||
if e.get(key, {}).get("name"):
|
||||
refs.add((kind, e[key]["name"]))
|
||||
for v in pspec.get("volumes") or []:
|
||||
if v.get("configMap", {}).get("name"):
|
||||
refs.add(("ConfigMap", v["configMap"]["name"]))
|
||||
if v.get("secret", {}).get("secretName"):
|
||||
refs.add(("Secret", v["secret"]["secretName"]))
|
||||
if v.get("persistentVolumeClaim", {}).get("claimName"):
|
||||
refs.add(("PersistentVolumeClaim", v["persistentVolumeClaim"]["claimName"]))
|
||||
return refs
|
||||
|
||||
|
||||
def ingress_backends(obj):
|
||||
"""Service names referenced by an Ingress (networking.k8s.io/v1 + legacy)."""
|
||||
names, stack = set(), [obj.get("spec") or {}]
|
||||
while stack:
|
||||
cur = stack.pop()
|
||||
if isinstance(cur, dict):
|
||||
svc = cur.get("service")
|
||||
if isinstance(svc, dict) and svc.get("name"):
|
||||
names.add(svc["name"])
|
||||
if isinstance(cur.get("serviceName"), str):
|
||||
names.add(cur["serviceName"])
|
||||
stack.extend(cur.values())
|
||||
elif isinstance(cur, list):
|
||||
stack.extend(cur)
|
||||
return names
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Kubernetes manifests -> autolayout graph JSON.")
|
||||
ap.add_argument("paths", nargs="+", help="manifest files and/or directories")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true", help="group objects by namespace")
|
||||
ap.add_argument("--no-icons", action="store_true",
|
||||
help="plain boxes instead of official Kubernetes icons")
|
||||
args = ap.parse_args()
|
||||
|
||||
objs = load_manifests(args.paths)
|
||||
if not objs:
|
||||
sys.exit("error: no Kubernetes objects found (need kind + metadata.name)")
|
||||
|
||||
def key(obj):
|
||||
meta = obj.get("metadata") or {}
|
||||
return (meta.get("namespace") or "", obj["kind"], meta["name"])
|
||||
|
||||
by_key = {key(o): o for o in objs}
|
||||
icons = {} if args.no_icons else icon_styles()
|
||||
|
||||
edges = set()
|
||||
for k, obj in by_key.items():
|
||||
ns, kind, _ = k
|
||||
|
||||
def link(tkind, tname):
|
||||
if (ns, tkind, tname) in by_key and (ns, tkind, tname) != k:
|
||||
edges.add((k, (ns, tkind, tname)))
|
||||
|
||||
if kind == "Ingress":
|
||||
for svc in ingress_backends(obj):
|
||||
link("Service", svc)
|
||||
elif kind == "Service":
|
||||
sel = (obj.get("spec") or {}).get("selector") or {}
|
||||
if sel:
|
||||
for tk, target in by_key.items():
|
||||
if (tk[0] == ns and tk[1] in WORKLOADS
|
||||
and sel.items() <= pod_labels(target).items()):
|
||||
edges.add((k, tk))
|
||||
elif kind in WORKLOADS:
|
||||
for tkind, tname in mounted_refs(pod_spec(obj)):
|
||||
link(tkind, tname)
|
||||
elif kind == "HorizontalPodAutoscaler":
|
||||
ref = (obj.get("spec") or {}).get("scaleTargetRef") or {}
|
||||
if ref.get("kind") and ref.get("name"):
|
||||
link(ref["kind"], ref["name"])
|
||||
|
||||
def nid(k):
|
||||
ns, kind, name = k
|
||||
return f"{ns + '/' if ns else ''}{kind}/{name}"
|
||||
|
||||
nodes = []
|
||||
for k in by_key:
|
||||
ns, kind, name = k
|
||||
node = {"id": nid(k), "label": name}
|
||||
icon = icons.get(KIND_ICON.get(kind, ""))
|
||||
if icon:
|
||||
node.update(style=icon[0], width=icon[1], height=icon[2])
|
||||
else:
|
||||
node["label"] = f"{name}\n{kind}"
|
||||
if args.group and ns:
|
||||
node["group"] = ns
|
||||
nodes.append(node)
|
||||
|
||||
graph = {"direction": args.direction, "nodes": nodes,
|
||||
"edges": [{"source": nid(s), "target": nid(t)} for s, t in sorted(edges)]}
|
||||
if icons:
|
||||
# Icon labels render below the shape — reserve extra layout spacing.
|
||||
graph.update(ranksep=0.7, nodesep=0.6)
|
||||
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"{len(nodes)} objects, {len(edges)} edges\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
165
skills/drawio-skill/scripts/openapiimports.py
Normal file
165
skills/drawio-skill/scripts/openapiimports.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn an OpenAPI / Swagger spec into an API diagram as autolayout graph JSON.
|
||||
|
||||
Reads an OpenAPI 3 or Swagger 2 spec (JSON, or YAML with PyYAML) and emits one
|
||||
node per operation — coloured by HTTP method — plus one node per component
|
||||
schema, with edges from each operation to the schemas it references (request /
|
||||
response bodies) and between schemas that nest one another. Feeds autolayout.py:
|
||||
|
||||
python3 openapiimports.py openapi.yaml -o graph.json
|
||||
python3 autolayout.py graph.json -o api.drawio
|
||||
|
||||
Operations are grouped by their first `tag` (falling back to the first path
|
||||
segment) with `--group`; `--no-schemas` drops the data-model nodes to show just
|
||||
the endpoint surface. `$ref`s are resolved to their final name; only schemas
|
||||
defined under components/definitions become nodes, so external refs are ignored.
|
||||
|
||||
Usage: python3 openapiimports.py <spec.json|spec.yaml> [-o graph.json]
|
||||
[--direction TB|LR] [--group] [--no-schemas]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
METHODS = ("get", "post", "put", "patch", "delete", "head", "options", "trace")
|
||||
# HTTP method -> (fill, stroke). GET reads, POST creates, PUT/PATCH update, DELETE removes.
|
||||
METHOD_STYLE = {
|
||||
"get": ("#dae8fc", "#6c8ebf"), "post": ("#d5e8d4", "#82b366"),
|
||||
"put": ("#ffe6cc", "#d79b00"), "patch": ("#ffe6cc", "#d79b00"),
|
||||
"delete": ("#f8cecc", "#b85450"),
|
||||
}
|
||||
OTHER_STYLE = ("#f5f5f5", "#666666")
|
||||
SCHEMA_STYLE = "rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;"
|
||||
OP_EDGE = "edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;fontSize=10;endArrow=open;"
|
||||
REF_EDGE = ("edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;fontSize=10;"
|
||||
"dashed=1;endArrow=open;strokeColor=#9673a6;")
|
||||
|
||||
|
||||
def load_spec(path):
|
||||
"""Parse the spec: JSON directly, YAML (or ambiguous) via PyYAML if present."""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
if path.lower().endswith((".yaml", ".yml")):
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("error: spec is YAML but PyYAML is not installed "
|
||||
"(pip install pyyaml) — or convert the spec to JSON")
|
||||
return yaml.safe_load(text)
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("error: could not parse spec as JSON (install PyYAML to read YAML)")
|
||||
return yaml.safe_load(text)
|
||||
|
||||
|
||||
def find_refs(obj):
|
||||
"""Yield the final name of every $ref anywhere inside a spec fragment."""
|
||||
if isinstance(obj, dict):
|
||||
for k, v in obj.items():
|
||||
if k == "$ref" and isinstance(v, str):
|
||||
yield v.split("/")[-1]
|
||||
else:
|
||||
yield from find_refs(v)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
yield from find_refs(item)
|
||||
|
||||
|
||||
def method_style(method):
|
||||
fill, stroke = METHOD_STYLE.get(method, OTHER_STYLE)
|
||||
return ("rounded=1;whiteSpace=wrap;html=1;align=left;spacingLeft=6;"
|
||||
f"fillColor={fill};strokeColor={stroke};")
|
||||
|
||||
|
||||
def build(spec, group, no_schemas, direction):
|
||||
"""Spec dict -> autolayout graph JSON dict."""
|
||||
paths = spec.get("paths") or {}
|
||||
# components.schemas (OpenAPI 3) or definitions (Swagger 2)
|
||||
schemas = (spec.get("components") or {}).get("schemas") or spec.get("definitions") or {}
|
||||
want_schemas = bool(schemas) and not no_schemas
|
||||
sid = {name: f"S:{name}" for name in schemas}
|
||||
|
||||
nodes, edges, seen = [], [], set()
|
||||
|
||||
def add_edge(src, dst, style):
|
||||
if src != dst and (src, dst) not in seen:
|
||||
seen.add((src, dst))
|
||||
edges.append({"source": src, "target": dst, "style": style, "label": ""})
|
||||
|
||||
i = 0
|
||||
for path, item in paths.items():
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
for method in METHODS:
|
||||
op = item.get(method)
|
||||
if not isinstance(op, dict):
|
||||
continue
|
||||
oid = f"op{i}"
|
||||
i += 1
|
||||
summary = (op.get("summary") or op.get("operationId") or "").strip()
|
||||
head = f"{method.upper()} {path}"
|
||||
nodes.append({
|
||||
"id": oid, "label": head + (f"\n{summary}" if summary else ""),
|
||||
"style": method_style(method),
|
||||
"width": max(160, 8 * len(head) + 20), "height": 40,
|
||||
**({"group": (op.get("tags") or [path.strip('/').split('/')[0] or "root"])[0]}
|
||||
if group else {}),
|
||||
})
|
||||
if want_schemas:
|
||||
for ref in set(find_refs(op)):
|
||||
if ref in sid:
|
||||
add_edge(oid, sid[ref], OP_EDGE)
|
||||
|
||||
if want_schemas:
|
||||
for name, schema in schemas.items():
|
||||
fields = schema.get("properties") if isinstance(schema, dict) else None
|
||||
count = len(fields) if fields else 0
|
||||
nodes.append({
|
||||
"id": sid[name],
|
||||
"label": name + (f"\n({count} field{'s' if count != 1 else ''})" if count else ""),
|
||||
"style": SCHEMA_STYLE, "width": max(140, 9 * len(name) + 20), "height": 40,
|
||||
**({"group": "schemas"} if group else {}),
|
||||
})
|
||||
for ref in set(find_refs(schema)):
|
||||
if ref in sid:
|
||||
add_edge(sid[name], sid[ref], REF_EDGE)
|
||||
|
||||
return {"direction": direction, "nodes": nodes, "edges": edges}
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="OpenAPI/Swagger spec -> API diagram graph JSON.")
|
||||
ap.add_argument("spec", help="OpenAPI 3 / Swagger 2 spec (.json or .yaml)")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="LR", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true", help="group operations by tag")
|
||||
ap.add_argument("--no-schemas", action="store_true",
|
||||
help="show only endpoints, omit schema nodes and edges")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(args.spec):
|
||||
sys.exit(f"error: {args.spec} not found")
|
||||
spec = load_spec(args.spec) or {}
|
||||
if not (spec.get("paths")):
|
||||
sys.exit("error: no paths found (is this an OpenAPI/Swagger spec?)")
|
||||
|
||||
graph = build(spec, args.group, args.no_schemas, args.direction)
|
||||
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)
|
||||
ops = sum(1 for n in graph["nodes"] if n["id"].startswith("op"))
|
||||
sys.stderr.write(f"{ops} operations, {len(graph['nodes']) - ops} schemas, "
|
||||
f"{len(graph['edges'])} edges\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
191
skills/drawio-skill/scripts/prdiff.py
Normal file
191
skills/drawio-skill/scripts/prdiff.py
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render base/head/diff PNGs for every .drawio changed between two git refs.
|
||||
|
||||
For each `.drawio` that differs between `--base` and `--head`, exports the
|
||||
base and head pages as PNGs via the draw.io CLI, and — for files present on
|
||||
both sides — chains `drawiodiff.py` -> `autolayout.py` -> CLI export into a
|
||||
third colour-coded diff PNG. Added/removed files just get the one side that
|
||||
exists. Emits a Markdown report with one section per changed file (status +
|
||||
image links) and a summary count, suitable for a PR comment or CI job
|
||||
summary; pair with `.github/actions/drawio-diff/`.
|
||||
|
||||
python3 prdiff.py --base origin/main --head HEAD -o drawio-pr/report.md
|
||||
|
||||
Missing draw.io CLI degrades gracefully: the Markdown still lists every
|
||||
changed file, just without images (a review comment listing the files is
|
||||
still useful). Missing git, or `--repo` not a git repository, is fatal.
|
||||
|
||||
Usage: python3 prdiff.py --base <ref> [--head <ref>] [--repo <dir>] [--out-dir <dir>] [-o report.md]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
def changed_drawios(base, head, repo):
|
||||
"""List of (path, status) for .drawio files that differ between base and head.
|
||||
|
||||
status is "added", "removed", or "modified" (renames/copies count as
|
||||
modified, keyed on the new path). Shells to `git diff --name-status`.
|
||||
"""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["git", "-C", repo, "diff", "--name-status", f"{base}..{head}", "--", "*.drawio"],
|
||||
capture_output=True, text=True)
|
||||
except FileNotFoundError:
|
||||
sys.exit("error: not a git repo / git not found (git not on PATH)")
|
||||
if r.returncode != 0:
|
||||
sys.exit(f"error: not a git repo / git not found: {r.stderr.strip()}")
|
||||
entries = []
|
||||
for line in r.stdout.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
parts = line.split("\t")
|
||||
code, path = parts[0], parts[-1]
|
||||
status = "added" if code.startswith("A") else "removed" if code.startswith("D") else "modified"
|
||||
entries.append((path, status))
|
||||
return entries
|
||||
|
||||
|
||||
def git_show_file(repo, ref, path, dest):
|
||||
"""Write the blob at ref:path (in repo) to dest. False if it doesn't exist there."""
|
||||
r = subprocess.run(["git", "-C", repo, "show", f"{ref}:{path}"], capture_output=True)
|
||||
if r.returncode != 0:
|
||||
return False
|
||||
with open(dest, "wb") as f:
|
||||
f.write(r.stdout)
|
||||
return True
|
||||
|
||||
|
||||
def export_png(src_drawio, out_png):
|
||||
"""CLI-export page 1 of src_drawio to out_png. True on success."""
|
||||
r = subprocess.run(["drawio", "-x", "-f", "png", "--page-index", "1", "-o", out_png, src_drawio],
|
||||
capture_output=True)
|
||||
return r.returncode == 0 and os.path.exists(out_png)
|
||||
|
||||
|
||||
def export_diff_png(base_drawio, head_drawio, out_png, tmp):
|
||||
"""drawiodiff.py -> autolayout.py -> CLI export a coloured diff PNG. True on success."""
|
||||
diff_json = os.path.join(tmp, "diff.json")
|
||||
diff_drawio = os.path.join(tmp, "diff.drawio")
|
||||
r1 = subprocess.run([sys.executable, os.path.join(HERE, "drawiodiff.py"),
|
||||
base_drawio, head_drawio, "-o", diff_json], capture_output=True)
|
||||
if r1.returncode != 0 or not os.path.exists(diff_json):
|
||||
return False
|
||||
r2 = subprocess.run([sys.executable, os.path.join(HERE, "autolayout.py"),
|
||||
diff_json, "-o", diff_drawio], capture_output=True)
|
||||
if r2.returncode != 0 or not os.path.exists(diff_drawio):
|
||||
return False
|
||||
return export_png(diff_drawio, out_png)
|
||||
|
||||
|
||||
def build_entry(repo, base, head, path, status, out_dir, drawio_available):
|
||||
"""One render_markdown entry: fetch both sides, export whatever PNGs it can."""
|
||||
entry = {"path": path, "status": status}
|
||||
if not drawio_available:
|
||||
entry["skipped"] = True
|
||||
return entry
|
||||
slug = path.replace("/", "__")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
base_drawio = os.path.join(tmp, "base.drawio")
|
||||
head_drawio = os.path.join(tmp, "head.drawio")
|
||||
have_base = git_show_file(repo, base, path, base_drawio)
|
||||
have_head = git_show_file(repo, head, path, head_drawio)
|
||||
if have_base:
|
||||
p = os.path.join(out_dir, f"{slug}.base.png")
|
||||
if export_png(base_drawio, p):
|
||||
entry["base_png"] = p
|
||||
if have_head:
|
||||
p = os.path.join(out_dir, f"{slug}.head.png")
|
||||
if export_png(head_drawio, p):
|
||||
entry["head_png"] = p
|
||||
if have_base and have_head:
|
||||
p = os.path.join(out_dir, f"{slug}.diff.png")
|
||||
if export_diff_png(base_drawio, head_drawio, p, tmp):
|
||||
entry["diff_png"] = p
|
||||
return entry
|
||||
|
||||
|
||||
def render_markdown(entries, out_dir):
|
||||
"""Pure: Markdown PR report from prdiff entries. No I/O, no CLI.
|
||||
|
||||
entries: list of {"path", "status", "base_png"?, "head_png"?, "diff_png"?,
|
||||
"skipped"?} — image paths (if any) are made relative to out_dir for the
|
||||
Markdown links. "skipped" means the draw.io CLI was unavailable.
|
||||
"""
|
||||
counts = {"added": 0, "removed": 0, "modified": 0}
|
||||
for e in entries:
|
||||
counts[e["status"]] = counts.get(e["status"], 0) + 1
|
||||
lines = [
|
||||
"# draw.io diagram changes",
|
||||
"",
|
||||
f"{len(entries)} file(s) changed: +{counts.get('added', 0)} added, "
|
||||
f"-{counts.get('removed', 0)} removed, ~{counts.get('modified', 0)} modified",
|
||||
]
|
||||
if not entries:
|
||||
lines.append("")
|
||||
lines.append("No `.drawio` files changed.")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def rel(png):
|
||||
return os.path.relpath(png, out_dir).replace(os.sep, "/") if png else None
|
||||
|
||||
for e in entries:
|
||||
lines.append("")
|
||||
lines.append(f"## {e['path']} ({e['status']})")
|
||||
if e.get("skipped"):
|
||||
lines.append("")
|
||||
lines.append("_draw.io CLI not available — images skipped._")
|
||||
continue
|
||||
base_r, head_r, diff_r = rel(e.get("base_png")), rel(e.get("head_png")), rel(e.get("diff_png"))
|
||||
lines.append("")
|
||||
if base_r:
|
||||
lines.append(f"")
|
||||
if head_r:
|
||||
lines.append(f"")
|
||||
if diff_r:
|
||||
lines.append(f"")
|
||||
if not (base_r or head_r or diff_r):
|
||||
lines.append("_no image produced._")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Render PNGs + a Markdown report for .drawio files "
|
||||
"changed between two git refs.")
|
||||
ap.add_argument("--base", required=True, help="base git ref/sha")
|
||||
ap.add_argument("--head", default="HEAD", help="head git ref/sha (default HEAD)")
|
||||
ap.add_argument("--repo", default=".", help="path to the git repo (default: current directory)")
|
||||
ap.add_argument("--out-dir", default="drawio-pr", help="directory for exported PNGs (default: ./drawio-pr)")
|
||||
ap.add_argument("-o", "--output", help="write the Markdown report here (default: stdout)")
|
||||
args = ap.parse_args()
|
||||
|
||||
changed = changed_drawios(args.base, args.head, args.repo)
|
||||
if not changed:
|
||||
sys.stderr.write("no .drawio files changed\n")
|
||||
|
||||
drawio_available = shutil.which("drawio") is not None
|
||||
if not drawio_available and changed:
|
||||
sys.stderr.write("warning: draw.io CLI not found - image export skipped, "
|
||||
"Markdown will list files only (is the draw.io CLI installed?)\n")
|
||||
os.makedirs(args.out_dir, exist_ok=True)
|
||||
|
||||
entries = [build_entry(args.repo, args.base, args.head, path, status, args.out_dir, drawio_available)
|
||||
for path, status in changed]
|
||||
report = render_markdown(entries, args.out_dir)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(report)
|
||||
sys.stderr.write(f"wrote {args.output}\n")
|
||||
else:
|
||||
sys.stdout.write(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
158
skills/drawio-skill/scripts/pyclasses.py
Normal file
158
skills/drawio-skill/scripts/pyclasses.py
Normal file
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a Python project's class-inheritance graph as autolayout graph JSON.
|
||||
|
||||
A finer-grained companion to pyimports.py: instead of module->module imports,
|
||||
it emits one node per class and an edge from each subclass to the project base
|
||||
classes it extends. With --group, classes are boxed by their module (nested by
|
||||
sub-package), so the result reads as an auto-generated class hierarchy.
|
||||
|
||||
python3 pyclasses.py myproject --group -o graph.json
|
||||
python3 autolayout.py graph.json -o diagram.drawio
|
||||
|
||||
Only inheritance is resolved (statically reliable); base classes are matched
|
||||
by name, preferring a class in the same module. External bases (object,
|
||||
Exception, third-party) are ignored. This is a *class structure* view, not a
|
||||
function-level call graph — static call resolution in Python is unreliable, so
|
||||
that is deliberately out of scope.
|
||||
|
||||
Usage: python3 pyclasses.py <project_dir> [-o graph.json] [--direction TB|LR]
|
||||
[--group] [--no-reduce]
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def discover(root):
|
||||
"""Map dotted module name -> file path; qualify with the package name when
|
||||
root is itself a package (mirrors pyimports.py)."""
|
||||
root = os.path.abspath(root)
|
||||
base = os.path.basename(root) if os.path.exists(os.path.join(root, "__init__.py")) else ""
|
||||
modules = {}
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for fn in files:
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
rel = os.path.relpath(os.path.join(dirpath, fn), root)[:-3]
|
||||
parts = rel.split(os.sep)
|
||||
if parts[-1] == "__init__":
|
||||
parts = parts[:-1]
|
||||
parts = ([base] + parts) if base else parts
|
||||
if parts:
|
||||
modules[".".join(parts)] = os.path.join(dirpath, fn)
|
||||
return modules, base
|
||||
|
||||
|
||||
def base_name(node):
|
||||
"""Simple name of a base-class expression (`Foo` or `pkg.Foo` -> 'Foo')."""
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr
|
||||
return None
|
||||
|
||||
|
||||
def classes_in(module, path):
|
||||
"""Top-level classes of a module: list of (qualified_id, simple_name, [base names])."""
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read(), filename=path)
|
||||
except SyntaxError:
|
||||
return []
|
||||
out = []
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ClassDef):
|
||||
bases = [b for b in (base_name(b) for b in node.bases) if b]
|
||||
out.append((f"{module}.{node.name}", node.name, bases))
|
||||
return out
|
||||
|
||||
|
||||
def transitive_reduce(nodes, edges):
|
||||
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
||||
idx = {n: i for i, n in enumerate(nodes)}
|
||||
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
||||
try:
|
||||
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
||||
text=True, check=True).stdout
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
||||
return edges
|
||||
rev = {i: n for n, i in idx.items()}
|
||||
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Python class-inheritance graph -> autolayout graph JSON.")
|
||||
ap.add_argument("project", help="package or project directory")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="box classes by their module (nested by sub-package)")
|
||||
ap.add_argument("--no-reduce", action="store_true",
|
||||
help="keep every edge (skip transitive reduction)")
|
||||
args = ap.parse_args()
|
||||
|
||||
modules, base = discover(args.project)
|
||||
classes = {} # qualified id -> (module, bases)
|
||||
by_name = {} # simple name -> [qualified ids]
|
||||
for mod, path in modules.items():
|
||||
for cid, name, bases in classes_in(mod, path):
|
||||
classes[cid] = (mod, bases)
|
||||
by_name.setdefault(name, []).append(cid)
|
||||
if not classes:
|
||||
sys.exit(f"error: no classes found under {args.project}")
|
||||
|
||||
def resolve(name, module):
|
||||
cands = by_name.get(name, [])
|
||||
same = [c for c in cands if classes[c][0] == module]
|
||||
if same:
|
||||
return same[0] # prefer a class in the same module
|
||||
return cands[0] if len(cands) == 1 else None # else only if unambiguous
|
||||
|
||||
edges = set()
|
||||
for cid, (mod, bases) in classes.items():
|
||||
for b in bases:
|
||||
target = resolve(b, mod)
|
||||
if target and target != cid:
|
||||
edges.add((cid, target))
|
||||
edges = sorted(edges)
|
||||
raw = len(edges)
|
||||
if not args.no_reduce:
|
||||
edges = transitive_reduce(list(classes), edges)
|
||||
|
||||
strip = base + "." if base else ""
|
||||
short = lambda m: m[len(strip):] if strip and m.startswith(strip) else m
|
||||
|
||||
def node(cid):
|
||||
# No hard-coded colour: autolayout tints nodes by their group (module),
|
||||
# so a grouped class hierarchy reads as coloured-by-module.
|
||||
d = {"id": cid, "label": cid.rsplit(".", 1)[1]}
|
||||
if args.group:
|
||||
mod = classes[cid][0]
|
||||
path = short(mod).replace(".", "/") # module path -> nested boxes
|
||||
if path:
|
||||
d["group"] = path
|
||||
return d
|
||||
|
||||
graph = {
|
||||
"direction": args.direction,
|
||||
"nodes": [node(cid) for cid in classes],
|
||||
"edges": [{"source": s, "target": t} for s, t in 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)
|
||||
note = "" if args.no_reduce else f" (reduced from {raw})"
|
||||
sys.stderr.write(f"{len(classes)} classes, {len(edges)} inheritance edges{note}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
155
skills/drawio-skill/scripts/pyimports.py
Normal file
155
skills/drawio-skill/scripts/pyimports.py
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a Python project's module-import graph as autolayout graph JSON.
|
||||
|
||||
Walks a package/project directory, parses each module with `ast`, builds the
|
||||
intra-project import edges, and (by default) applies transitive reduction so
|
||||
the diagram stays readable instead of becoming a hairball. The output feeds
|
||||
autolayout.py:
|
||||
|
||||
python3 pyimports.py myproject -o graph.json
|
||||
python3 autolayout.py graph.json -o diagram.drawio
|
||||
|
||||
Transitive reduction uses Graphviz `tred` (drops edges implied by a longer
|
||||
path); pass --no-reduce to keep every import edge. Only intra-project imports
|
||||
are kept — third-party and stdlib imports are ignored.
|
||||
|
||||
Usage: python3 pyimports.py <project_dir> [-o graph.json] [--direction TB|LR] [--no-reduce]
|
||||
"""
|
||||
import argparse
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def discover(root):
|
||||
"""Map dotted module name -> file path for every .py under root, plus the
|
||||
package prefix. If root is itself a package (has __init__.py), module names
|
||||
are qualified with its name so the project's own absolute imports resolve."""
|
||||
root = os.path.abspath(root)
|
||||
base = os.path.basename(root) if os.path.exists(os.path.join(root, "__init__.py")) else ""
|
||||
modules = {}
|
||||
for dirpath, _, files in os.walk(root):
|
||||
for fn in files:
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
rel = os.path.relpath(os.path.join(dirpath, fn), root)[:-3] # strip .py
|
||||
parts = rel.split(os.sep)
|
||||
if parts[-1] == "__init__":
|
||||
parts = parts[:-1] # package = its dir
|
||||
parts = ([base] + parts) if base else parts
|
||||
if parts:
|
||||
modules[".".join(parts)] = os.path.join(dirpath, fn)
|
||||
return modules, base
|
||||
|
||||
|
||||
def resolve(name, current, modules):
|
||||
"""Resolve a dotted name to the longest known module prefix (or None)."""
|
||||
parts = name.split(".") if name else []
|
||||
while parts:
|
||||
cand = ".".join(parts)
|
||||
if cand in modules and cand != current:
|
||||
return cand
|
||||
parts = parts[:-1]
|
||||
return None
|
||||
|
||||
|
||||
def edges_of(name, path, modules):
|
||||
"""Intra-project modules imported by `name`."""
|
||||
pkg = name if path.endswith("__init__.py") else name.rsplit(".", 1)[0] if "." in name else ""
|
||||
found = set()
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
tree = ast.parse(f.read(), filename=path)
|
||||
except SyntaxError:
|
||||
return found
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import): # import a.b.c
|
||||
for alias in node.names:
|
||||
target = resolve(alias.name, name, modules)
|
||||
if target:
|
||||
found.add(target)
|
||||
elif isinstance(node, ast.ImportFrom): # from a.b import c
|
||||
if node.level: # relative: climb level-1 packages
|
||||
base = pkg.split(".") if pkg else []
|
||||
base = base[: len(base) - (node.level - 1)]
|
||||
prefix = ".".join(base)
|
||||
mod = f"{prefix}.{node.module}" if prefix and node.module else (node.module or prefix)
|
||||
else:
|
||||
mod = node.module or ""
|
||||
target = resolve(mod, name, modules)
|
||||
if target:
|
||||
found.add(target)
|
||||
for alias in node.names: # `from pkg import submodule`
|
||||
sub = f"{mod}.{alias.name}" if mod else alias.name
|
||||
target = resolve(sub, name, modules)
|
||||
if target:
|
||||
found.add(target)
|
||||
return found
|
||||
|
||||
|
||||
def transitive_reduce(nodes, edges):
|
||||
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
||||
idx = {n: i for i, n in enumerate(nodes)}
|
||||
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
||||
try:
|
||||
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
||||
text=True, check=True).stdout
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
||||
return edges
|
||||
rev = {i: n for n, i in idx.items()}
|
||||
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Python import graph -> autolayout graph JSON.")
|
||||
ap.add_argument("project", help="package or project directory")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="group nodes into containers by sub-package")
|
||||
ap.add_argument("--no-reduce", action="store_true",
|
||||
help="keep every edge (skip transitive reduction)")
|
||||
args = ap.parse_args()
|
||||
|
||||
modules, base = discover(args.project)
|
||||
if not modules:
|
||||
sys.exit(f"error: no .py modules found under {args.project}")
|
||||
edges = sorted({(name, t) for name, path in modules.items()
|
||||
for t in edges_of(name, path, modules)})
|
||||
raw = len(edges)
|
||||
if not args.no_reduce:
|
||||
edges = transitive_reduce(list(modules), edges)
|
||||
# Drop the shared package prefix from labels for readability; ids stay full.
|
||||
strip = base + "." if base else ""
|
||||
label = lambda m: m[len(strip):] if strip and m.startswith(strip) else m
|
||||
|
||||
def node(m):
|
||||
d = {"id": m, "label": label(m)}
|
||||
if args.group:
|
||||
rest = label(m).split(".")
|
||||
if len(rest) > 1: # nested under a sub-package
|
||||
d["group"] = "/".join(rest[:-1]) # full sub-package path -> nested boxes
|
||||
return d
|
||||
|
||||
graph = {
|
||||
"direction": args.direction,
|
||||
"nodes": [node(m) for m in modules],
|
||||
"edges": [{"source": s, "target": t} for s, t in 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)
|
||||
note = "" if args.no_reduce else f" (reduced from {raw})"
|
||||
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
196
skills/drawio-skill/scripts/raster2drawio.py
Normal file
196
skills/drawio-skill/scripts/raster2drawio.py
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""De-rasterize an image-extracted graph (JSON) into an editable .drawio.
|
||||
|
||||
Turns a whiteboard photo, legacy PNG, or Visio screenshot into an editable
|
||||
diagram: Claude's own vision reads the image and extracts a JSON description
|
||||
of the nodes/edges (the workflow is documented in
|
||||
references/derasterize.md); this script turns that JSON into `.drawio` XML,
|
||||
honoring the coordinates, labels, shapes, and colors Claude read off the
|
||||
image.
|
||||
|
||||
python3 raster2drawio.py graph.json -o out.drawio
|
||||
|
||||
Input JSON:
|
||||
{"nodes": [{"id": "n1", "label": "API Gateway", "x": 120, "y": 60,
|
||||
"w": 160, "h": 60, "shape": "rect",
|
||||
"fill": "#dae8fc", "stroke": "#6c8ebf"},
|
||||
{"id": "n2", "label": "Auth DB", "x": 360, "y": 60,
|
||||
"shape": "cylinder"}],
|
||||
"edges": [{"source": "n1", "target": "n2", "label": "HTTPS",
|
||||
"dashed": false, "arrow": true}]}
|
||||
|
||||
Only "id" is required per node; label defaults to id, w/h default to
|
||||
120/60, shape defaults to "rect" (choices: rect, rounded, ellipse,
|
||||
rhombus/diamond, cylinder, parallelogram, cloud, hexagon), fill/stroke
|
||||
default to the skill's palette blue. Edge "arrow" defaults to true
|
||||
(endArrow=none when false); "dashed" defaults to false.
|
||||
|
||||
If ANY node is missing x or y, positions are not guessed: the graph is
|
||||
handed to autolayout.py (shelled out, requires Graphviz `dot`) to place it,
|
||||
and a note is written to stderr.
|
||||
|
||||
Usage: python3 raster2drawio.py <graph.json|-> [-o out.drawio]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
DEFAULT_W, DEFAULT_H = 120, 60
|
||||
DEFAULT_FILL, DEFAULT_STROKE = "#dae8fc", "#6c8ebf"
|
||||
SHAPES = {
|
||||
"rect": "whiteSpace=wrap;html=1;",
|
||||
"rounded": "rounded=1;whiteSpace=wrap;html=1;",
|
||||
"ellipse": "ellipse;whiteSpace=wrap;html=1;",
|
||||
"rhombus": "rhombus;whiteSpace=wrap;html=1;",
|
||||
"diamond": "rhombus;whiteSpace=wrap;html=1;",
|
||||
"cylinder": "shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;size=15;",
|
||||
"parallelogram": "shape=parallelogram;whiteSpace=wrap;html=1;",
|
||||
"cloud": "ellipse;shape=cloud;whiteSpace=wrap;html=1;",
|
||||
"hexagon": "shape=hexagon;perimeter=hexagonPerimeter2;whiteSpace=wrap;html=1;",
|
||||
}
|
||||
EDGE_BASE = "edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;"
|
||||
|
||||
|
||||
def attr(value):
|
||||
# Newlines in labels become 
 so draw.io renders a line break (a raw
|
||||
# newline inside an XML attribute is normalized to a space by parsers).
|
||||
return escape(str(value), {'"': """, "\n": "
"})
|
||||
|
||||
|
||||
def node_style(node):
|
||||
base = SHAPES.get(node.get("shape", "rect"), SHAPES["rect"])
|
||||
fill = node.get("fill", DEFAULT_FILL)
|
||||
stroke = node.get("stroke", DEFAULT_STROKE)
|
||||
return f"{base}fillColor={fill};strokeColor={stroke};"
|
||||
|
||||
|
||||
def edge_style(edge):
|
||||
style = EDGE_BASE
|
||||
if edge.get("dashed"):
|
||||
style += "dashed=1;"
|
||||
if edge.get("arrow") is False:
|
||||
style += "endArrow=none;"
|
||||
return style
|
||||
|
||||
|
||||
def to_drawio(nodes, edges):
|
||||
"""Direct build: every node already has x/y. Mirrors autolayout.py's
|
||||
to_drawio() string-building, without the dot layout pass."""
|
||||
cells = []
|
||||
for node in nodes:
|
||||
w, h = node.get("w", DEFAULT_W), node.get("h", DEFAULT_H)
|
||||
cells.append(
|
||||
f' <mxCell id="{attr(node["id"])}" value="{attr(node.get("label", node["id"]))}" '
|
||||
f'style="{attr(node_style(node))}" vertex="1" parent="1">\n'
|
||||
f' <mxGeometry x="{node["x"]}" y="{node["y"]}" width="{w}" height="{h}" as="geometry"/>\n'
|
||||
f" </mxCell>"
|
||||
)
|
||||
for i, edge in enumerate(edges):
|
||||
cells.append(
|
||||
f' <mxCell id="e{i}" value="{attr(edge.get("label", ""))}" '
|
||||
f'style="{attr(edge_style(edge))}" edge="1" parent="1" '
|
||||
f'source="{attr(edge["source"])}" target="{attr(edge["target"])}">\n'
|
||||
f' <mxGeometry relative="1" as="geometry"/>\n'
|
||||
f" </mxCell>"
|
||||
)
|
||||
return (
|
||||
"<mxfile>\n"
|
||||
' <diagram id="raster2drawio" name="Page-1">\n'
|
||||
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" '
|
||||
'tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" '
|
||||
'pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
|
||||
" <root>\n"
|
||||
' <mxCell id="0"/>\n'
|
||||
' <mxCell id="1" parent="0"/>\n'
|
||||
+ "\n".join(cells)
|
||||
+ "\n </root>\n </mxGraphModel>\n </diagram>\n</mxfile>\n"
|
||||
)
|
||||
|
||||
|
||||
def build_autolayout_graph(nodes, edges):
|
||||
"""Same graph, in autolayout.py's input shape (positions dropped —
|
||||
dot will compute fresh ones for every node)."""
|
||||
return {
|
||||
"direction": "TB",
|
||||
"nodes": [
|
||||
{"id": n["id"], "label": n.get("label", n["id"]), "style": node_style(n),
|
||||
"width": n.get("w", DEFAULT_W), "height": n.get("h", DEFAULT_H)}
|
||||
for n in nodes
|
||||
],
|
||||
"edges": [
|
||||
{"source": e["source"], "target": e["target"], "label": e.get("label", ""),
|
||||
"style": edge_style(e)}
|
||||
for e in edges
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def run_autolayout(graph):
|
||||
"""Shell out to the sibling autolayout.py; return the .drawio XML text."""
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
autolayout = os.path.join(here, "autolayout.py")
|
||||
fd, graph_path = tempfile.mkstemp(suffix=".json")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(graph, f)
|
||||
r = subprocess.run([sys.executable, autolayout, graph_path],
|
||||
capture_output=True, text=True)
|
||||
finally:
|
||||
os.unlink(graph_path)
|
||||
if r.returncode != 0:
|
||||
sys.exit(f"error: autolayout.py failed: {r.stderr.strip()}")
|
||||
return r.stdout
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Convert an image-extracted graph JSON into an editable .drawio.")
|
||||
ap.add_argument("input", help="graph JSON file, or - for stdin")
|
||||
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.input == "-":
|
||||
raw = sys.stdin.read()
|
||||
else:
|
||||
try:
|
||||
with open(args.input, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
except OSError as exc:
|
||||
sys.exit(f"error: cannot read {args.input}: {exc}")
|
||||
try:
|
||||
graph = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.exit(f"error: invalid JSON: {exc}")
|
||||
|
||||
nodes = graph.get("nodes") or []
|
||||
edges = graph.get("edges") or []
|
||||
if not nodes:
|
||||
sys.exit("error: no nodes in input")
|
||||
for n in nodes:
|
||||
if "id" not in n:
|
||||
sys.exit("error: every node needs an 'id'")
|
||||
for e in edges:
|
||||
if "source" not in e or "target" not in e:
|
||||
sys.exit("error: every edge needs 'source' and 'target'")
|
||||
|
||||
if any(n.get("x") is None or n.get("y") is None for n in nodes):
|
||||
xml = run_autolayout(build_autolayout_graph(nodes, edges))
|
||||
sys.stderr.write(
|
||||
"note: some nodes had no x/y — positions were auto-placed via autolayout.py\n")
|
||||
else:
|
||||
xml = to_drawio(nodes, edges)
|
||||
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
sys.stderr.write(f"wrote {args.output} ({len(nodes)} nodes, {len(edges)} edges)\n")
|
||||
else:
|
||||
sys.stdout.write(xml)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
102
skills/drawio-skill/scripts/relabel.py
Normal file
102
skills/drawio-skill/scripts/relabel.py
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Swap every label in a .drawio via a mapping — layout, styles, ids untouched.
|
||||
|
||||
The main use-case is language variants of one diagram (EN <-> CN docs figures):
|
||||
extract the labels, translate the values, apply the map — the geometry never
|
||||
moves, so both variants stay pixel-identical except for the text.
|
||||
|
||||
python3 relabel.py diagram.drawio --extract -o labels.json # step 1
|
||||
# edit labels.json: keep keys, replace each value with the new text
|
||||
python3 relabel.py diagram.drawio --map labels.json -o diagram_cn.drawio
|
||||
|
||||
Extract emits an identity JSON map {"label": "label", ...} of every non-empty
|
||||
vertex/edge label, UserObject label, and page name, in document order. Apply
|
||||
replaces each label that exactly matches a map key (raw string, HTML markup
|
||||
included) and reports what matched. Unmapped labels stay unchanged; map keys
|
||||
that matched nothing are listed on stderr so translations don't silently miss.
|
||||
|
||||
Usage: relabel.py <file.drawio> (--extract | --map <labels.json>) [-o <out>]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
|
||||
def label_slots(tree):
|
||||
"""Yield (element, attribute) for every label-bearing slot in the file."""
|
||||
for diagram in tree.getroot().iter("diagram"):
|
||||
if diagram.get("name"):
|
||||
yield diagram, "name"
|
||||
model = diagram.find("mxGraphModel")
|
||||
root = model.find("root") if model is not None else None
|
||||
if root is None: # compressed page — can't edit
|
||||
sys.stderr.write("warning: skipping compressed page "
|
||||
f"'{diagram.get('name', '?')}' (open+save in draw.io to decompress)\n")
|
||||
continue
|
||||
for child in root:
|
||||
if child.tag == "mxCell":
|
||||
if child.get("value"):
|
||||
yield child, "value"
|
||||
elif child.tag in ("UserObject", "object"):
|
||||
if child.get("label"):
|
||||
yield child, "label"
|
||||
inner = child.find("mxCell")
|
||||
if inner is not None and inner.get("value"):
|
||||
yield inner, "value"
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Extract or swap .drawio labels via a JSON map.")
|
||||
p.add_argument("file", help="input .drawio")
|
||||
mode = p.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--extract", action="store_true",
|
||||
help="dump an identity label map as JSON")
|
||||
mode.add_argument("--map", metavar="JSON", dest="mapfile",
|
||||
help="JSON map {old label: new label} to apply")
|
||||
p.add_argument("-o", "--output", help="output path (default: stdout for --extract, "
|
||||
"<file>-relabel.drawio for --map)")
|
||||
args = p.parse_args()
|
||||
|
||||
if not os.path.isfile(args.file):
|
||||
sys.exit(f"error: {args.file} not found")
|
||||
tree = ET.parse(args.file)
|
||||
|
||||
if args.extract:
|
||||
seen = {}
|
||||
for el, attr in label_slots(tree):
|
||||
seen.setdefault(el.get(attr), el.get(attr))
|
||||
out = json.dumps(seen, ensure_ascii=False, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(out + "\n")
|
||||
sys.stderr.write(f"wrote {args.output} ({len(seen)} labels)\n")
|
||||
else:
|
||||
print(out)
|
||||
return
|
||||
|
||||
with open(args.mapfile, encoding="utf-8") as f:
|
||||
mapping = json.load(f)
|
||||
if not isinstance(mapping, dict):
|
||||
sys.exit("error: map file must be a JSON object {old: new}")
|
||||
|
||||
matched, used = 0, set()
|
||||
for el, attr in label_slots(tree):
|
||||
old = el.get(attr)
|
||||
if old in mapping:
|
||||
el.set(attr, str(mapping[old]))
|
||||
matched += 1
|
||||
used.add(old)
|
||||
|
||||
out = args.output or os.path.splitext(args.file)[0] + "-relabel.drawio"
|
||||
tree.write(out, encoding="utf-8", xml_declaration=False)
|
||||
unused = [k for k in mapping if k not in used]
|
||||
if unused:
|
||||
sys.stderr.write("warning: %d map key(s) matched no label: %s\n"
|
||||
% (len(unused), ", ".join(repr(k)[:60] for k in unused[:10])))
|
||||
sys.stderr.write(f"wrote {out} ({matched} labels replaced)\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
37
skills/drawio-skill/scripts/repair_png.py
Normal file
37
skills/drawio-skill/scripts/repair_png.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repair truncated IEND chunk in draw.io -e PNG exports (issue #8).
|
||||
|
||||
draw.io's CLI emits -e PNGs with the 4-byte IEND length field but missing
|
||||
the 8 bytes of "IEND" type + CRC. Strict PNG decoders and vision APIs
|
||||
(Anthropic included) reject the file with 400 "Could not process image".
|
||||
SVG/PDF are unaffected.
|
||||
|
||||
Usage: python3 repair_png.py <path/to/diagram.drawio.png>
|
||||
|
||||
Idempotent: the endswith(IEND) guard makes this a no-op once draw.io
|
||||
fixes the bug upstream, so it's safe to run unconditionally after every
|
||||
-e PNG export.
|
||||
"""
|
||||
import sys
|
||||
|
||||
IEND = b"\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
|
||||
|
||||
def repair(path: str) -> bool:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
if data.endswith(IEND):
|
||||
return False
|
||||
if data.endswith(b"\x00\x00\x00\x00"):
|
||||
data = data[:-4]
|
||||
with open(path, "wb") as f:
|
||||
f.write(data + IEND)
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: repair_png.py <path>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if repair(sys.argv[1]):
|
||||
print(f"repaired {sys.argv[1]}")
|
||||
161
skills/drawio-skill/scripts/restyle.py
Normal file
161
skills/drawio-skill/scripts/restyle.py
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Re-theme an EXISTING .drawio with a style preset — layout and shapes untouched.
|
||||
|
||||
Style presets (styles/schema.json) normally apply at generation time; this is
|
||||
the post-processor for diagrams that already exist: "make this dark", "apply my
|
||||
corporate style to last week's diagram".
|
||||
|
||||
python3 restyle.py diagram.drawio --preset dark
|
||||
python3 restyle.py diagram.drawio --preset ~/.drawio-skill/styles/corp.json -o out.drawio
|
||||
|
||||
What it changes, per the preset application rules in references/style-presets.md:
|
||||
- Every vertex fill/stroke is remapped to the preset palette. Each existing
|
||||
fillColor is matched to its nearest palette slot by hue (grey/low-saturation
|
||||
-> neutral), so same-colored nodes stay same-colored in the new theme.
|
||||
- font.fontFamily on every vertex; existing fontSize values are kept (they
|
||||
encode hierarchy).
|
||||
- extras: fontColor (vertices + text cells), edgeColor (edge stroke + label),
|
||||
sketch=1, globalStrokeWidth, page background on <mxGraphModel>.
|
||||
Edge ROUTING styles and shape keywords are left alone — rewriting them would
|
||||
break existing waypoints and geometry. fillColor=none is structural (lanes,
|
||||
transparent containers) and is never replaced.
|
||||
|
||||
Usage: restyle.py <file.drawio> --preset <name|path.json> [-o <out.drawio>]
|
||||
"""
|
||||
import argparse
|
||||
import colorsys
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
SKILL_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
# Canonical hue (degrees) of each palette slot in the built-in conventions.
|
||||
SLOT_HUES = {"primary": 210, "success": 120, "warning": 50, "accent": 30,
|
||||
"danger": 0, "secondary": 280}
|
||||
SLOT_ORDER = ["primary", "success", "warning", "accent", "danger", "neutral", "secondary"]
|
||||
|
||||
|
||||
def find_preset(name):
|
||||
"""Resolve a preset name/path to its JSON dict (user dir, then built-ins)."""
|
||||
candidates = [name] if name.endswith(".json") else [
|
||||
os.path.expanduser(f"~/.drawio-skill/styles/{name.lower()}.json"),
|
||||
os.path.join(SKILL_DIR, "styles", "built-in", f"{name.lower()}.json"),
|
||||
]
|
||||
for path in candidates:
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
builtin = os.path.join(SKILL_DIR, "styles", "built-in")
|
||||
known = sorted(f[:-5] for f in os.listdir(builtin) if f.endswith(".json"))
|
||||
sys.exit(f"error: preset '{name}' not found (built-ins: {', '.join(known)})")
|
||||
|
||||
|
||||
def hue_slot(hexcolor, palette):
|
||||
"""Nearest non-null palette slot for an existing fill color, by hue."""
|
||||
r, g, b = (int(hexcolor[i:i + 2], 16) / 255 for i in (1, 3, 5))
|
||||
h, l, s = colorsys.rgb_to_hls(r, g, b)
|
||||
if s < 0.15 or l > 0.97 or l < 0.03: # grey / near-white / near-black
|
||||
slot = "neutral"
|
||||
else:
|
||||
deg = h * 360
|
||||
slot = min(SLOT_HUES, key=lambda k: min(abs(deg - SLOT_HUES[k]),
|
||||
360 - abs(deg - SLOT_HUES[k])))
|
||||
if palette.get(slot):
|
||||
return slot
|
||||
for k in SLOT_ORDER: # fallback ladder
|
||||
if palette.get(k):
|
||||
return k
|
||||
sys.exit("error: preset palette has no non-null slots")
|
||||
|
||||
|
||||
def get_key(style, key):
|
||||
m = re.search(rf"(?:^|;){key}=([^;]*)", style)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def set_keys(style, **kv):
|
||||
"""Replace/insert style keys, dropping existing occurrences first."""
|
||||
for key in kv:
|
||||
style = re.sub(rf"(?:^|;){key}=[^;]*", "", style)
|
||||
style = style.strip("; ")
|
||||
tail = ";".join(f"{k}={v}" for k, v in kv.items() if v is not None)
|
||||
return (style + ";" if style else "") + tail + ";"
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="Apply a style preset to an existing .drawio.")
|
||||
p.add_argument("file", help="input .drawio")
|
||||
p.add_argument("--preset", required=True, help="preset name (user or built-in) or JSON path")
|
||||
p.add_argument("-o", "--output", help="output path (default: <file>-<preset>.drawio)")
|
||||
args = p.parse_args()
|
||||
|
||||
if not os.path.isfile(args.file):
|
||||
sys.exit(f"error: {args.file} not found")
|
||||
preset = find_preset(args.preset)
|
||||
palette, extras, font = preset["palette"], preset.get("extras", {}), preset["font"]
|
||||
|
||||
vertex_extra = {"fontFamily": font["fontFamily"]}
|
||||
if extras.get("fontColor"):
|
||||
vertex_extra["fontColor"] = extras["fontColor"]
|
||||
if extras.get("sketch"):
|
||||
vertex_extra["sketch"] = "1"
|
||||
if extras.get("globalStrokeWidth") not in (None, 1):
|
||||
vertex_extra["strokeWidth"] = "%g" % extras["globalStrokeWidth"]
|
||||
|
||||
tree = ET.parse(args.file)
|
||||
slot_map, n_vert, n_edge = {}, 0, 0
|
||||
for diagram in tree.getroot().iter("diagram"):
|
||||
model = diagram.find("mxGraphModel")
|
||||
root = model.find("root") if model is not None else None
|
||||
if root is None:
|
||||
sys.stderr.write(f"warning: skipping compressed page '{diagram.get('name', '?')}'\n")
|
||||
continue
|
||||
if extras.get("background"):
|
||||
model.set("background", extras["background"])
|
||||
for child in root:
|
||||
cell = child if child.tag == "mxCell" else child.find("mxCell")
|
||||
if cell is None:
|
||||
continue
|
||||
style = cell.get("style") or ""
|
||||
if cell.get("edge") == "1":
|
||||
kv = {}
|
||||
if extras.get("edgeColor"):
|
||||
# labelBackgroundColor=none: the default white label box is
|
||||
# unreadable under a light edgeColor on dark backgrounds
|
||||
kv.update(strokeColor=extras["edgeColor"], fontColor=extras["edgeColor"],
|
||||
labelBackgroundColor="none")
|
||||
if extras.get("sketch"):
|
||||
kv["sketch"] = "1"
|
||||
if extras.get("globalStrokeWidth") not in (None, 1):
|
||||
kv["strokeWidth"] = "%g" % extras["globalStrokeWidth"]
|
||||
if kv:
|
||||
cell.set("style", set_keys(style, **kv))
|
||||
n_edge += 1
|
||||
continue
|
||||
if cell.get("vertex") != "1":
|
||||
continue
|
||||
kv = dict(vertex_extra)
|
||||
fill = get_key(style, "fillColor")
|
||||
if fill and re.fullmatch(r"#[0-9A-Fa-f]{6}", fill):
|
||||
slot = slot_map.setdefault(fill.lower(), hue_slot(fill.lower(), palette))
|
||||
pair = palette[slot]
|
||||
kv.update(fillColor=pair["fillColor"], strokeColor=pair["strokeColor"])
|
||||
elif fill is None:
|
||||
# No fillColor -> draw.io default white fill. Keep its default
|
||||
# dark text: extras.fontColor would be unreadable on white.
|
||||
kv.pop("fontColor", None)
|
||||
cell.set("style", set_keys(style, **kv))
|
||||
n_vert += 1
|
||||
|
||||
out = args.output or "%s-%s.drawio" % (os.path.splitext(args.file)[0],
|
||||
preset.get("name", "restyled"))
|
||||
tree.write(out, encoding="utf-8", xml_declaration=False)
|
||||
remap = ", ".join(f"{c}->{s}" for c, s in sorted(slot_map.items()))
|
||||
sys.stderr.write(f"wrote {out} ({n_vert} vertices, {n_edge} edges restyled"
|
||||
+ (f"; colors: {remap}" if remap else "") + ")\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
241
skills/drawio-skill/scripts/runbook.py
Normal file
241
skills/drawio-skill/scripts/runbook.py
Normal file
@@ -0,0 +1,241 @@
|
||||
#!/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 <file.drawio> [-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("</", "<\\/")
|
||||
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{html.escape(title)}</title><style>
|
||||
:root{{color-scheme:light dark}}
|
||||
*{{box-sizing:border-box}}
|
||||
body{{margin:0;font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
|
||||
background:#f6f7f9;color:#1a1a1a;min-height:100vh;display:flex;flex-direction:column;align-items:center}}
|
||||
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
|
||||
header{{width:100%;max-width:640px;padding:16px 20px 4px}}
|
||||
h1{{margin:0;font-size:16px;font-weight:600}}
|
||||
#crumbs{{width:100%;max-width:640px;padding:6px 20px;display:flex;flex-wrap:wrap;gap:4px;
|
||||
font-size:12px;color:#667}}
|
||||
@media(prefers-color-scheme:dark){{#crumbs{{color:#9aa}}}}
|
||||
#crumbs span:not(:last-child)::after{{content:" \\2192 ";margin:0 2px}}
|
||||
main{{width:100%;max-width:640px;padding:12px 20px 28px;flex:1}}
|
||||
#card{{background:#fff;border:1px solid #0002;border-left:4px solid #0d99ff;border-radius:12px;
|
||||
padding:24px;box-shadow:0 1px 3px #0001}}
|
||||
@media(prefers-color-scheme:dark){{#card{{background:#1e2226;border-color:#fff2;border-left-color:#0d99ff}}}}
|
||||
#card.decision{{border-left-color:#d79b00}}
|
||||
#card.end{{border-left-color:#82b366}}
|
||||
#card.io{{border-left-color:#9673a6}}
|
||||
#type{{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#889;margin:0 0 8px}}
|
||||
#label{{font-size:19px;font-weight:600;white-space:pre-wrap;margin:0 0 20px}}
|
||||
.choices{{display:flex;flex-direction:column;gap:8px}}
|
||||
.choices button{{font:inherit;text-align:left;padding:10px 14px;border:1px solid #0002;
|
||||
border-radius:8px;background:#fff;cursor:pointer;color:inherit}}
|
||||
@media(prefers-color-scheme:dark){{.choices button{{background:#262b31;border-color:#fff2}}}}
|
||||
.choices button:hover{{border-color:#0d99ff;color:#0d99ff}}
|
||||
.ctl{{display:flex;gap:10px;margin-top:20px}}
|
||||
.ctl button{{font:inherit;padding:6px 14px;border:1px solid #0002;border-radius:8px;
|
||||
background:#fff;cursor:pointer;color:inherit}}
|
||||
@media(prefers-color-scheme:dark){{.ctl button{{background:#262b31;border-color:#fff2}}}}
|
||||
.ctl button:hover{{border-color:#0d99ff}}
|
||||
.ctl button:disabled{{opacity:.4;cursor:default}}
|
||||
#endmsg{{display:none;color:#82b366;font-weight:600;margin:0}}
|
||||
</style></head><body>
|
||||
<header><h1>{html.escape(title)}</h1></header>
|
||||
<div id="crumbs"></div>
|
||||
<main>
|
||||
<div id="card">
|
||||
<p id="type"></p>
|
||||
<p id="label"></p>
|
||||
<div class="choices" id="choices"></div>
|
||||
<p id="endmsg">End of path -- nothing more to check.</p>
|
||||
</div>
|
||||
<div class="ctl">
|
||||
<button id="back">← Back</button>
|
||||
<button id="restart">↻ Restart</button>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const DATA={payload};
|
||||
let path=[DATA.start];
|
||||
const $=id=>document.getElementById(id);
|
||||
function render(){{
|
||||
const cur=path[path.length-1];
|
||||
const node=DATA.nodes[cur]||{{label:String(cur),type:"process"}};
|
||||
$("card").className=node.type;
|
||||
$("type").textContent=node.type;
|
||||
$("label").textContent=node.label;
|
||||
const choices=DATA.edges[cur]||[];
|
||||
const box=$("choices");box.innerHTML="";
|
||||
$("endmsg").style.display=choices.length?"none":"block";
|
||||
choices.forEach(c=>{{
|
||||
const b=document.createElement("button");
|
||||
const target=DATA.nodes[c.target]||{{}};
|
||||
b.textContent=c.label||(choices.length===1?"Continue":(target.label||c.target));
|
||||
b.onclick=()=>{{path.push(c.target);render();}};
|
||||
box.appendChild(b);
|
||||
}});
|
||||
$("back").disabled=path.length<2;
|
||||
const crumbs=$("crumbs");crumbs.innerHTML="";
|
||||
path.forEach((id,i)=>{{
|
||||
const s=document.createElement("span");
|
||||
s.textContent=(DATA.nodes[id]||{{}}).label||id;
|
||||
if(i<path.length-1){{
|
||||
s.style.cursor="pointer";
|
||||
s.onclick=()=>{{path=path.slice(0,i+1);render();}};
|
||||
}}
|
||||
crumbs.appendChild(s);
|
||||
}});
|
||||
}}
|
||||
$("back").onclick=()=>{{if(path.length>1){{path.pop();render();}}}};
|
||||
$("restart").onclick=()=>{{path=[DATA.start];render();}};
|
||||
render();
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
|
||||
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()
|
||||
205
skills/drawio-skill/scripts/rustimports.py
Normal file
205
skills/drawio-skill/scripts/rustimports.py
Normal file
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a Rust crate's module-use graph as autolayout graph JSON.
|
||||
|
||||
The Rust counterpart to pyimports.py / jsimports.py / goimports.py. Treats each
|
||||
.rs file as a module (path-derived: src/foo/bar.rs -> module foo::bar; main.rs /
|
||||
lib.rs / mod.rs name the enclosing module), and records intra-crate `use` edges
|
||||
resolved through Rust's path roots:
|
||||
|
||||
use crate::a::b::C; -> edge to module a::b
|
||||
use super::sibling; -> resolved against the current module's parent
|
||||
use self::child::Item;-> resolved against the current module
|
||||
use other_crate::...; / use std::...; -> external, ignored
|
||||
|
||||
Brace groups (`use crate::a::{B, C};`, `use crate::{a, b};`) are expanded.
|
||||
Transitive reduction is on by default so the diagram stays readable.
|
||||
|
||||
python3 rustimports.py ./mycrate --group -o graph.json
|
||||
python3 autolayout.py graph.json -o diagram.drawio
|
||||
|
||||
Parsing is regex-based, not a full parser: inline `mod { ... }` blocks are not
|
||||
split out, `#[cfg]`-gated modules are always included, and 2015-edition bare
|
||||
intra-crate paths (without `crate::`) are not resolved.
|
||||
|
||||
Usage: python3 rustimports.py <crate_dir> [-o graph.json] [--direction TB|LR]
|
||||
[--group] [--no-reduce]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
USE = re.compile(r"\buse\s+([^;]+);")
|
||||
|
||||
|
||||
def crate_name(root):
|
||||
cargo = os.path.join(root, "Cargo.toml")
|
||||
if os.path.exists(cargo):
|
||||
with open(cargo, encoding="utf-8", errors="ignore") as f:
|
||||
m = re.search(r'(?m)^\s*name\s*=\s*"([^"]+)"', f.read())
|
||||
if m:
|
||||
return m.group(1)
|
||||
return "crate"
|
||||
|
||||
|
||||
def discover(root):
|
||||
"""Map module path (tuple of segments; () is the crate root) -> file path."""
|
||||
root = os.path.abspath(root)
|
||||
src = os.path.join(root, "src") if os.path.isdir(os.path.join(root, "src")) else root
|
||||
modules = {}
|
||||
for dirpath, dirs, files in os.walk(src):
|
||||
dirs[:] = [d for d in dirs if d != "target" and not d.startswith(".")]
|
||||
for fn in files:
|
||||
if not fn.endswith(".rs"):
|
||||
continue
|
||||
parts = os.path.relpath(os.path.join(dirpath, fn), src)[:-3].split(os.sep)
|
||||
if parts[-1] == "mod":
|
||||
parts = parts[:-1]
|
||||
if len(parts) == 1 and parts[0] in ("main", "lib"):
|
||||
parts = [] # crate root
|
||||
modules[tuple(parts)] = os.path.join(dirpath, fn)
|
||||
return modules, src
|
||||
|
||||
|
||||
def split_top(inner):
|
||||
"""Split a brace group on top-level commas, ignoring nested braces."""
|
||||
out, depth, cur = [], 0, ""
|
||||
for ch in inner:
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
out.append(cur)
|
||||
cur = ""
|
||||
else:
|
||||
cur += ch
|
||||
if cur.strip():
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def base_segments(prefix, current):
|
||||
"""Classify a `use` path prefix into intra-crate base segments, or None."""
|
||||
segs = [s for s in (p.strip() for p in prefix.split("::")) if s]
|
||||
if not segs:
|
||||
return None
|
||||
if segs[0] == "crate":
|
||||
return segs[1:]
|
||||
if segs[0] == "self":
|
||||
return list(current) + segs[1:]
|
||||
if segs[0] == "super":
|
||||
n = 0
|
||||
while segs and segs[0] == "super":
|
||||
n += 1
|
||||
segs = segs[1:]
|
||||
if n > len(current):
|
||||
return None # climbs above the crate root
|
||||
return list(current)[: len(current) - n] + segs
|
||||
return None # std / external crate
|
||||
|
||||
|
||||
def resolve(parts, modules, current):
|
||||
"""Longest known module prefix of `parts` (a tuple), or None."""
|
||||
if not parts:
|
||||
return () if () in modules and () != tuple(current) else None
|
||||
p = list(parts)
|
||||
while p:
|
||||
if tuple(p) in modules and tuple(p) != tuple(current):
|
||||
return tuple(p)
|
||||
p = p[:-1]
|
||||
return None
|
||||
|
||||
|
||||
def edges_of(current, path, modules):
|
||||
"""Intra-crate module paths used by the module at `current`."""
|
||||
found = set()
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
src = f.read()
|
||||
except OSError:
|
||||
return found
|
||||
for stmt in USE.findall(src):
|
||||
if "{" in stmt:
|
||||
prefix = stmt[: stmt.index("{")]
|
||||
inner = stmt[stmt.index("{") + 1: stmt.rindex("}")] if "}" in stmt else ""
|
||||
leaves = split_top(inner)
|
||||
else:
|
||||
prefix, leaves = stmt, [None]
|
||||
base = base_segments(prefix, current)
|
||||
if base is None:
|
||||
continue
|
||||
for leaf in leaves:
|
||||
segs = list(base)
|
||||
if leaf:
|
||||
first = leaf.strip().split("::")[0].split()[0]
|
||||
if first and first not in ("self", "*"):
|
||||
segs.append(first)
|
||||
target = resolve(tuple(segs), modules, current)
|
||||
if target is not None and target != current:
|
||||
found.add(target)
|
||||
return found
|
||||
|
||||
|
||||
def transitive_reduce(nodes, edges):
|
||||
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
||||
idx = {n: i for i, n in enumerate(nodes)}
|
||||
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
||||
try:
|
||||
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
||||
text=True, check=True).stdout
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
||||
return edges
|
||||
rev = {i: n for n, i in idx.items()}
|
||||
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Rust module-use graph -> autolayout graph JSON.")
|
||||
ap.add_argument("crate", help="crate directory (contains Cargo.toml and/or src/)")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="box modules by their parent module path (nested)")
|
||||
ap.add_argument("--no-reduce", action="store_true",
|
||||
help="keep every edge (skip transitive reduction)")
|
||||
args = ap.parse_args()
|
||||
|
||||
modules, _ = discover(args.crate)
|
||||
if not modules:
|
||||
sys.exit(f"error: no .rs modules found under {args.crate}")
|
||||
name = crate_name(args.crate)
|
||||
mid = lambda parts: name if not parts else "::".join(parts)
|
||||
edges = sorted({(mid(m), mid(t)) for m, path in modules.items()
|
||||
for t in edges_of(m, path, modules)})
|
||||
raw = len(edges)
|
||||
if not args.no_reduce:
|
||||
edges = transitive_reduce([mid(m) for m in modules], edges)
|
||||
|
||||
def node(parts):
|
||||
d = {"id": mid(parts), "label": name if not parts else parts[-1]}
|
||||
if args.group and len(parts) > 1:
|
||||
d["group"] = "/".join(parts[:-1]) # parent module path -> nested boxes
|
||||
return d
|
||||
|
||||
graph = {
|
||||
"direction": args.direction,
|
||||
"nodes": [node(m) for m in modules],
|
||||
"edges": [{"source": s, "target": t} for s, t in 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)
|
||||
note = "" if args.no_reduce else f" (reduced from {raw})"
|
||||
sys.stderr.write(f"{len(modules)} modules, {len(edges)} edges{note}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
222
skills/drawio-skill/scripts/seqlayout.py
Normal file
222
skills/drawio-skill/scripts/seqlayout.py
Normal file
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic sequence-diagram layout: message list JSON -> .drawio XML.
|
||||
|
||||
Sequence diagrams are the easiest type to get wrong by hand-placing
|
||||
coordinates (lifelines, activation bars and message arrows all share exact
|
||||
x/y math) and the least suited to Graphviz — but their geometry is pure
|
||||
arithmetic: participants split the x axis, messages advance the y axis.
|
||||
This script computes all of it, using the same official styles as
|
||||
references/diagram-types.md (umlLifeline shapes, block/open arrows).
|
||||
|
||||
python3 seqlayout.py seq.json -o diagram.drawio
|
||||
|
||||
Input JSON:
|
||||
{
|
||||
"title": "Login flow", # optional page name
|
||||
"participants": [
|
||||
{"id": "u", "label": "User", "actor": true},
|
||||
{"id": "s", "label": "Server"} # order = left-to-right order
|
||||
],
|
||||
"messages": [
|
||||
{"from": "u", "to": "s", "label": "POST /login"}, # sync (solid, filled arrow)
|
||||
{"from": "s", "to": "s", "label": "validate()"}, # self message
|
||||
{"from": "s", "to": "u", "label": "200 OK", "return": true}, # return (grey dashed)
|
||||
{"from": "u", "to": "s", "label": "notify", "async": true}, # async (dashed, open arrow)
|
||||
{"note": "token cached", "over": "s"} # note beside a lifeline
|
||||
]
|
||||
}
|
||||
|
||||
Activation bars are automatic: a sync/async message opens a bar on the
|
||||
target, a return message closes the sender's bar, and bars still open at the
|
||||
end run to the bottom. Override per message with "activate": false (don't
|
||||
open on target) or "deactivate": true (close the sender's bar after this
|
||||
message). Arrows attach to the bar edge when a bar is active, else to the
|
||||
lifeline. Fragments (alt/loop/opt frames) are out of scope — add them in
|
||||
draw.io afterwards.
|
||||
|
||||
Usage: python3 seqlayout.py <seq.json> [-o diagram.drawio]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
LIFELINE_W, HEADER_H = 100, 40
|
||||
BAR_W = 10
|
||||
TOP, ROW, SELF_ROW, NOTE_ROW, BOTTOM_PAD = 40, 50, 70, 60, 40
|
||||
MIN_SPACING = 200
|
||||
|
||||
LIFELINE = ("shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;"
|
||||
"container=1;dropTarget=0;collapsible=0;recursiveResize=0;outlineConnect=0;"
|
||||
f"portConstraint=eastwest;size={HEADER_H};")
|
||||
# Actor lifelines render a stick figure in the header — anchor the name at
|
||||
# the header bottom (white-backed) so figure and label don't overlap.
|
||||
ACTOR = LIFELINE + ("participant=umlActor;verticalAlign=bottom;"
|
||||
"spacingBottom=-14;labelBackgroundColor=#ffffff;")
|
||||
BAR = "html=1;points=[];perimeter=orthogonalPerimeter;outlineConnect=0;fillColor=#ffffff;"
|
||||
NOTE = ("shape=note;whiteSpace=wrap;html=1;size=14;fillColor=#fff2cc;"
|
||||
"strokeColor=#d6b656;")
|
||||
SYNC = "html=1;verticalAlign=bottom;endArrow=block;curved=0;rounded=0;"
|
||||
ASYNC = "html=1;verticalAlign=bottom;endArrow=open;dashed=1;curved=0;rounded=0;"
|
||||
RETURN = ("html=1;verticalAlign=bottom;endArrow=open;dashed=1;curved=0;rounded=0;"
|
||||
"strokeColor=#999999;fontColor=#999999;")
|
||||
|
||||
|
||||
def attr(value):
|
||||
return escape(str(value), {'"': """, "\n": "
"})
|
||||
|
||||
|
||||
def frac(y, top, height):
|
||||
return round(max(0.0, min(1.0, (y - top) / height)), 4)
|
||||
|
||||
|
||||
def layout(spec):
|
||||
parts = spec["participants"]
|
||||
if not parts:
|
||||
sys.exit("error: no participants")
|
||||
order = {p["id"]: i for i, p in enumerate(parts)}
|
||||
if len(order) != len(parts):
|
||||
sys.exit("error: duplicate participant ids")
|
||||
|
||||
# x axis: uniform spacing, widened if any label needs it (~7px/char).
|
||||
spacing = max([MIN_SPACING] + [7 * len(str(p.get("label", p["id"]))) + 80 for p in parts])
|
||||
spacing = -(-spacing // 10) * 10 # snap up to the grid
|
||||
cx = {p["id"]: TOP + i * spacing + LIFELINE_W // 2 for i, p in enumerate(parts)}
|
||||
|
||||
# y axis: walk the messages once, assigning each row a y position and
|
||||
# tracking one open activation bar per participant ({pid: start_y}).
|
||||
y = TOP + HEADER_H + 50
|
||||
rows, open_bar, bars = [], {}, [] # bars: (pid, y0, y1)
|
||||
|
||||
def close(pid, at):
|
||||
if pid in open_bar:
|
||||
bars.append((pid, open_bar.pop(pid), at))
|
||||
|
||||
for i, m in enumerate(spec.get("messages", [])):
|
||||
if "note" in m:
|
||||
rows.append(("note", m, y))
|
||||
y += NOTE_ROW
|
||||
continue
|
||||
src, dst = m["from"], m["to"]
|
||||
if src not in order or dst not in order:
|
||||
sys.exit(f"error: message {i} references unknown participant")
|
||||
is_return = m.get("return", False)
|
||||
if src == dst:
|
||||
rows.append(("self", m, y))
|
||||
y += SELF_ROW
|
||||
continue
|
||||
rows.append(("msg", m, y))
|
||||
if is_return:
|
||||
close(src, y) # returning ends the caller's work
|
||||
elif m.get("activate", True) and dst not in open_bar:
|
||||
open_bar[dst] = y # call starts work on the target
|
||||
if m.get("deactivate"):
|
||||
close(src, y)
|
||||
y += ROW
|
||||
height = y + BOTTOM_PAD - TOP
|
||||
for pid in list(open_bar):
|
||||
close(pid, TOP + height - BOTTOM_PAD)
|
||||
|
||||
cells = []
|
||||
for p in parts:
|
||||
style = ACTOR if p.get("actor") else LIFELINE
|
||||
cells.append(
|
||||
f' <mxCell id="{attr(p["id"])}" value="{attr(p.get("label", p["id"]))}" '
|
||||
f'style="{style}" vertex="1" parent="1">\n'
|
||||
f' <mxGeometry x="{cx[p["id"]] - LIFELINE_W // 2}" y="{TOP}" '
|
||||
f'width="{LIFELINE_W}" height="{height}" as="geometry"/>\n'
|
||||
" </mxCell>")
|
||||
|
||||
# Activation bars: children of their lifeline (coordinates relative to it).
|
||||
bar_of = {} # pid -> list of (y0, y1, cell_id)
|
||||
for n, (pid, y0, y1) in enumerate(bars):
|
||||
bid = f"act{n}"
|
||||
bar_of.setdefault(pid, []).append((y0, y1, bid))
|
||||
cells.append(
|
||||
f' <mxCell id="{bid}" value="" style="{BAR}" vertex="1" '
|
||||
f'parent="{attr(pid)}">\n'
|
||||
f' <mxGeometry x="{LIFELINE_W // 2 - BAR_W // 2}" y="{y0 - TOP}" '
|
||||
f'width="{BAR_W}" height="{y1 - y0}" as="geometry"/>\n'
|
||||
" </mxCell>")
|
||||
|
||||
def anchor(pid, my, side):
|
||||
"""(cell_id, exitX-style fragment values) for a message endpoint: the
|
||||
activation bar's edge when one is active at this y, else the lifeline."""
|
||||
for y0, y1, bid in bar_of.get(pid, []):
|
||||
if y0 <= my <= y1:
|
||||
return bid, (1 if side == "right" else 0), frac(my, y0, y1 - y0)
|
||||
return pid, 0.5, frac(my, TOP, height)
|
||||
|
||||
for i, (kind, m, my) in enumerate(rows):
|
||||
if kind == "note":
|
||||
pid = m.get("over")
|
||||
if pid not in cx:
|
||||
sys.exit(f"error: note {i} is over unknown participant {pid!r}")
|
||||
w = max(120, 7 * len(str(m["note"])) + 30)
|
||||
cells.append(
|
||||
f' <mxCell id="note{i}" value="{attr(m["note"])}" style="{NOTE}" '
|
||||
f'vertex="1" parent="1">\n'
|
||||
f' <mxGeometry x="{cx[pid] + 20}" y="{my - 20}" '
|
||||
f'width="{min(w, spacing - 60)}" height="40" as="geometry"/>\n'
|
||||
" </mxCell>")
|
||||
continue
|
||||
src, dst = m["from"], m["to"]
|
||||
style = RETURN if m.get("return") else ASYNC if m.get("async") else SYNC
|
||||
if kind == "self":
|
||||
sid, sx, sy = anchor(src, my, "right")
|
||||
_, tx, ty = anchor(src, my + 30, "right")
|
||||
loop_x = cx[src] + 60
|
||||
cells.append(
|
||||
f' <mxCell id="m{i}" value="{attr(m.get("label", ""))}" '
|
||||
f'style="{style}exitX={sx};exitY={sy};entryX={tx};entryY={ty};" '
|
||||
f'edge="1" parent="1" source="{attr(sid)}" target="{attr(sid)}">\n'
|
||||
f' <mxGeometry relative="1" as="geometry">\n'
|
||||
f' <Array as="points">'
|
||||
f'<mxPoint x="{loop_x}" y="{my}"/><mxPoint x="{loop_x}" y="{my + 30}"/>'
|
||||
f'</Array>\n'
|
||||
" </mxGeometry>\n"
|
||||
" </mxCell>")
|
||||
continue
|
||||
rightward = order[src] < order[dst]
|
||||
sid, sx, sy = anchor(src, my, "right" if rightward else "left")
|
||||
tid, tx, ty = anchor(dst, my, "left" if rightward else "right")
|
||||
cells.append(
|
||||
f' <mxCell id="m{i}" value="{attr(m.get("label", ""))}" '
|
||||
f'style="{style}exitX={sx};exitY={sy};entryX={tx};entryY={ty};" '
|
||||
f'edge="1" parent="1" source="{attr(sid)}" target="{attr(tid)}">\n'
|
||||
f' <mxGeometry relative="1" as="geometry"/>\n'
|
||||
" </mxCell>")
|
||||
|
||||
name = attr(spec.get("title", "Sequence"))
|
||||
return (
|
||||
f'<mxfile>\n <diagram id="seqlayout" name="{name}">\n'
|
||||
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" '
|
||||
'tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" '
|
||||
'pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
|
||||
" <root>\n"
|
||||
' <mxCell id="0"/>\n'
|
||||
' <mxCell id="1" parent="0"/>\n'
|
||||
+ "\n".join(cells)
|
||||
+ "\n </root>\n </mxGraphModel>\n </diagram>\n</mxfile>\n"
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Sequence-diagram JSON -> draw.io XML.")
|
||||
ap.add_argument("input", help="sequence JSON file")
|
||||
ap.add_argument("-o", "--output", help="output .drawio path (default: stdout)")
|
||||
args = ap.parse_args()
|
||||
with open(args.input, encoding="utf-8") as f:
|
||||
spec = json.load(f)
|
||||
xml = layout(spec)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
print(f"wrote {args.output} ({len(spec['participants'])} participants, "
|
||||
f"{len(spec.get('messages', []))} messages)", file=sys.stderr)
|
||||
else:
|
||||
sys.stdout.write(xml)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
162
skills/drawio-skill/scripts/shapesearch.py
Normal file
162
skills/drawio-skill/scripts/shapesearch.py
Normal file
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Search 10k+ official draw.io shapes for their exact style strings.
|
||||
|
||||
Resolves a keyword query (e.g. "aws lambda", "uml actor", "k8s pod") to the
|
||||
matching palette shapes so a diagram can use the real draw.io `style=` string
|
||||
instead of a hand-guessed one. Covers AWS / Azure / GCP / Cisco / Kubernetes /
|
||||
UML / BPMN / P&ID / electrical / flowchart / network / general shape sets.
|
||||
|
||||
Based on the search in jgraph/drawio-mcp (Apache-2.0): tag map with exact +
|
||||
Soundex matching, strict AND first, scored OR fallback. The matched set is
|
||||
identical to upstream; the one addition is a tiebreaker that, among shapes with
|
||||
the same tag score, prefers ones whose title contains the query terms verbatim
|
||||
(so "dynamodb" returns the shape titled "DynamoDB", not a neighbor merely tagged
|
||||
with it). The bundled index (data/shape-index.json.gz) is the upstream draw.io
|
||||
shape data — see data/SHAPE-INDEX-NOTICE.md.
|
||||
|
||||
Usage:
|
||||
python3 shapesearch.py "aws lambda" [--limit N] [--json]
|
||||
"""
|
||||
import argparse
|
||||
import gzip
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
INDEX = os.path.join(os.path.dirname(__file__), "..", "data", "shape-index.json.gz")
|
||||
_SOUNDEX_MAP = "01230120022455012603010202" # A..Z digit codes
|
||||
_TRAIL = re.compile(r"\.*\d*$") # strip trailing digits/dots before soundex
|
||||
|
||||
|
||||
def soundex(name):
|
||||
if not name:
|
||||
return ""
|
||||
s = [name[0].upper()]
|
||||
si = 1
|
||||
for ch in name[1:]:
|
||||
c = ord(ch.upper()) - 65
|
||||
if 0 <= c <= 25 and _SOUNDEX_MAP[c] != "0":
|
||||
code = _SOUNDEX_MAP[c]
|
||||
if code != s[si - 1]:
|
||||
s.append(code)
|
||||
si += 1
|
||||
if si > 3:
|
||||
break
|
||||
s += ["0"] * (4 - len(s))
|
||||
return "".join(s[:4])
|
||||
|
||||
|
||||
def build_tag_map(shapes):
|
||||
"""tag (and its Soundex) -> set of shape indices."""
|
||||
tag_map = {}
|
||||
for i, shape in enumerate(shapes):
|
||||
raw = shape.get("tags")
|
||||
if not raw:
|
||||
continue
|
||||
seen = set()
|
||||
for token in re.sub(r"[/,()]", " ", raw.lower()).split(" "):
|
||||
if len(token) < 2 or token in seen:
|
||||
continue
|
||||
seen.add(token)
|
||||
tag_map.setdefault(token, set()).add(i)
|
||||
sx = soundex(_TRAIL.sub("", token))
|
||||
if sx and sx != token and sx not in seen:
|
||||
seen.add(sx)
|
||||
tag_map.setdefault(sx, set()).add(i)
|
||||
return tag_map
|
||||
|
||||
|
||||
def split_compound(token):
|
||||
"""'pid2misc' -> ['pid','misc']; 'discInst' -> ['disc','inst']."""
|
||||
spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", token)
|
||||
spaced = re.sub(r"([a-zA-Z])(\d)", r"\1 \2", spaced)
|
||||
spaced = re.sub(r"(\d)([a-zA-Z])", r"\1 \2", spaced)
|
||||
return [p for p in spaced.lower().split() if len(p) >= 2]
|
||||
|
||||
|
||||
def match_term(tag_map, term):
|
||||
exact = set(tag_map.get(term, set()))
|
||||
phonetic = set()
|
||||
sx = soundex(_TRAIL.sub("", term))
|
||||
if sx and sx != term:
|
||||
phonetic = {i for i in tag_map.get(sx, set()) if i not in exact}
|
||||
return exact, phonetic
|
||||
|
||||
|
||||
def search(shapes, tag_map, query, limit):
|
||||
if not query:
|
||||
return []
|
||||
terms, seen = [], set()
|
||||
for raw in query.lower().split():
|
||||
subs = split_compound(raw) or ([raw] if len(raw) >= 2 else [])
|
||||
for t in subs:
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
terms.append(t)
|
||||
if not terms:
|
||||
return []
|
||||
|
||||
term_matches = [match_term(tag_map, t) for t in terms]
|
||||
|
||||
# Strict AND across all terms first.
|
||||
and_set = None
|
||||
for exact, phonetic in term_matches:
|
||||
combined = exact | phonetic
|
||||
and_set = combined if and_set is None else (and_set & combined)
|
||||
if not and_set:
|
||||
break
|
||||
|
||||
# Score: +1.0 exact, +0.5 Soundex-only, per term. AND results if any, else OR.
|
||||
scores = {}
|
||||
pool = and_set if and_set else None
|
||||
for exact, phonetic in term_matches:
|
||||
for idx in exact:
|
||||
if pool is None or idx in pool:
|
||||
scores[idx] = scores.get(idx, 0) + 1.0
|
||||
for idx in phonetic:
|
||||
if (pool is None or idx in pool) and idx not in exact:
|
||||
scores[idx] = scores.get(idx, 0) + 0.5
|
||||
|
||||
# Rank by tag score desc, then by how many query terms appear verbatim in the
|
||||
# title, then casefolded title, then index. The title-hit tiebreak (our one
|
||||
# addition over upstream) only reorders *within* an equal tag-score group, so
|
||||
# e.g. the shape literally titled "DynamoDB" ranks above a neighbor that is
|
||||
# merely tagged `dynamodb` (like "Attribute"). The trailing index keeps ties
|
||||
# deterministic.
|
||||
term_set = set(terms)
|
||||
|
||||
def title_hits(idx):
|
||||
toks = set(re.split(r"[^a-z0-9]+", shapes[idx].get("title", "").casefold()))
|
||||
return len(term_set & toks)
|
||||
|
||||
ranked = sorted(scores, key=lambda i: (-scores[i], -title_hits(i),
|
||||
shapes[i].get("title", "").casefold(), i))
|
||||
return [{"style": shapes[i]["style"], "w": shapes[i]["w"],
|
||||
"h": shapes[i]["h"], "title": shapes[i]["title"]} for i in ranked[:limit]]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Search official draw.io shapes for their style strings.")
|
||||
ap.add_argument("query", help='keywords, e.g. "aws lambda" or "uml actor"')
|
||||
ap.add_argument("--limit", type=int, default=10)
|
||||
ap.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.exists(INDEX):
|
||||
sys.exit(f"error: shape index not found at {INDEX}")
|
||||
with gzip.open(INDEX, "rt", encoding="utf-8") as f:
|
||||
shapes = json.load(f)
|
||||
|
||||
results = search(shapes, build_tag_map(shapes), args.query, args.limit)
|
||||
if not results:
|
||||
sys.exit(f"no shapes matched {args.query!r}")
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
for r in results:
|
||||
print(f"{r['title']} ({r['w']}x{r['h']})\n {r['style']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
167
skills/drawio-skill/scripts/sqlerd.py
Normal file
167
skills/drawio-skill/scripts/sqlerd.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract an ER diagram from SQL DDL as autolayout graph JSON.
|
||||
|
||||
Parses ``CREATE TABLE`` statements (regex + paren matching — no SQL library),
|
||||
one node per table listing its columns with PK/FK markers, and one
|
||||
crow's-foot edge per foreign key (many side at the referencing table).
|
||||
The output feeds autolayout.py:
|
||||
|
||||
python3 sqlerd.py schema.sql -o graph.json
|
||||
python3 autolayout.py graph.json -o erd.drawio
|
||||
|
||||
Understood per table: column name + type, inline ``PRIMARY KEY`` /
|
||||
``REFERENCES tab(col)``, table-level ``PRIMARY KEY (...)`` and
|
||||
``[CONSTRAINT x] FOREIGN KEY (col) REFERENCES tab(col)``. Quoted identifiers
|
||||
("t", `t`, [t]) and ``schema.table`` prefixes are normalized; edges land only
|
||||
on tables defined in the scanned files. Dialect-specific clauses beyond that
|
||||
(partitioning, generated columns, …) are simply ignored — worst case a column
|
||||
line is skipped, never a wrong edge.
|
||||
|
||||
Usage: python3 sqlerd.py <file.sql-or-dir> [-o graph.json]
|
||||
[--direction TB|LR] [--group] [--no-types]
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
TABLE_STYLE = ("rounded=0;whiteSpace=wrap;html=1;align=left;verticalAlign=top;"
|
||||
"spacingLeft=6;spacingTop=4;fillColor=#dae8fc;strokeColor=#6c8ebf;")
|
||||
# orthogonalEdgeStyle (not entityRelationEdgeStyle) so the edge honours the
|
||||
# obstacle-avoiding waypoints dot computed; ER arrows give the crow's foot.
|
||||
ER_EDGE = ("edgeStyle=orthogonalEdgeStyle;html=1;rounded=0;fontSize=11;"
|
||||
"labelBackgroundColor=#ffffff;"
|
||||
"startArrow=ERmany;startFill=0;endArrow=ERone;endFill=0;")
|
||||
|
||||
_COMMENT = re.compile(r"/\*.*?\*/|--[^\n]*", re.S)
|
||||
_CREATE = re.compile(r"\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w.\"`\[\]]+)\s*\(",
|
||||
re.I)
|
||||
_FK = re.compile(r"FOREIGN\s+KEY\s*\(([^)]+)\)\s*REFERENCES\s+([\w.\"`\[\]]+)\s*(?:\(([^)]+)\))?",
|
||||
re.I)
|
||||
_PK = re.compile(r"PRIMARY\s+KEY\s*\(([^)]+)\)", re.I)
|
||||
_INLINE_REF = re.compile(r"\bREFERENCES\s+([\w.\"`\[\]]+)", re.I)
|
||||
_SKIP = re.compile(r"^\s*(CONSTRAINT|UNIQUE|CHECK|KEY|INDEX|FULLTEXT|SPATIAL|EXCLUDE|LIKE)\b",
|
||||
re.I)
|
||||
|
||||
|
||||
def ident(raw):
|
||||
"""Normalize an identifier: strip quoting, keep the last dotted part."""
|
||||
name = raw.strip().strip('"`[]').split(".")[-1].strip('"`[]')
|
||||
return name.lower()
|
||||
|
||||
|
||||
def split_columns(body):
|
||||
"""Split a CREATE TABLE body on top-level commas."""
|
||||
items, depth, cur = [], 0, []
|
||||
for ch in body:
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
items.append("".join(cur).strip())
|
||||
cur = []
|
||||
else:
|
||||
cur.append(ch)
|
||||
if "".join(cur).strip():
|
||||
items.append("".join(cur).strip())
|
||||
return items
|
||||
|
||||
|
||||
def parse_tables(text):
|
||||
"""{table: {"schema", "columns": [(name, type)], "pks": set, "fks": [(col, table)]}}"""
|
||||
text = _COMMENT.sub("", text)
|
||||
tables = {}
|
||||
for m in _CREATE.finditer(text):
|
||||
raw_name = m.group(1)
|
||||
depth, i = 1, m.end()
|
||||
while i < len(text) and depth:
|
||||
if text[i] == "(":
|
||||
depth += 1
|
||||
elif text[i] == ")":
|
||||
depth -= 1
|
||||
i += 1
|
||||
body = text[m.end():i - 1]
|
||||
name = ident(raw_name)
|
||||
parts = raw_name.strip().strip('"`[]').split(".")
|
||||
schema = ident(parts[-2]) if len(parts) > 1 else ""
|
||||
cols, pks, fks = [], set(), []
|
||||
for item in split_columns(body):
|
||||
fk = _FK.search(item)
|
||||
if fk:
|
||||
for col in fk.group(1).split(","):
|
||||
fks.append((ident(col), ident(fk.group(2))))
|
||||
continue
|
||||
pk = _PK.search(item)
|
||||
if pk and _SKIP.match(item) is None and item.upper().lstrip().startswith("PRIMARY"):
|
||||
pks.update(ident(c) for c in pk.group(1).split(","))
|
||||
continue
|
||||
if _SKIP.match(item):
|
||||
continue
|
||||
toks = item.split()
|
||||
if len(toks) < 2:
|
||||
continue
|
||||
col, ctype = ident(toks[0]), toks[1].rstrip(",")
|
||||
cols.append((col, ctype))
|
||||
if re.search(r"\bPRIMARY\s+KEY\b", item, re.I):
|
||||
pks.add(col)
|
||||
ref = _INLINE_REF.search(item)
|
||||
if ref:
|
||||
fks.append((col, ident(ref.group(1))))
|
||||
tables[name] = {"schema": schema, "columns": cols, "pks": pks, "fks": fks}
|
||||
return tables
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="SQL DDL -> ER diagram graph JSON.")
|
||||
ap.add_argument("path", help=".sql file or directory containing .sql files")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true", help="group tables by schema")
|
||||
ap.add_argument("--no-types", action="store_true",
|
||||
help="list column names only (hide the SQL types)")
|
||||
args = ap.parse_args()
|
||||
|
||||
files = ([args.path] if os.path.isfile(args.path)
|
||||
else sorted(glob.glob(os.path.join(args.path, "**", "*.sql"), recursive=True)))
|
||||
tables = {}
|
||||
for path in files:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
tables.update(parse_tables(f.read()))
|
||||
if not tables:
|
||||
sys.exit(f"error: no CREATE TABLE statements found under {args.path}")
|
||||
|
||||
nodes, edges = [], []
|
||||
for name, t in tables.items():
|
||||
fk_cols = {c for c, _ in t["fks"]}
|
||||
lines = [name]
|
||||
for col, ctype in t["columns"]:
|
||||
mark = "PK " if col in t["pks"] else "FK " if col in fk_cols else ""
|
||||
lines.append(f"{mark}{col}" + ("" if args.no_types else f": {ctype}"))
|
||||
width = max(160, -(-max(7 * len(l) + 30 for l in lines) // 10) * 10)
|
||||
height = -(-(30 + 20 * len(t["columns"])) // 10) * 10
|
||||
node = {"id": name, "label": "\n".join(lines), "style": TABLE_STYLE,
|
||||
"width": width, "height": height}
|
||||
if args.group and t["schema"]:
|
||||
node["group"] = t["schema"]
|
||||
nodes.append(node)
|
||||
for col, ref in t["fks"]:
|
||||
if ref in tables and ref != name:
|
||||
edges.append({"source": name, "target": ref, "label": col,
|
||||
"style": ER_EDGE})
|
||||
|
||||
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"{len(nodes)} tables, {len(edges)} foreign keys\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
85
skills/drawio-skill/scripts/svgflow.py
Normal file
85
skills/drawio-skill/scripts/svgflow.py
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Make a diagram's edges *flow* — an animated data-flow SVG.
|
||||
|
||||
Exports a .drawio to SVG (or takes an .svg directly) and turns every edge into a
|
||||
marching-ants animation: dashes travel along each connector in the direction of
|
||||
the arrow, so the diagram shows data/flow moving through it. The result is a
|
||||
single self-contained .svg that loops forever in any browser — nice for a README
|
||||
(GitHub renders SVG), a docs page, or a slide background.
|
||||
|
||||
python3 svgflow.py architecture.drawio -o architecture-flow.svg
|
||||
python3 svgflow.py already-exported.svg -o flow.svg
|
||||
|
||||
Edges are found by draw.io's own marker: connector *lines* carry
|
||||
`pointer-events="stroke"` (shape outlines and arrowheads use `="all"`), so only
|
||||
the real edges animate — arrowheads and shapes stay put. `--speed` sets seconds
|
||||
per cycle, `--dash` the dash pattern, `--reverse` flips the flow direction.
|
||||
|
||||
Usage: python3 svgflow.py <file.drawio|file.svg> [-o out.svg]
|
||||
[--speed SEC] [--dash "6 4"] [--reverse]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
EDGE_PATH = re.compile(r'(<path )((?:(?!/?>)[^>])*pointer-events="stroke"(?:(?!/?>)[^>])*/?>)')
|
||||
|
||||
|
||||
def to_svg(path):
|
||||
"""Return SVG text for a .drawio (export via CLI) or .svg (read directly)."""
|
||||
if path.lower().endswith(".svg"):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
out = os.path.join(tmp, "d.svg")
|
||||
r = subprocess.run(["drawio", "-x", "-f", "svg", "--embed-svg-images", "-o", out, path],
|
||||
capture_output=True)
|
||||
if r.returncode != 0 or not os.path.exists(out):
|
||||
sys.exit("error: draw.io SVG export failed (is the draw.io CLI installed?)")
|
||||
with open(out, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def animate(svg, speed, dash, reverse):
|
||||
"""Tag edge paths and inject the flow keyframes. Returns (svg, edge_count)."""
|
||||
svg, n = EDGE_PATH.subn(r'\1class="dio-flow" \2', svg)
|
||||
# One dash+gap of travel per cycle => seamless loop. Reverse flips the sign.
|
||||
period = sum(float(x) for x in dash.split()) or 10
|
||||
offset = period if reverse else -period
|
||||
style = (f"<style>.dio-flow{{stroke-dasharray:{dash};"
|
||||
f"animation:dio-flow {speed}s linear infinite;}}"
|
||||
f"@keyframes dio-flow{{to{{stroke-dashoffset:{offset:g};}}}}</style>")
|
||||
svg = re.sub(r"(<svg\b[^>]*>)", r"\1" + style, svg, count=1)
|
||||
return svg, n
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Animate a diagram's edges into a flowing SVG.")
|
||||
ap.add_argument("file", help=".drawio (exported to SVG) or an .svg")
|
||||
ap.add_argument("-o", "--output", help="output .svg (default: <name>-flow.svg)")
|
||||
ap.add_argument("--speed", type=float, default=1.2, help="seconds per flow cycle (default 1.2)")
|
||||
ap.add_argument("--dash", default="6 4", help='dash pattern, e.g. "6 4" (default)')
|
||||
ap.add_argument("--reverse", action="store_true", help="flow toward the source")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(args.file):
|
||||
sys.exit(f"error: {args.file} not found")
|
||||
svg = to_svg(args.file)
|
||||
if "<svg" not in svg:
|
||||
sys.exit("error: no <svg> element found in the exported output")
|
||||
svg, n = animate(svg, args.speed, args.dash, args.reverse)
|
||||
if n == 0:
|
||||
sys.stderr.write("warning: no edges found to animate "
|
||||
"(a diagram with no connectors?)\n")
|
||||
|
||||
out = args.output or os.path.splitext(args.file)[0] + "-flow.svg"
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
f.write(svg)
|
||||
sys.stderr.write(f"wrote {out} ({n} edge{'s' if n != 1 else ''} animated)\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
262
skills/drawio-skill/scripts/tfimports.py
Normal file
262
skills/drawio-skill/scripts/tfimports.py
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a Terraform configuration's resource graph as autolayout graph JSON.
|
||||
|
||||
Parses ``.tf`` files with a small regex + brace-matching pass (no HCL library
|
||||
needed), builds resource-reference edges (``aws_iam_role.lambda.arn`` inside
|
||||
another resource's body -> edge), and resolves each resource type to its
|
||||
official draw.io cloud icon via the bundled shape index — AWS (aws4 set),
|
||||
Azure (azure2 set) and GCP (Google Cloud icon set). The output feeds
|
||||
autolayout.py:
|
||||
|
||||
python3 tfimports.py ./infra -o graph.json
|
||||
python3 autolayout.py graph.json -o infra.drawio
|
||||
|
||||
Nodes are the ``resource`` and ``module`` blocks declared in the scanned files;
|
||||
data sources, variables, locals and providers are ignored. A reference is any
|
||||
``type.name`` / ``module.name`` token in a resource body that matches a
|
||||
declared node — attribute chains (``aws_s3_bucket.logs.arn``), ``"${...}"``
|
||||
interpolations and ``depends_on`` entries all count. Transitive reduction
|
||||
(Graphviz ``tred``) keeps big graphs readable; ``--no-reduce`` keeps every
|
||||
edge. Heredoc bodies with unbalanced braces are the one known parse limit.
|
||||
|
||||
Usage: python3 tfimports.py <dir-or-file.tf> [-o graph.json]
|
||||
[--direction TB|LR] [--group] [--no-reduce] [--no-icons]
|
||||
"""
|
||||
import argparse
|
||||
import glob
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# provider prefix of the resource type -> (icon query prefix, style predicate).
|
||||
# The predicate pins results to the modern shape set for that cloud — a bare
|
||||
# keyword search happily returns another vendor's icon (e.g. "kubernetes
|
||||
# deployment" -> Azure Arc), so set filtering is what makes resolution safe.
|
||||
PROVIDERS = {
|
||||
"aws": ("aws", lambda st: "mxgraph.aws4" in st),
|
||||
"azurerm": ("azure", lambda st: "img/lib/azure2" in st),
|
||||
"azuread": ("azure", lambda st: "img/lib/azure2" in st),
|
||||
"google": ("gcp", lambda st: "editableCssRules" in st),
|
||||
}
|
||||
|
||||
# Resource types whose derived query ("aws lambda function") misses or mis-hits
|
||||
# the intended icon; values are the query that finds it. Keep alphabetical.
|
||||
QUERY_OVERRIDES = {
|
||||
"aws_alb": "aws elastic load balancing",
|
||||
"aws_apigatewayv2_api": "aws api gateway",
|
||||
"aws_autoscaling_group": "aws ec2 auto scaling",
|
||||
"aws_cloudwatch_log_group": "aws cloudwatch",
|
||||
"aws_db_instance": "aws rds",
|
||||
"aws_dynamodb_table": "aws dynamodb",
|
||||
"aws_ecr_repository": "aws elastic container registry",
|
||||
"aws_ecs_cluster": "aws elastic container service",
|
||||
"aws_ecs_service": "aws elastic container service",
|
||||
"aws_ecs_task_definition": "aws elastic container service",
|
||||
"aws_efs_file_system": "aws elastic file system",
|
||||
"aws_eks_cluster": "aws elastic kubernetes service",
|
||||
"aws_elasticache_cluster": "aws elasticache",
|
||||
"aws_iam_policy": "aws identity and access management",
|
||||
"aws_instance": "aws ec2",
|
||||
"aws_kms_key": "aws key management service",
|
||||
"aws_lambda_function": "aws lambda",
|
||||
"aws_lb": "aws elastic load balancing",
|
||||
"aws_rds_cluster": "aws aurora",
|
||||
"aws_s3_bucket": "aws simple storage service",
|
||||
"aws_secretsmanager_secret": "aws secrets manager",
|
||||
"aws_sfn_state_machine": "aws step functions",
|
||||
"aws_sns_topic": "aws simple notification service",
|
||||
"aws_sqs_queue": "aws simple queue service",
|
||||
"azurerm_app_service": "azure app services",
|
||||
"azurerm_application_gateway": "azure application gateways",
|
||||
"azurerm_cosmosdb_account": "azure cosmos db",
|
||||
"azurerm_kubernetes_cluster": "azure kubernetes services",
|
||||
"azurerm_linux_function_app": "azure function apps",
|
||||
"azurerm_linux_virtual_machine": "azure virtual machine",
|
||||
"azurerm_linux_web_app": "azure app services",
|
||||
"azurerm_mssql_database": "azure sql database",
|
||||
"azurerm_mssql_server": "azure sql database",
|
||||
"azurerm_servicebus_namespace": "azure service bus",
|
||||
"azurerm_storage_account": "azure storage accounts",
|
||||
"azurerm_virtual_network": "azure virtual networks",
|
||||
"azurerm_windows_function_app": "azure function apps",
|
||||
"azurerm_windows_virtual_machine": "azure virtual machine",
|
||||
"azurerm_windows_web_app": "azure app services",
|
||||
"google_cloudfunctions2_function": "gcp cloud functions",
|
||||
"google_cloudfunctions_function": "gcp cloud functions",
|
||||
"google_compute_instance": "gcp compute engine",
|
||||
"google_container_cluster": "gcp kubernetes engine",
|
||||
"google_redis_instance": "gcp memorystore",
|
||||
"google_sql_database_instance": "gcp cloud sql",
|
||||
"google_storage_bucket": "gcp cloud storage",
|
||||
}
|
||||
|
||||
_COMMENT = re.compile(r"/\*.*?\*/|(?:#|//)[^\n]*", re.S)
|
||||
_BLOCK = re.compile(r'^[ \t]*(resource|module)[ \t]+"([\w.-]+)"(?:[ \t]+"([\w.-]+)")?[ \t]*\{', re.M)
|
||||
_REF = re.compile(r"\b([a-z][a-z0-9_]*\.[A-Za-z_][A-Za-z0-9_-]*)")
|
||||
|
||||
|
||||
def parse_blocks(text):
|
||||
"""Yield (kind, label1, label2, body) for resource/module blocks."""
|
||||
text = _COMMENT.sub("", text)
|
||||
for m in _BLOCK.finditer(text):
|
||||
depth, i = 1, m.end()
|
||||
while i < len(text) and depth:
|
||||
if text[i] == "{":
|
||||
depth += 1
|
||||
elif text[i] == "}":
|
||||
depth -= 1
|
||||
i += 1
|
||||
yield m.group(1), m.group(2), m.group(3), text[m.end():i - 1]
|
||||
|
||||
|
||||
def load_shapesearch():
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shapesearch.py")
|
||||
spec = importlib.util.spec_from_file_location("shapesearch", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
class IconResolver:
|
||||
"""Resolve a Terraform resource type to an official draw.io icon style."""
|
||||
|
||||
def __init__(self):
|
||||
import gzip
|
||||
self.ss = load_shapesearch()
|
||||
with gzip.open(self.ss.INDEX, "rt", encoding="utf-8") as f:
|
||||
self.shapes = json.load(f)
|
||||
self.tag_map = self.ss.build_tag_map(self.shapes)
|
||||
self.cache = {}
|
||||
|
||||
def _and_styles(self, words):
|
||||
"""Style strings of shapes whose tags match EVERY query word.
|
||||
shapesearch.search() falls back to scored OR matching when the AND set
|
||||
is empty — fine interactively, but here a partial match means a visibly
|
||||
wrong icon, so results outside this set are rejected and the caller's
|
||||
back-off handles the miss (a plain box beats a wrong icon)."""
|
||||
idxs = None
|
||||
for t in words:
|
||||
exact, phonetic = self.ss.match_term(self.tag_map, t)
|
||||
s = exact | phonetic
|
||||
idxs = s if idxs is None else idxs & s
|
||||
if not idxs:
|
||||
return set()
|
||||
return {self.shapes[i]["style"] for i in idxs}
|
||||
|
||||
def resolve(self, rtype):
|
||||
if rtype in self.cache:
|
||||
return self.cache[rtype]
|
||||
provider = rtype.split("_", 1)[0]
|
||||
hit = None
|
||||
if provider in PROVIDERS:
|
||||
prefix, want = PROVIDERS[provider]
|
||||
words = (QUERY_OVERRIDES.get(rtype) or
|
||||
f"{prefix} {rtype.split('_', 1)[1].replace('_', ' ')}").split()
|
||||
# Back off one trailing word at a time: "aws lambda event source
|
||||
# mapping" eventually matches on "aws lambda".
|
||||
while len(words) > 1 and hit is None:
|
||||
allowed = self._and_styles(words)
|
||||
good = [r for r in
|
||||
self.ss.search(self.shapes, self.tag_map, " ".join(words), 40)
|
||||
if r["style"] in allowed and want(r["style"])
|
||||
and "group" not in r["style"].lower()]
|
||||
# Prefer aws4 service icons (resIcon=) over scenario glyphs.
|
||||
hit = next((r for r in good if "resIcon=" in r["style"]), None) or \
|
||||
(good[0] if good else None)
|
||||
words = words[:-1]
|
||||
if hit and max(hit["w"], hit["h"]) < 44:
|
||||
# Some sets (GCP) ship tiny nominal sizes; scale up so the icon
|
||||
# is not dwarfed by its label. aspect=fixed keeps the ratio.
|
||||
f = 48 / max(hit["w"], hit["h"])
|
||||
hit = dict(hit, w=round(hit["w"] * f), h=round(hit["h"] * f))
|
||||
self.cache[rtype] = hit
|
||||
return hit
|
||||
|
||||
|
||||
def transitive_reduce(nodes, edges):
|
||||
"""Drop edges implied by a longer path, via Graphviz `tred`."""
|
||||
idx = {n: i for i, n in enumerate(nodes)}
|
||||
dot = "digraph{" + "".join(f"{idx[s]}->{idx[t]};" for s, t in edges) + "}"
|
||||
try:
|
||||
out = subprocess.run(["tred"], input=dot, capture_output=True,
|
||||
text=True, check=True).stdout
|
||||
except (FileNotFoundError, subprocess.CalledProcessError) as exc:
|
||||
sys.stderr.write(f"warning: tred unavailable, keeping all edges ({exc})\n")
|
||||
return edges
|
||||
rev = {i: n for n, i in idx.items()}
|
||||
return [(rev[int(a)], rev[int(b)]) for a, b in re.findall(r"(\d+)\s*->\s*(\d+)", out)]
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Terraform resource graph -> autolayout graph JSON.")
|
||||
ap.add_argument("path", help=".tf file or directory containing .tf files")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="group resources into containers by service (aws_s3_* -> s3)")
|
||||
ap.add_argument("--no-reduce", action="store_true",
|
||||
help="keep every edge (skip transitive reduction)")
|
||||
ap.add_argument("--no-icons", action="store_true",
|
||||
help="plain boxes instead of official cloud icons")
|
||||
args = ap.parse_args()
|
||||
|
||||
files = ([args.path] if os.path.isfile(args.path)
|
||||
else sorted(glob.glob(os.path.join(args.path, "**", "*.tf"), recursive=True)))
|
||||
blocks = []
|
||||
for path in files:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
blocks.extend(parse_blocks(f.read()))
|
||||
if not blocks:
|
||||
sys.exit(f"error: no resource/module blocks found under {args.path}")
|
||||
|
||||
declared = {} # node id -> (rtype or None, name, body)
|
||||
for kind, l1, l2, body in blocks:
|
||||
nid = f"{l1}.{l2}" if kind == "resource" else f"module.{l1}"
|
||||
declared[nid] = (l1 if kind == "resource" else None, l2 or l1, body)
|
||||
|
||||
edges = sorted({(nid, ref) for nid, (_, _, body) in declared.items()
|
||||
for ref in _REF.findall(body) if ref in declared and ref != nid})
|
||||
raw = len(edges)
|
||||
if not args.no_reduce:
|
||||
edges = transitive_reduce(list(declared), edges)
|
||||
|
||||
resolver = None if args.no_icons else IconResolver()
|
||||
unmatched = []
|
||||
nodes = []
|
||||
for nid, (rtype, name, _) in declared.items():
|
||||
node = {"id": nid, "label": name}
|
||||
icon = resolver.resolve(rtype) if resolver and rtype else None
|
||||
if icon:
|
||||
node.update(style=icon["style"], width=icon["w"], height=icon["h"])
|
||||
else:
|
||||
# No icon: keep the type visible on the box (second line).
|
||||
node["label"] = f"{name}\n{rtype}" if rtype else f"module {name}"
|
||||
if rtype:
|
||||
unmatched.append(rtype)
|
||||
if args.group and rtype and "_" in rtype:
|
||||
node["group"] = rtype.split("_")[1]
|
||||
nodes.append(node)
|
||||
|
||||
graph = {"direction": args.direction, "nodes": nodes,
|
||||
"edges": [{"source": s, "target": t} for s, t in edges]}
|
||||
if resolver:
|
||||
# Icon labels render below the shape — reserve extra layout spacing.
|
||||
graph.update(ranksep=0.7, nodesep=0.6)
|
||||
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)
|
||||
note = "" if args.no_reduce else f" (reduced from {raw})"
|
||||
sys.stderr.write(f"{len(nodes)} nodes, {len(edges)} edges{note}\n")
|
||||
if unmatched:
|
||||
sys.stderr.write("no icon for: " + ", ".join(sorted(set(unmatched))) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
132
skills/drawio-skill/scripts/tfstate.py
Normal file
132
skills/drawio-skill/scripts/tfstate.py
Normal file
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draw the cloud resources ACTUALLY deployed, from `terraform show -json`.
|
||||
|
||||
Where tfimports.py reads the *declared* config (`.tf` files), this reads the
|
||||
*real* state: what Terraform recorded as provisioned. It is provider-agnostic
|
||||
(the JSON is uniform across AWS / Azure / GCP), expands `count`/`for_each` into
|
||||
their real instances, keeps module nesting, and reuses tfimports' icon resolver
|
||||
so every resource shows its official cloud icon. The output feeds autolayout.py:
|
||||
|
||||
terraform show -json | python3 tfstate.py - -o graph.json
|
||||
python3 autolayout.py graph.json -o deployed.drawio
|
||||
|
||||
Input is the JSON `terraform show -json` prints — from live state (no argument)
|
||||
or a saved plan (`terraform show -json plan.tfplan`) — as a file path or `-` for
|
||||
stdin. Nodes are the managed resource instances (data sources are ignored);
|
||||
edges come from the dependencies Terraform recorded in state (`depends_on`).
|
||||
`--group` boxes resources by their module; `--no-icons` forces plain boxes.
|
||||
|
||||
Usage: terraform show -json | python3 tfstate.py - [-o graph.json]
|
||||
[--direction TB|LR] [--group] [--no-reduce] [--no-icons]
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def load_tfimports():
|
||||
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "tfimports.py")
|
||||
spec = importlib.util.spec_from_file_location("tfimports", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def walk_module(mod, out):
|
||||
"""Collect (address, type, name, index, module_path, depends_on) for every
|
||||
managed resource, recursing into child modules."""
|
||||
addr = mod.get("address", "") # "" for root, else module.x[...]
|
||||
for r in mod.get("resources") or []:
|
||||
if r.get("mode") == "data":
|
||||
continue
|
||||
out.append((r.get("address"), r.get("type"), r.get("name"),
|
||||
r.get("index"), addr, r.get("depends_on") or []))
|
||||
for child in mod.get("child_modules") or []:
|
||||
walk_module(child, out)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="`terraform show -json` -> autolayout graph JSON.")
|
||||
ap.add_argument("input", help="`terraform show -json` output file, or - for stdin")
|
||||
ap.add_argument("-o", "--output", help="output JSON path (default: stdout)")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("--group", action="store_true",
|
||||
help="group resources into containers by module")
|
||||
ap.add_argument("--no-reduce", action="store_true",
|
||||
help="keep every edge (skip transitive reduction)")
|
||||
ap.add_argument("--no-icons", action="store_true",
|
||||
help="plain boxes instead of official cloud icons")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.input == "-":
|
||||
text = sys.stdin.read()
|
||||
else:
|
||||
with open(args.input, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.exit(f"error: input is not valid JSON ({exc}) — feed `terraform show -json`")
|
||||
# State: top-level "values"; saved plan: "planned_values".
|
||||
root = ((data.get("values") or data.get("planned_values") or {}).get("root_module")) or {}
|
||||
resources = []
|
||||
walk_module(root, resources)
|
||||
if not resources:
|
||||
sys.exit("error: no managed resources found in the Terraform state/plan")
|
||||
|
||||
addresses = {r[0] for r in resources}
|
||||
|
||||
def targets(dep):
|
||||
"""Instance addresses a depends_on entry names. State records the
|
||||
un-indexed address (`aws_subnet.this`) for a resource with several
|
||||
instances (`aws_subnet.this[0]`), so expand by prefix too."""
|
||||
if dep in addresses:
|
||||
return {dep}
|
||||
return {a for a in addresses if a.startswith(dep + "[")}
|
||||
|
||||
edges = sorted({(addr, t) for addr, _, _, _, _, deps in resources
|
||||
for dep in deps for t in targets(dep) if t != addr})
|
||||
|
||||
tf = load_tfimports()
|
||||
raw = len(edges)
|
||||
if not args.no_reduce and edges:
|
||||
edges = tf.transitive_reduce(list(addresses), edges)
|
||||
|
||||
resolver = None if args.no_icons else tf.IconResolver()
|
||||
unmatched, nodes = [], []
|
||||
for addr, rtype, name, index, mpath, _ in resources:
|
||||
label = name if index is None else f"{name}[{index}]"
|
||||
node = {"id": addr, "label": label}
|
||||
icon = resolver.resolve(rtype) if resolver and rtype else None
|
||||
if icon:
|
||||
node.update(style=icon["style"], width=icon["w"], height=icon["h"])
|
||||
else:
|
||||
node["label"] = f"{label}\n{rtype}" if rtype else label
|
||||
if rtype:
|
||||
unmatched.append(rtype)
|
||||
if args.group and mpath:
|
||||
node["group"] = mpath
|
||||
nodes.append(node)
|
||||
|
||||
graph = {"direction": args.direction, "nodes": nodes,
|
||||
"edges": [{"source": s, "target": t} for s, t in edges]}
|
||||
if resolver:
|
||||
# Icon labels render below the shape — reserve extra layout spacing.
|
||||
graph.update(ranksep=0.7, nodesep=0.6)
|
||||
out = json.dumps(graph, indent=2)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(out)
|
||||
sys.stderr.write(f"wrote {args.output}\n")
|
||||
else:
|
||||
sys.stdout.write(out)
|
||||
note = "" if args.no_reduce else f" (reduced from {raw})"
|
||||
sys.stderr.write(f"{len(nodes)} resources, {len(edges)} edges{note}\n")
|
||||
if unmatched:
|
||||
sys.stderr.write("no icon for: " + ", ".join(sorted(set(unmatched))) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
257
skills/drawio-skill/scripts/timelapse.py
Normal file
257
skills/drawio-skill/scripts/timelapse.py
Normal file
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Animate how a codebase's architecture grew, across its git history.
|
||||
|
||||
Walks the git history of a directory, re-runs one of the bundled importers at
|
||||
each sampled commit (the tree is pulled with ``git archive`` — the working copy
|
||||
is never touched), lays each out and exports a PNG frame, then assembles a
|
||||
single self-contained HTML player (frames embedded as base64, play / step
|
||||
controls, no external files or CDNs). Open it in any browser to watch the
|
||||
modules and edges appear over time.
|
||||
|
||||
python3 timelapse.py skills/drawio-skill/scripts --importer pyimports
|
||||
# -> architecture-evolution.html
|
||||
|
||||
The importer is any of the bundled graph extractors (pyimports, jsimports,
|
||||
goimports, rustimports, pyclasses, tfimports, k8simports, composeimports,
|
||||
sqlerd); it is run against the archived directory with the same positional
|
||||
"path" argument they all take, so point the path at the project/package/infra
|
||||
root the importer expects. Extra importer flags pass through via
|
||||
``--importer-args`` (e.g. ``--importer-args "--group"``).
|
||||
|
||||
Commits that touched the directory are sampled evenly (always keeping the first
|
||||
and last) down to ``--max-frames``; a commit where the importer finds nothing
|
||||
(the path did not exist yet) is skipped. Needs git, the importer's requirements,
|
||||
Graphviz (autolayout) and the draw.io CLI — the same tools the importers use.
|
||||
|
||||
Usage: python3 timelapse.py <dir> [--importer NAME] [--importer-args STR]
|
||||
[--max-frames N] [-o out.html] [--direction TB|LR] [--keep-frames]
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
IMPORTERS = {"pyimports", "jsimports", "goimports", "rustimports", "pyclasses",
|
||||
"tfimports", "k8simports", "composeimports", "sqlerd"}
|
||||
|
||||
|
||||
def git(root, *args):
|
||||
"""Run a git command in `root`; return (returncode, stdout_bytes)."""
|
||||
p = subprocess.run(["git", "-C", root, *args], capture_output=True)
|
||||
return p.returncode, p.stdout
|
||||
|
||||
|
||||
def sample_indices(total, n):
|
||||
"""Evenly-spaced indices across range(total), always incl. first and last.
|
||||
|
||||
For total <= n every index is kept; otherwise n indices are picked so the
|
||||
first (0) and last (total-1) are always present and the rest are spread
|
||||
uniformly between them.
|
||||
"""
|
||||
if total <= 0:
|
||||
return []
|
||||
if total <= n or n <= 1:
|
||||
return list(range(total))
|
||||
return sorted({round(i * (total - 1) / (n - 1)) for i in range(n)})
|
||||
|
||||
|
||||
def history(root, subpath):
|
||||
"""Chronological [(hash, iso_date, subject), ...] of commits touching subpath."""
|
||||
code, out = git(root, "log", "--format=%H%x09%aI%x09%s", "--", subpath or ".")
|
||||
if code != 0:
|
||||
return []
|
||||
rows = []
|
||||
for line in out.decode("utf-8", "replace").splitlines():
|
||||
parts = line.split("\t", 2)
|
||||
if len(parts) == 3:
|
||||
rows.append(tuple(parts))
|
||||
return list(reversed(rows)) # oldest first
|
||||
|
||||
|
||||
def extract_tree(root, commit, subpath, dest):
|
||||
"""Extract subpath of `commit` into dest via git archive. False if absent."""
|
||||
code, tar_bytes = git(root, "archive", commit, subpath or ".")
|
||||
if code != 0 or not tar_bytes:
|
||||
return False
|
||||
with tarfile.open(fileobj=io.BytesIO(tar_bytes)) as tf:
|
||||
tf.extractall(dest) # git-authored tar; trusted
|
||||
return True
|
||||
|
||||
|
||||
def build_frame(importer, importer_args, work_path, direction, tmp):
|
||||
"""Importer -> autolayout -> PNG for one commit. Returns (png_bytes, n, e) or None."""
|
||||
graph_json = os.path.join(tmp, "graph.json")
|
||||
imp = subprocess.run(
|
||||
[sys.executable, os.path.join(HERE, importer + ".py"), work_path,
|
||||
"-o", graph_json, *importer_args],
|
||||
capture_output=True)
|
||||
if imp.returncode != 0 or not os.path.exists(graph_json):
|
||||
return None
|
||||
with open(graph_json, encoding="utf-8") as f:
|
||||
graph = json.loads(f.read())
|
||||
if not graph.get("nodes"):
|
||||
return None
|
||||
graph["direction"] = direction
|
||||
with open(graph_json, "w", encoding="utf-8") as f:
|
||||
f.write(json.dumps(graph))
|
||||
drawio = os.path.join(tmp, "frame.drawio")
|
||||
lay = subprocess.run(
|
||||
[sys.executable, os.path.join(HERE, "autolayout.py"), graph_json, "-o", drawio],
|
||||
capture_output=True)
|
||||
if lay.returncode != 0 or not os.path.exists(drawio):
|
||||
return None
|
||||
png = os.path.join(tmp, "frame.png")
|
||||
exp = subprocess.run(["drawio", "-x", "-f", "png", "--width", "1600",
|
||||
"-o", png, drawio], capture_output=True)
|
||||
if exp.returncode != 0 or not os.path.exists(png):
|
||||
return None
|
||||
with open(png, "rb") as f:
|
||||
png_data = f.read()
|
||||
return png_data, len(graph["nodes"]), len(graph["edges"])
|
||||
|
||||
|
||||
def build_html(frames, title):
|
||||
"""Self-contained HTML player for the frame list."""
|
||||
data = [{"img": "data:image/png;base64," + base64.b64encode(png).decode(),
|
||||
"hash": h[:9], "date": d[:10], "subj": s, "n": n, "e": e}
|
||||
for png, h, d, s, n, e in frames]
|
||||
peak = max((f["n"] for f in data), default=1) or 1
|
||||
payload = json.dumps(data).replace("</", "<\\/")
|
||||
return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{title}</title><style>
|
||||
:root{{color-scheme:light dark}}
|
||||
*{{box-sizing:border-box}}
|
||||
body{{margin:0;font:14px/1.5 system-ui,-apple-system,Segoe UI,sans-serif;
|
||||
background:#f6f7f9;color:#1a1a1a}}
|
||||
@media(prefers-color-scheme:dark){{body{{background:#15171a;color:#e8e8e8}}}}
|
||||
header{{padding:16px 20px 4px}}h1{{margin:0;font-size:17px;font-weight:600}}
|
||||
main{{max-width:1100px;margin:0 auto;padding:8px 16px 28px}}
|
||||
#stage{{background:#fff;border:1px solid #0001;border-radius:10px;
|
||||
min-height:60vh;display:flex;align-items:center;justify-content:center;padding:12px}}
|
||||
@media(prefers-color-scheme:dark){{#stage{{background:#1e2226;border-color:#fff2}}}}
|
||||
#stage img{{max-width:100%;max-height:74vh;object-fit:contain}}
|
||||
.cap{{display:flex;gap:14px;flex-wrap:wrap;align-items:baseline;
|
||||
padding:12px 4px 6px;color:#556;font-size:13px}}
|
||||
@media(prefers-color-scheme:dark){{.cap{{color:#9aa}}}}
|
||||
.cap b{{color:inherit;font-weight:600}}.cap .subj{{color:#1a1a1a}}
|
||||
@media(prefers-color-scheme:dark){{.cap .subj{{color:#e8e8e8}}}}
|
||||
.bar{{height:6px;border-radius:3px;background:#0d99ff;transition:width .3s}}
|
||||
.barwrap{{height:6px;background:#0001;border-radius:3px;margin:2px 4px 12px}}
|
||||
.ctl{{display:flex;gap:10px;align-items:center;padding:4px}}
|
||||
button{{font:inherit;padding:6px 12px;border:1px solid #0002;border-radius:8px;
|
||||
background:#fff;cursor:pointer;color:inherit}}
|
||||
@media(prefers-color-scheme:dark){{button{{background:#262b31;border-color:#fff2}}}}
|
||||
button:hover{{border-color:#0d99ff}}
|
||||
input[type=range]{{flex:1;accent-color:#0d99ff}}
|
||||
</style></head><body>
|
||||
<header><h1>{title}</h1></header>
|
||||
<main>
|
||||
<div id="stage"><img id="img" alt="architecture frame"></div>
|
||||
<div class="cap">
|
||||
<span><b id="idx"></b></span>
|
||||
<span><b id="hash"></b> · <span id="date"></span></span>
|
||||
<span class="subj" id="subj"></span>
|
||||
<span id="counts"></span>
|
||||
</div>
|
||||
<div class="barwrap"><div class="bar" id="bar"></div></div>
|
||||
<div class="ctl">
|
||||
<button id="prev">‹ Prev</button>
|
||||
<button id="play">▶ Play</button>
|
||||
<button id="next">Next ›</button>
|
||||
<input type="range" id="scrub" min="0" value="0">
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const F={payload},PEAK={peak};
|
||||
let i=0,timer=null;
|
||||
const $=id=>document.getElementById(id);
|
||||
$("scrub").max=F.length-1;
|
||||
function show(k){{
|
||||
i=(k+F.length)%F.length;const f=F[i];
|
||||
$("img").src=f.img;$("idx").textContent=`Frame ${{i+1}} / ${{F.length}}`;
|
||||
$("hash").textContent=f.hash;$("date").textContent=f.date;$("subj").textContent=f.subj;
|
||||
$("counts").textContent=`${{f.n}} nodes · ${{f.e}} edges`;
|
||||
$("bar").style.width=(6+94*f.n/PEAK)+"%";$("scrub").value=i;
|
||||
}}
|
||||
function stop(){{clearInterval(timer);timer=null;$("play").textContent="▶ Play";}}
|
||||
$("prev").onclick=()=>{{stop();show(i-1);}};
|
||||
$("next").onclick=()=>{{stop();show(i+1);}};
|
||||
$("scrub").oninput=e=>{{stop();show(+e.target.value);}};
|
||||
$("play").onclick=()=>{{
|
||||
if(timer){{stop();return;}}
|
||||
$("play").textContent="⏸ Pause";
|
||||
timer=setInterval(()=>{{if(i>=F.length-1){{show(0);}}else{{show(i+1);}}}},900);
|
||||
}};
|
||||
show(0);
|
||||
</script></body></html>"""
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Git-history architecture time-lapse -> HTML player.")
|
||||
ap.add_argument("path", help="directory to visualize (inside a git repo)")
|
||||
ap.add_argument("--importer", default="pyimports",
|
||||
help="bundled importer to run at each commit (default pyimports)")
|
||||
ap.add_argument("--importer-args", default="",
|
||||
help="extra args passed to the importer, e.g. \"--group\"")
|
||||
ap.add_argument("--max-frames", type=int, default=10, help="sample down to N commits")
|
||||
ap.add_argument("--direction", default="TB", choices=["TB", "LR"])
|
||||
ap.add_argument("-o", "--output", default="architecture-evolution.html")
|
||||
ap.add_argument("--keep-frames", action="store_true",
|
||||
help="also write the PNG frames next to the HTML")
|
||||
args = ap.parse_args()
|
||||
|
||||
importer = args.importer[:-3] if args.importer.endswith(".py") else args.importer
|
||||
if importer not in IMPORTERS:
|
||||
sys.exit(f"error: unknown importer {importer!r} (choose one of: "
|
||||
+ ", ".join(sorted(IMPORTERS)) + ")")
|
||||
if not os.path.isdir(args.path):
|
||||
sys.exit(f"error: {args.path} is not a directory")
|
||||
code, top = git(args.path, "rev-parse", "--show-toplevel")
|
||||
if code != 0:
|
||||
sys.exit(f"error: {args.path} is not inside a git repository")
|
||||
root = top.decode().strip()
|
||||
subpath = os.path.relpath(os.path.abspath(args.path), root)
|
||||
if subpath == ".":
|
||||
subpath = ""
|
||||
|
||||
commits = history(root, subpath)
|
||||
if not commits:
|
||||
sys.exit(f"error: no commits touch {args.path}")
|
||||
picked = [commits[i] for i in sample_indices(len(commits), args.max_frames)]
|
||||
imp_args = args.importer_args.split()
|
||||
|
||||
frames = []
|
||||
for n, (h, date, subj) in enumerate(picked, 1):
|
||||
sys.stderr.write(f"[{n}/{len(picked)}] {h[:9]} {subj[:50]}\n")
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
if not extract_tree(root, h, subpath, tmp):
|
||||
continue
|
||||
work = os.path.join(tmp, subpath) if subpath else tmp
|
||||
frame = build_frame(importer, imp_args, work, args.direction, tmp)
|
||||
if frame is None:
|
||||
sys.stderr.write(" (importer found nothing — skipped)\n")
|
||||
continue
|
||||
png, nn, ee = frame
|
||||
frames.append((png, h, date, subj, nn, ee))
|
||||
if args.keep_frames:
|
||||
fp = f"{os.path.splitext(args.output)[0]}-frame{len(frames):02d}.png"
|
||||
with open(fp, "wb") as f:
|
||||
f.write(png)
|
||||
|
||||
if not frames:
|
||||
sys.exit("error: no frames produced (importer found nothing in any commit)")
|
||||
title = f"Architecture evolution — {os.path.basename(os.path.abspath(args.path))}"
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(build_html(frames, title))
|
||||
sys.stderr.write(f"wrote {args.output} ({len(frames)} frames)\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
181
skills/drawio-skill/scripts/tubemap.py
Normal file
181
skills/drawio-skill/scripts/tubemap.py
Normal file
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restyle a graph as a London-Underground-style metro map (Tube-Map Mode).
|
||||
|
||||
Input JSON describes coloured *lines* (each an ordered list of station ids) and the
|
||||
*stations* they pass through, placed on an integer grid. The script snaps stations to
|
||||
a pixel grid, routes every line segment octilinearly (horizontal / vertical / 45°
|
||||
diagonal, inserting one bend when two stations are not already aligned), draws thick
|
||||
coloured line strokes, marks interchange stations as white-fill black-ring circles and
|
||||
regular stops as small white circles, and labels each station — the classic tube-map
|
||||
look — as an editable `.drawio`.
|
||||
|
||||
python3 tubemap.py metro.json -o metro.drawio
|
||||
|
||||
Input schema (see references/tubemap.md for the full authoring guide):
|
||||
|
||||
{
|
||||
"stations": {
|
||||
"<id>": {"label": "...", "gx": <int>, "gy": <int>, "interchange": <bool?>}
|
||||
},
|
||||
"lines": [
|
||||
{"name": "...", "color": "#rrggbb"?, "stations": ["<id>", "<id>", ...]}
|
||||
]
|
||||
}
|
||||
|
||||
Keep consecutive stations on a line horizontally, vertically, or 45°-diagonally aligned
|
||||
for the cleanest routing; any other offset gets one automatic diagonal-then-straight
|
||||
bend. A line with no "color" is assigned one from the default tube palette by order.
|
||||
|
||||
Usage: python3 tubemap.py <metro.json> [-o out.drawio] [--grid N]
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
# Default palette (approx. real tube-line colours), cycled for lines lacking a "color".
|
||||
TUBE_PALETTE = [
|
||||
"#0098d4", # blue
|
||||
"#007d32", # green
|
||||
"#e1251b", # red
|
||||
"#ee7c0e", # orange
|
||||
"#9b0056", # magenta
|
||||
"#00a4a7", # teal
|
||||
"#ffce00", # yellow
|
||||
"#894e24", # brown
|
||||
]
|
||||
|
||||
|
||||
def esc(s):
|
||||
return (s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace('"', """))
|
||||
|
||||
|
||||
def octilinear_waypoints(x1, y1, x2, y2):
|
||||
"""Waypoints so the path is horizontal, vertical, or 45°: diagonal then straight.
|
||||
|
||||
Returns [] when the two points are already octilinearly aligned, else a single bend
|
||||
point (run the 45° diagonal for the shorter delta, then a straight axis segment).
|
||||
"""
|
||||
dx, dy = x2 - x1, y2 - y1
|
||||
if dx == 0 or dy == 0 or abs(dx) == abs(dy):
|
||||
return []
|
||||
sx = 1 if dx > 0 else -1
|
||||
sy = 1 if dy > 0 else -1
|
||||
d = min(abs(dx), abs(dy))
|
||||
if abs(dx) > abs(dy): # diagonal first, then horizontal into the target
|
||||
return [(x1 + sx * d, y2)]
|
||||
return [(x2, y1 + sy * d)] # diagonal first, then vertical into the target
|
||||
|
||||
|
||||
def build(data, grid=110):
|
||||
"""Build the tube-map `.drawio` XML string from the parsed metro description."""
|
||||
stations = data.get("stations", {})
|
||||
lines = data.get("lines", [])
|
||||
if not stations:
|
||||
sys.exit("error: no stations in input")
|
||||
for ln in lines:
|
||||
for sid in ln.get("stations", []):
|
||||
if sid not in stations:
|
||||
sys.exit(f"error: line {ln.get('name', '?')!r} references unknown "
|
||||
f"station id {sid!r}")
|
||||
|
||||
ox = oy = 80
|
||||
G = grid
|
||||
|
||||
def px(sid):
|
||||
s = stations[sid]
|
||||
return ox + int(s["gx"]) * G, oy + int(s["gy"]) * G
|
||||
|
||||
maxx = ox + max(int(s["gx"]) for s in stations.values()) * G + 220
|
||||
maxy = oy + max(int(s["gy"]) for s in stations.values()) * G + 120
|
||||
lw = max(8, G // 12)
|
||||
|
||||
out = ['<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<mxfile host="drawio-skill" type="device">',
|
||||
' <diagram id="tube" name="Tube Map">',
|
||||
f' <mxGraphModel dx="0" dy="0" grid="0" gridSize="10" pageWidth="{maxx}" '
|
||||
f'pageHeight="{maxy}" math="0" shadow="0" background="#ffffff">',
|
||||
' <root><mxCell id="0"/><mxCell id="1" parent="0"/>']
|
||||
|
||||
nid = [2]
|
||||
|
||||
def cid():
|
||||
c = nid[0]
|
||||
nid[0] += 1
|
||||
return c
|
||||
|
||||
# 1) Line strokes first, so station markers sit on top of them.
|
||||
for i, ln in enumerate(lines):
|
||||
col = ln.get("color") or TUBE_PALETTE[i % len(TUBE_PALETTE)]
|
||||
sts = ln.get("stations", [])
|
||||
for a, b in zip(sts, sts[1:]):
|
||||
x1, y1 = px(a)
|
||||
x2, y2 = px(b)
|
||||
wps = octilinear_waypoints(x1, y1, x2, y2)
|
||||
arr = ""
|
||||
if wps:
|
||||
pts = "".join(f'<mxPoint x="{wx}" y="{wy}"/>' for wx, wy in wps)
|
||||
arr = f'<Array as="points">{pts}</Array>'
|
||||
out.append(
|
||||
f' <mxCell id="e{cid()}" edge="1" parent="1" '
|
||||
f'style="endArrow=none;startArrow=none;strokeColor={col};strokeWidth={lw};'
|
||||
f'rounded=1;html=1;edgeStyle=none;">'
|
||||
f'<mxGeometry relative="1" as="geometry">'
|
||||
f'<mxPoint x="{x1}" y="{y1}" as="sourcePoint"/>'
|
||||
f'<mxPoint x="{x2}" y="{y2}" as="targetPoint"/>{arr}</mxGeometry></mxCell>')
|
||||
|
||||
# 2) Station markers + labels.
|
||||
for sid, s in stations.items():
|
||||
x, y = px(sid)
|
||||
label = esc(str(s.get("label", sid)))
|
||||
if s.get("interchange"):
|
||||
r = lw + 6
|
||||
marker = (f'ellipse;fillColor=#ffffff;strokeColor=#111111;strokeWidth=3;'
|
||||
f'html=1;')
|
||||
else:
|
||||
r = lw - 1
|
||||
marker = (f'ellipse;fillColor=#ffffff;strokeColor=#555555;strokeWidth=2;'
|
||||
f'html=1;')
|
||||
out.append(
|
||||
f' <mxCell id="s{cid()}" vertex="1" parent="1" style="{marker}" '
|
||||
f'value=""><mxGeometry x="{x - r}" y="{y - r}" width="{2 * r}" '
|
||||
f'height="{2 * r}" as="geometry"/></mxCell>')
|
||||
out.append(
|
||||
f' <mxCell id="l{cid()}" vertex="1" parent="1" '
|
||||
f'style="text;html=1;align=left;verticalAlign=middle;fontSize=13;fontStyle=1;'
|
||||
f'fontColor=#222222;labelBackgroundColor=#ffffff;" value="{label}">'
|
||||
f'<mxGeometry x="{x + lw + 8}" y="{y - 12}" width="170" height="24" '
|
||||
f'as="geometry"/></mxCell>')
|
||||
|
||||
out.append(' </root></mxGraphModel></diagram></mxfile>')
|
||||
return "\n".join(out), len(stations), len(lines)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Restyle a graph as a metro / tube map.")
|
||||
ap.add_argument("input", help="metro JSON (or - for stdin)")
|
||||
ap.add_argument("-o", "--output", help="output .drawio (default: stdout)")
|
||||
ap.add_argument("--grid", type=int, default=110, help="grid pitch in px (default 110)")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.input == "-":
|
||||
raw = sys.stdin.read()
|
||||
else:
|
||||
with open(args.input, encoding="utf-8") as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
sys.exit(f"error: bad JSON in {args.input}: {exc}")
|
||||
|
||||
xml, n_st, n_ln = build(data, grid=args.grid)
|
||||
if args.output:
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(xml)
|
||||
sys.stderr.write(f"wrote {args.output} ({n_st} stations, {n_ln} lines)\n")
|
||||
else:
|
||||
sys.stdout.write(xml)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
340
skills/drawio-skill/scripts/validate.py
Normal file
340
skills/drawio-skill/scripts/validate.py
Normal file
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Deterministic structural linter for .drawio files.
|
||||
|
||||
Catches the class of mistakes a vision self-check is slow and unreliable at:
|
||||
dangling edge endpoints, duplicate or reserved ids, broken parent references,
|
||||
and (as warnings) off-grid geometry, overlapping sibling nodes, and edge
|
||||
routing defects. Runs without launching draw.io, so it is a fast pre-check
|
||||
before the visual review step.
|
||||
|
||||
python3 validate.py diagram.drawio
|
||||
|
||||
Edge routing checks (warnings): an edge segment crossing a non-incident leaf
|
||||
vertex ("routes through vertex"), and two edges crossing each other ("edges X
|
||||
and Y cross") — the two defects the SKILL.md step-5 self-check looks for
|
||||
("Edge-shape overlap", "Stacked edges"), but caught here deterministically.
|
||||
|
||||
Routing is only knowable from the XML when an edge carries explicit waypoints
|
||||
(``<Array as="points">``) — exactly the hand-routed case the SKILL.md tells
|
||||
authors to use to route around shapes. Edges with no waypoints are auto-routed
|
||||
by draw.io at render time (the path is not stored), so they are NOT geometry-
|
||||
checked here, keeping these warnings free of false positives. Endpoints honour
|
||||
``exitX/exitY``/``entryX/entryY`` when present, else the node centre, and
|
||||
absolute positions are resolved through parent containers.
|
||||
|
||||
Exit status is non-zero when any error (or, with --strict, any warning) is
|
||||
found, so it can gate a workflow. Compressed (non-XML) diagram pages are
|
||||
skipped with a warning — this skill always writes uncompressed XML.
|
||||
|
||||
Usage: python3 validate.py <file.drawio> [--strict]
|
||||
"""
|
||||
import argparse
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
RESERVED = {"0", "1"}
|
||||
|
||||
|
||||
def rect(cell):
|
||||
"""Return (x, y, w, h) floats for a cell's geometry, or None if absent/bad.
|
||||
|
||||
x/y default to 0 when omitted: draw.io treats a missing position as the
|
||||
origin, and container-managed children (table rows, swimlane/UML-class
|
||||
lines under tableLayout) legitimately omit x/y while keeping width/height.
|
||||
Only width/height are required to be present and numeric.
|
||||
"""
|
||||
g = cell.find("mxGeometry")
|
||||
if g is None:
|
||||
return None
|
||||
try:
|
||||
return (float(g.get("x", "0")), float(g.get("y", "0")),
|
||||
float(g.get("width", "nan")), float(g.get("height", "nan")))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_edge_label(cell):
|
||||
"""True for a draw.io edge label / relative-positioned child vertex.
|
||||
|
||||
These legitimately omit width/height: their position is given relative to a
|
||||
parent edge (style ``edgeLabel``) or via ``relative="1"`` geometry. Treating
|
||||
them as normal vertices wrongly flags them as missing/invalid geometry.
|
||||
"""
|
||||
if "edgeLabel" in (cell.get("style") or ""):
|
||||
return True
|
||||
g = cell.find("mxGeometry")
|
||||
return g is not None and g.get("relative") == "1"
|
||||
|
||||
|
||||
def overlap(a, b):
|
||||
ax, ay, aw, ah = a
|
||||
bx, by, bw, bh = b
|
||||
return ax < bx + bw and bx < ax + aw and ay < by + bh and by < ay + ah
|
||||
|
||||
|
||||
# --- Edge routing geometry -------------------------------------------------
|
||||
#
|
||||
# These helpers reason about edge paths. They only apply to edges with explicit
|
||||
# waypoints (the route is otherwise computed by draw.io at render time and not
|
||||
# stored in the XML), so the checks never guess an auto-routed path.
|
||||
|
||||
def style_num(style, key):
|
||||
"""Return float value of ``key=`` in a draw.io style string, or None."""
|
||||
for part in (style or "").split(";"):
|
||||
if part.startswith(key + "="):
|
||||
try:
|
||||
return float(part.split("=", 1)[1])
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def abs_rect(cell, by_id):
|
||||
"""Absolute (x, y, w, h) of a vertex, summing parent-container offsets.
|
||||
|
||||
Children of a container use coordinates relative to the container origin, so
|
||||
an edge spanning containers needs absolute positions to be compared.
|
||||
"""
|
||||
r = rect(cell)
|
||||
if r is None or any(v != v for v in r):
|
||||
return None
|
||||
x, y, w, h = r
|
||||
parent, seen = cell.get("parent"), set()
|
||||
while parent and parent in by_id and parent not in seen:
|
||||
seen.add(parent)
|
||||
p = by_id[parent]
|
||||
if p.get("vertex") == "1":
|
||||
pr = rect(p)
|
||||
if pr and not any(v != v for v in pr):
|
||||
x += pr[0]
|
||||
y += pr[1]
|
||||
parent = p.get("parent")
|
||||
return (x, y, w, h)
|
||||
|
||||
|
||||
def endpoint(edge, end, by_id):
|
||||
"""Absolute (x, y) where ``edge`` meets its source/target vertex.
|
||||
|
||||
Honours exitX/exitY (source) and entryX/entryY (target) if the style pins
|
||||
them; otherwise the vertex centre. Returns None if the vertex is unresolved.
|
||||
"""
|
||||
vid = edge.get(end)
|
||||
if not vid or vid not in by_id:
|
||||
return None
|
||||
box = abs_rect(by_id[vid], by_id)
|
||||
if box is None:
|
||||
return None
|
||||
x, y, w, h = box
|
||||
style = edge.get("style") or ""
|
||||
fx = style_num(style, "exitX" if end == "source" else "entryX")
|
||||
fy = style_num(style, "exitY" if end == "source" else "entryY")
|
||||
return (x + (fx if fx is not None else 0.5) * w,
|
||||
y + (fy if fy is not None else 0.5) * h)
|
||||
|
||||
|
||||
def edge_waypoints(edge):
|
||||
"""Explicit <Array as="points"> waypoints of an edge as [(x, y), ...]."""
|
||||
g = edge.find("mxGeometry")
|
||||
if g is None:
|
||||
return []
|
||||
arr = g.find("Array")
|
||||
if arr is None:
|
||||
return []
|
||||
pts = []
|
||||
for pt in arr.findall("mxPoint"):
|
||||
px, py = pt.get("x"), pt.get("y")
|
||||
if px is not None and py is not None:
|
||||
try:
|
||||
pts.append((float(px), float(py)))
|
||||
except ValueError:
|
||||
pass
|
||||
return pts
|
||||
|
||||
|
||||
def edge_route(edge, by_id):
|
||||
"""Absolute polyline [(x, y), ...] for a waypointed edge, or None.
|
||||
|
||||
Returns None when the edge has no explicit waypoints (auto-routed; path
|
||||
unknown) or an endpoint cannot be resolved.
|
||||
"""
|
||||
waypoints = edge_waypoints(edge)
|
||||
if not waypoints:
|
||||
return None
|
||||
s, t = endpoint(edge, "source", by_id), endpoint(edge, "target", by_id)
|
||||
if s is None or t is None:
|
||||
return None
|
||||
return [s] + waypoints + [t]
|
||||
|
||||
|
||||
def _orient(a, b, c):
|
||||
v = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])
|
||||
return 0 if abs(v) < 1e-9 else (1 if v > 0 else -1)
|
||||
|
||||
|
||||
def segments_cross(p1, p2, p3, p4):
|
||||
"""True if segments p1p2 and p3p4 properly cross (interior intersection).
|
||||
|
||||
Proper crossing only: collinear overlap and shared-endpoint touches return
|
||||
False, so edges meeting at a common node or grazing a corner are not flagged.
|
||||
"""
|
||||
o1, o2 = _orient(p1, p2, p3), _orient(p1, p2, p4)
|
||||
o3, o4 = _orient(p3, p4, p1), _orient(p3, p4, p2)
|
||||
return o1 != o2 and o3 != o4 and 0 not in (o1, o2, o3, o4)
|
||||
|
||||
|
||||
def _point_in_rect(p, box, eps=1e-6):
|
||||
x, y, w, h = box
|
||||
return x + eps < p[0] < x + w - eps and y + eps < p[1] < y + h - eps
|
||||
|
||||
|
||||
def route_hits_rect(points, box):
|
||||
"""True if a polyline enters a rectangle's interior or crosses a border."""
|
||||
x, y, w, h = box
|
||||
corners = [(x, y), (x + w, y), (x + w, y + h), (x, y + h)]
|
||||
borders = list(zip(corners, corners[1:] + corners[:1]))
|
||||
for a, b in zip(points, points[1:]):
|
||||
if _point_in_rect(a, box) or _point_in_rect(b, box):
|
||||
return True
|
||||
if any(segments_cross(a, b, c, d) for c, d in borders):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def routes_cross(pa, pb):
|
||||
"""True if any segment of polyline pa properly crosses any of pb."""
|
||||
for a1, a2 in zip(pa, pa[1:]):
|
||||
for b1, b2 in zip(pb, pb[1:]):
|
||||
if segments_cross(a1, a2, b1, b2):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def geometry_warnings(cells, ids, parents):
|
||||
"""Edge-through-vertex and edge-crossing warnings for waypointed edges."""
|
||||
warns = []
|
||||
routed = [] # (edge_id, polyline, {source, target})
|
||||
for c in cells:
|
||||
if c.get("edge") == "1":
|
||||
pts = edge_route(c, ids)
|
||||
if pts:
|
||||
routed.append((c.get("id"), pts,
|
||||
{c.get("source"), c.get("target")}))
|
||||
# Edge routes through an unrelated leaf vertex (containers wrap children, so
|
||||
# an edge legitimately traverses them — restrict to leaves, as overlap does).
|
||||
leaves = [(c.get("id"), abs_rect(c, ids)) for c in cells
|
||||
if c.get("vertex") == "1" and c.get("id") not in parents
|
||||
and not is_edge_label(c)]
|
||||
leaves = [(vid, box) for vid, box in leaves if box]
|
||||
for eid, pts, ends in routed:
|
||||
for vid, box in leaves:
|
||||
if vid not in ends and route_hits_rect(pts, box):
|
||||
warns.append(f"edge {eid!r} routes through vertex {vid!r}")
|
||||
# Edge-edge crossings (both routes known).
|
||||
for i in range(len(routed)):
|
||||
for j in range(i + 1, len(routed)):
|
||||
(ia, pa, _), (ib, pb, _) = routed[i], routed[j]
|
||||
if routes_cross(pa, pb):
|
||||
warns.append(f"edges {ia!r} and {ib!r} cross")
|
||||
return warns
|
||||
|
||||
|
||||
def check_page(diagram):
|
||||
"""Return (errors, warnings) for one <diagram> page."""
|
||||
name = diagram.get("name", "?")
|
||||
model = diagram.find("mxGraphModel")
|
||||
if model is None:
|
||||
if (diagram.text or "").strip():
|
||||
return [], [f"page {name!r}: compressed, skipped (cannot lint)"]
|
||||
return [f"page {name!r}: no <mxGraphModel>"], []
|
||||
root = model.find("root")
|
||||
# Normalize UserObject/object wrappers (used for links & metadata): the id
|
||||
# lives on the wrapper, geometry/style on the inner mxCell — fold the two
|
||||
# into one cell so edges referencing the wrapper id resolve.
|
||||
cells = []
|
||||
for child in (root if root is not None else []):
|
||||
if child.tag == "mxCell":
|
||||
cells.append(child)
|
||||
elif child.tag in ("UserObject", "object"):
|
||||
inner = child.find("mxCell")
|
||||
if inner is not None:
|
||||
inner.set("id", child.get("id", ""))
|
||||
cells.append(inner)
|
||||
errors, warns = [], []
|
||||
ids = {}
|
||||
for c in cells:
|
||||
cid = c.get("id")
|
||||
if cid in ids:
|
||||
errors.append(f"duplicate id {cid!r}")
|
||||
ids[cid] = c
|
||||
parents = {c.get("parent") for c in cells} # ids that have children
|
||||
for c in cells:
|
||||
cid, parent = c.get("id"), c.get("parent")
|
||||
is_v, is_e = c.get("vertex") == "1", c.get("edge") == "1"
|
||||
if parent is not None and parent not in ids:
|
||||
errors.append(f"cell {cid!r} parent {parent!r} does not exist")
|
||||
for end in ("source", "target"):
|
||||
ref = c.get(end)
|
||||
if ref and ref not in ids:
|
||||
errors.append(f"edge {cid!r} {end} {ref!r} does not exist")
|
||||
if (is_v or is_e) and cid in RESERVED:
|
||||
errors.append(f"cell {cid!r} reuses reserved id 0/1")
|
||||
if is_v and not is_edge_label(c):
|
||||
r = rect(c)
|
||||
if r is None or any(v != v for v in r): # None or NaN
|
||||
errors.append(f"vertex {cid!r} has missing/invalid geometry")
|
||||
else:
|
||||
x, y, w, h = r
|
||||
if w <= 0 or h <= 0:
|
||||
warns.append(f"vertex {cid!r} non-positive size {w:g}x{h:g}")
|
||||
if x < 0 or y < 0:
|
||||
warns.append(f"vertex {cid!r} negative position ({x:g},{y:g})")
|
||||
# Sibling overlap: only leaf vertices (containers legitimately wrap children).
|
||||
boxes = [(c.get("id"), c.get("parent"), rect(c)) for c in cells
|
||||
if c.get("vertex") == "1" and c.get("id") not in parents and rect(c)
|
||||
and not any(v != v for v in rect(c))]
|
||||
for i in range(len(boxes)):
|
||||
for j in range(i + 1, len(boxes)):
|
||||
(ia, pa, ra), (ib, pb, rb) = boxes[i], boxes[j]
|
||||
if pa == pb and overlap(ra, rb):
|
||||
warns.append(f"vertices {ia!r} and {ib!r} overlap")
|
||||
warns += geometry_warnings(cells, ids, parents)
|
||||
return errors, warns
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Lint a .drawio file for structural errors.")
|
||||
ap.add_argument("file")
|
||||
ap.add_argument("--strict", action="store_true", help="treat warnings as failure too")
|
||||
ap.add_argument("--score", action="store_true",
|
||||
help="also print a readability score (lower is better) — "
|
||||
"useful for comparing layout variants of the same graph")
|
||||
args = ap.parse_args()
|
||||
try:
|
||||
tree = ET.parse(args.file)
|
||||
except (ET.ParseError, OSError) as exc:
|
||||
sys.exit(f"error: cannot parse {args.file}: {exc}")
|
||||
pages = tree.getroot().findall("diagram") or [tree.getroot()]
|
||||
errors, warns = [], []
|
||||
for page in pages:
|
||||
e, w = check_page(page)
|
||||
errors += e
|
||||
warns += w
|
||||
for w in warns:
|
||||
print(f"warning: {w}")
|
||||
for e in errors:
|
||||
print(f"error: {e}")
|
||||
print(f"{len(errors)} error(s), {len(warns)} warning(s)")
|
||||
if args.score:
|
||||
# Weighted by how badly each defect hurts readability. Comparable only
|
||||
# across variants of the SAME graph (same nodes/edges).
|
||||
through = sum(1 for w in warns if "routes through" in w)
|
||||
cross = sum(1 for w in warns if " cross" in w)
|
||||
olap = sum(1 for w in warns if " overlap" in w)
|
||||
print(f"score: {20 * through + 10 * cross + 5 * olap} "
|
||||
f"({through} through-vertex, {cross} crossings, {olap} overlaps)")
|
||||
if errors or (args.strict and warns):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user