163 lines
5.8 KiB
Python
163 lines
5.8 KiB
Python
#!/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()
|