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