204 lines
6.9 KiB
Python
204 lines
6.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Build a searchable index for all bundled SAP draw.io libraries.
|
|
|
|
The service-only icon index is kept for backwards compatibility with
|
|
extract_icon.py. This broader index covers the full SAP starter-kit surface
|
|
that is useful to an LLM: BTP service icons, generic icons, connectors, area
|
|
shapes, number bubbles, product names, text elements, and interface labels.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from xml.sax.saxutils import unescape
|
|
|
|
HERE = Path(__file__).resolve().parent
|
|
ASSETS = HERE.parent / "assets"
|
|
LIB_DIR = ASSETS / "libraries"
|
|
OUT = ASSETS / "asset-index.json"
|
|
|
|
LIBRARY_KINDS = {
|
|
"btp-service-icons-all-size-M.xml": "btp-service-icon",
|
|
"sap-generic-icons-size-M-200302.xml": "generic-icon",
|
|
"connectors.xml": "connector",
|
|
"area_shapes.xml": "area-shape",
|
|
"default_shapes.xml": "default-shape",
|
|
"essentials.xml": "essential-shape",
|
|
"numbers.xml": "number-marker",
|
|
"sap_brand_names.xml": "sap-brand-name",
|
|
"text_elements.xml": "text-element",
|
|
"annotations_and_interfaces.xml": "annotation-interface",
|
|
}
|
|
|
|
|
|
def clean_text(value: str) -> str:
|
|
value = unescape(value)
|
|
value = value.replace("
", " ").replace(" ", " ").replace("\\n", " ")
|
|
value = re.sub(r"<br\s*/?>", " ", value, flags=re.I)
|
|
value = re.sub(r"<[^>]+>", " ", value)
|
|
value = re.sub(r" |\xa0", " ", value)
|
|
value = re.sub(r"\s+", " ", value).strip()
|
|
return value
|
|
|
|
|
|
def humanize(value: str | None) -> str:
|
|
if not value:
|
|
return ""
|
|
value = re.sub(r"^\d+-", "", value)
|
|
value = re.sub(r"_sd$", "", value)
|
|
value = re.sub(r"[-_]+", " ", value)
|
|
value = re.sub(r"\bsize [sml]\b", "", value, flags=re.I)
|
|
value = re.sub(r"\s+", " ", value).strip()
|
|
return value
|
|
|
|
|
|
def slugify(value: str) -> str:
|
|
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
|
|
|
|
def load_mxlibrary(path: Path) -> list[dict[str, Any]]:
|
|
raw = path.read_text(encoding="utf-8")
|
|
raw = re.sub(r"<!--.*?-->", "", raw, flags=re.S).strip()
|
|
if not raw.startswith("<mxlibrary>") or not raw.endswith("</mxlibrary>"):
|
|
raise ValueError(f"{path} is not a draw.io mxlibrary")
|
|
body = raw[len("<mxlibrary>") : -len("</mxlibrary>")].strip()
|
|
return json.loads(body)
|
|
|
|
|
|
def first_cell_text(xml: str) -> str:
|
|
try:
|
|
root = ET.fromstring(xml)
|
|
except ET.ParseError:
|
|
return ""
|
|
for cell in root.iter("mxCell"):
|
|
value = clean_text(cell.get("value") or "")
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
|
|
def first_cell_style(xml: str) -> str:
|
|
try:
|
|
root = ET.fromstring(xml)
|
|
except ET.ParseError:
|
|
return ""
|
|
for cell in root.iter("mxCell"):
|
|
style = cell.get("style") or ""
|
|
if cell.get("id") not in {"0", "1"} and style:
|
|
return style
|
|
return ""
|
|
|
|
|
|
def infer_untitled_display(kind: str, library: str, entry_index: int, xml: str) -> str:
|
|
style = first_cell_style(xml).lower()
|
|
hints: list[str] = []
|
|
if "#0070f2" in style or "#ebf8ff" in style:
|
|
hints.append("SAP BTP")
|
|
if "#475e75" in style or "#f5f6f7" in style:
|
|
hints.append("non-SAP")
|
|
if "dashed=1" in style:
|
|
hints.append("dashed")
|
|
if "ellipse" in style:
|
|
hints.append("ellipse")
|
|
if "group" in style:
|
|
hints.append("group")
|
|
if "fillcolor=#ffffff" in style:
|
|
hints.append("white fill")
|
|
base = humanize(library.replace(".xml", ""))
|
|
suffix = " ".join(hints) if hints else f"entry {entry_index + 1:02d}"
|
|
return f"{base} {suffix}".strip()
|
|
|
|
|
|
def aliases_for(display: str, title: str, kind: str, library: str) -> list[str]:
|
|
aliases = {slugify(display), slugify(title), slugify(humanize(title)), slugify(kind), slugify(library)}
|
|
words = slugify(display)
|
|
replacements = {
|
|
"sap-business-technology-platform": "sap-btp",
|
|
"business-technology-platform": "btp",
|
|
"authorization-and-trust-management": "xsuaa",
|
|
"cloud-integration": "cpi",
|
|
"cloud-connector": "cc",
|
|
"cloud-foundry-runtime": "cf",
|
|
"identity-authentication": "ias",
|
|
"identity-provisioning": "ips",
|
|
"sap-destination-service": "destination",
|
|
"sap-hana-cloud": "hana",
|
|
}
|
|
for old, new in replacements.items():
|
|
if old in words:
|
|
aliases.add(words.replace(old, new))
|
|
return sorted(a for a in aliases if a and a != slugify(display))
|
|
|
|
|
|
def entry_display(kind: str, library: str, entry_index: int, entry: dict[str, Any]) -> str:
|
|
title = humanize(entry.get("title"))
|
|
if "xml" in entry:
|
|
xml = unescape(entry["xml"])
|
|
return first_cell_text(xml) or title or infer_untitled_display(kind, library, entry_index, xml)
|
|
return title or f"{humanize(library)} entry {entry_index + 1:02d}"
|
|
|
|
|
|
def build() -> dict[str, Any]:
|
|
assets: dict[str, dict[str, Any]] = {}
|
|
libraries: dict[str, dict[str, Any]] = {}
|
|
|
|
for library, kind in sorted(LIBRARY_KINDS.items()):
|
|
path = LIB_DIR / library
|
|
if not path.exists():
|
|
continue
|
|
entries = load_mxlibrary(path)
|
|
libraries[library] = {"kind": kind, "entries": len(entries)}
|
|
used_slugs: dict[str, int] = {}
|
|
|
|
for entry_index, entry in enumerate(entries):
|
|
display = entry_display(kind, library, entry_index, entry)
|
|
slug = slugify(display) or f"{kind}-{entry_index + 1:03d}"
|
|
if slug in used_slugs:
|
|
used_slugs[slug] += 1
|
|
slug = f"{slug}-{used_slugs[slug]}"
|
|
else:
|
|
used_slugs[slug] = 1
|
|
|
|
key = f"{kind}:{slug}"
|
|
title = entry.get("title") or ""
|
|
assets[key] = {
|
|
"kind": kind,
|
|
"display": display,
|
|
"title": title,
|
|
"aliases": aliases_for(display, title, kind, library),
|
|
"library": library,
|
|
"entry": entry_index,
|
|
"width": entry.get("w"),
|
|
"height": entry.get("h"),
|
|
"aspect": entry.get("aspect"),
|
|
"source": "data" if "data" in entry else "xml",
|
|
}
|
|
|
|
return {
|
|
"metadata": {
|
|
"source": "SAP/btp-solution-diagrams assets/shape-libraries-and-editable-presets/draw.io",
|
|
"count": len(assets),
|
|
"libraries": libraries,
|
|
},
|
|
"assets": dict(sorted(assets.items())),
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
missing = sorted(name for name in LIBRARY_KINDS if not (LIB_DIR / name).exists())
|
|
if missing:
|
|
print("missing libraries:", ", ".join(missing), file=sys.stderr)
|
|
return 1
|
|
index = build()
|
|
OUT.write_text(json.dumps(index, indent=2, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8")
|
|
print(f"wrote {OUT} — {index['metadata']['count']} assets across {len(index['metadata']['libraries'])} libraries")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|