Import sap-architecture skill
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a ready-to-paste <mxCell> for a named SAP BTP service icon.
|
||||
|
||||
Usage:
|
||||
extract_icon.py <service-name> [--x X --y Y --w W --h H --id ID --parent P --label "text"]
|
||||
extract_icon.py --list
|
||||
|
||||
Search is fuzzy (case-insensitive substring + slug alias).
|
||||
Emits the <mxCell ...><mxGeometry .../></mxCell> to stdout.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
INDEX = HERE.parent / "assets" / "icon-index.json"
|
||||
ASSET_INDEX = HERE.parent / "assets" / "asset-index.json"
|
||||
|
||||
try:
|
||||
from extract_asset import emit_asset as emit_general_asset
|
||||
from extract_asset import find_asset as find_general_asset
|
||||
except ImportError: # pragma: no cover - fallback for copied standalone script use
|
||||
emit_general_asset = None
|
||||
find_general_asset = None
|
||||
|
||||
|
||||
def load_index() -> dict:
|
||||
if not INDEX.exists():
|
||||
print(f"icon index not found at {INDEX}; run build_icon_index.py first", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
return json.loads(INDEX.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_asset_index() -> dict | None:
|
||||
if not ASSET_INDEX.exists() or emit_general_asset is None or find_general_asset is None:
|
||||
return None
|
||||
return json.loads(ASSET_INDEX.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
"""Strip HTML entities ( , &), lowercase, collapse non-alphanumeric to single spaces."""
|
||||
t = text.lower()
|
||||
# decode common HTML newline / ampersand entities
|
||||
t = re.sub(r"&#?\w+;", " ", t)
|
||||
# kill the -10- slug artifact from earlier encoding
|
||||
t = re.sub(r"(^|-)10(-|$)", r"\1 \2", t)
|
||||
return re.sub(r"[^a-z0-9]+", " ", t).strip()
|
||||
|
||||
|
||||
def _tokens(text: str) -> set[str]:
|
||||
return {tok for tok in _normalize(text).split() if tok}
|
||||
|
||||
|
||||
def is_backend_system_query(query: str) -> bool:
|
||||
"""Return True for backend product names that are not BTP service icons."""
|
||||
normalized = _normalize(query)
|
||||
compact = re.sub(r"[^a-z0-9]+", "", query.lower())
|
||||
if normalized in {"sap s 4hana", "sap s 4hana cloud", "s 4hana", "s 4hana cloud"}:
|
||||
return True
|
||||
if compact in {"s4hana", "saps4hana", "s4hanacloud", "saps4hanacloud"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def backend_system_guidance(query: str) -> str:
|
||||
return (
|
||||
f"'{query}' is a backend system/product label, not a BTP service icon. "
|
||||
"Use extract_asset.py \"sap-s-4hana\" --kind sap-brand-name for the label "
|
||||
"or extract_asset.py \"on-premise-sap\" --kind generic-icon for a generic backend icon."
|
||||
)
|
||||
|
||||
|
||||
# Hand-curated aliases for common SAP service abbreviations / nicknames.
|
||||
# These map a user-typed query → a slug substring that uniquely identifies the icon.
|
||||
COMMON_ALIASES: dict[str, str] = {
|
||||
"xsuaa": "sap-authorization-10-and-10-trust-management-service",
|
||||
"uaa": "sap-authorization-10-and-10-trust-management-service",
|
||||
"auth trust": "sap-authorization-10-and-10-trust-management-service",
|
||||
"ias": "identity-10-authentication",
|
||||
"ips": "identity-provisioning",
|
||||
"btp cockpit": "sap-btp-cockpit",
|
||||
"hana": "sap-hana-cloud",
|
||||
"hana cloud": "sap-hana-cloud",
|
||||
"cf": "cloud-10-foundry-runtime",
|
||||
"cloud foundry": "cloud-10-foundry-runtime",
|
||||
"cap": "cloud-application-programming",
|
||||
"destination": "sap-destination-10-service",
|
||||
"cc": "cloud-10-connector",
|
||||
"connector": "cloud-10-connector",
|
||||
"abap env": "abap-environment",
|
||||
"abap environment": "abap-environment",
|
||||
"build apps": "sap-build-apps",
|
||||
"build process": "sap-build-process-automation",
|
||||
"build code": "sap-build-code",
|
||||
"build work zone": "sap-build-work-zone-10-standard-edition",
|
||||
"sap build work zone": "sap-build-work-zone-10-standard-edition",
|
||||
"work zone": "sap-build-work-zone-10-standard-edition",
|
||||
"joule": "joule-studio",
|
||||
"task center": "sap-task-center",
|
||||
"cpi": "cloud-10-integration",
|
||||
"integration suite": "integration-suite",
|
||||
}
|
||||
|
||||
|
||||
def find(index: dict, query: str) -> tuple[str, dict] | None:
|
||||
if is_backend_system_query(query):
|
||||
return None
|
||||
q = query.lower().strip()
|
||||
qslug = re.sub(r"[^a-z0-9]+", "-", q).strip("-")
|
||||
# curated alias table
|
||||
if q in COMMON_ALIASES:
|
||||
slug = COMMON_ALIASES[q]
|
||||
if slug in index:
|
||||
return slug, index[slug]
|
||||
# exact slug
|
||||
if qslug in index:
|
||||
return qslug, index[qslug]
|
||||
# alias match
|
||||
for slug, entry in index.items():
|
||||
if qslug in entry["aliases"] or qslug == entry["display"].lower():
|
||||
return slug, entry
|
||||
|
||||
qtokens = _tokens(q)
|
||||
if not qtokens:
|
||||
return None
|
||||
|
||||
# token-subset match: every query token appears in (display | slug | aliases)
|
||||
token_candidates: list[tuple[str, dict]] = []
|
||||
for slug, entry in index.items():
|
||||
pool = _tokens(slug) | _tokens(entry["display"]) | {a for alias in entry["aliases"] for a in _tokens(alias)}
|
||||
if qtokens <= pool:
|
||||
token_candidates.append((slug, entry))
|
||||
if len(token_candidates) == 1:
|
||||
return token_candidates[0]
|
||||
if len(token_candidates) > 1:
|
||||
# prefer the candidate whose normalized display is shortest (tightest match)
|
||||
token_candidates.sort(key=lambda se: len(_normalize(se[1]["display"])))
|
||||
# if the top candidate is strictly shorter than the runner-up, take it
|
||||
top = _normalize(token_candidates[0][1]["display"])
|
||||
second = _normalize(token_candidates[1][1]["display"])
|
||||
if len(top) < len(second):
|
||||
return token_candidates[0]
|
||||
print(f"ambiguous '{query}' — {len(token_candidates)} matches:", file=sys.stderr)
|
||||
for s, e in token_candidates[:10]:
|
||||
print(f" {s} — {e['display']}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
# substring fallback
|
||||
candidates = [(s, e) for s, e in index.items() if q in e["display"].lower() or qslug in s]
|
||||
if len(candidates) == 1:
|
||||
return candidates[0]
|
||||
if len(candidates) > 1:
|
||||
print(f"ambiguous '{query}' — {len(candidates)} matches:", file=sys.stderr)
|
||||
for s, e in candidates[:10]:
|
||||
print(f" {s} — {e['display']}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("query", nargs="?")
|
||||
ap.add_argument("--list", action="store_true")
|
||||
ap.add_argument("--x", type=int, default=0)
|
||||
ap.add_argument("--y", type=int, default=0)
|
||||
# Defaults match the SAP corpus convention: 32x32 is the dominant icon
|
||||
# size (used 224x across the 71 bundled templates), then 48x48 (157x).
|
||||
# The previous default 64x80 caused icons to overlap card text — the
|
||||
# most common visible bug. Override with --w 48 --h 48 only when the
|
||||
# icon is the focal anchor of a zone (e.g. a brand mark beside a
|
||||
# top-left zone label).
|
||||
ap.add_argument("--w", type=int, default=32)
|
||||
ap.add_argument("--h", type=int, default=32)
|
||||
ap.add_argument("--id", default="icon1")
|
||||
ap.add_argument("--parent", default="1")
|
||||
ap.add_argument("--label", default=None, help="Override icon label text")
|
||||
args = ap.parse_args()
|
||||
|
||||
asset_index = load_asset_index()
|
||||
index = load_index()
|
||||
if args.list:
|
||||
if asset_index:
|
||||
assets = asset_index["assets"]
|
||||
for key in sorted(assets):
|
||||
entry = assets[key]
|
||||
if entry["kind"] == "btp-service-icon":
|
||||
slug = key.split(":", 1)[1]
|
||||
print(f"{slug:60s} {entry['display']}")
|
||||
else:
|
||||
for slug in sorted(index):
|
||||
print(f"{slug:60s} {index[slug]['display']}")
|
||||
return 0
|
||||
if not args.query:
|
||||
ap.print_usage(sys.stderr)
|
||||
return 2
|
||||
|
||||
if is_backend_system_query(args.query):
|
||||
print(backend_system_guidance(args.query), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
prefer_icon_index = args.query.lower().strip() in COMMON_ALIASES
|
||||
if asset_index and find_general_asset and emit_general_asset and not prefer_icon_index:
|
||||
try:
|
||||
asset_match = find_general_asset(asset_index, args.query, "btp-service-icon")
|
||||
except SystemExit:
|
||||
asset_match = None
|
||||
if asset_match:
|
||||
slug, asset = asset_match
|
||||
print(emit_general_asset(asset, args))
|
||||
print(f"# matched: {slug} — {asset['display']}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
match = find(index, args.query)
|
||||
if not match:
|
||||
print(f"no icon matches '{args.query}'", file=sys.stderr)
|
||||
return 1
|
||||
slug, entry = match
|
||||
|
||||
style = entry["style"]
|
||||
label = args.label if args.label is not None else entry["label"]
|
||||
label_xml = escape(label, {'"': """})
|
||||
|
||||
# Snap to 10-px grid (quiet fix)
|
||||
x = round(args.x / 10) * 10
|
||||
y = round(args.y / 10) * 10
|
||||
w = round(args.w / 10) * 10
|
||||
h = round(args.h / 10) * 10
|
||||
|
||||
cell = (
|
||||
f'<mxCell id="{args.id}" value="{label_xml}" style="{style}" '
|
||||
f'vertex="1" parent="{args.parent}">'
|
||||
f'<mxGeometry x="{x}" y="{y}" width="{w}" height="{h}" as="geometry"/>'
|
||||
f"</mxCell>"
|
||||
)
|
||||
print(cell)
|
||||
print(f"# matched: {slug} — {entry['display']}", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user