From f7d358ead69c9f318faef80731b22a81d7d24d64 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 26 Sep 2026 18:39:28 +1000 Subject: [PATCH 1/9] ir: add physical names + relationship verb phrases (erwin-facing fields) Small additive IR fields the erwin importer needs so a real erwin model imports faithfully and a re-export round-trips: - LogicalEntity.physical_name and Attribute.physical_name (erwin Physical_Name, distinct from the logical name) - Relationship.verb_phrase / inverse_verb_phrase (erwin Parent_To_Child / Child_To_Parent verb phrases, e.g. "places" / "is placed by") All optional and defaulted to None, so a model that doesn't use them is unchanged and dumps clean (exclude_none drops them). Round-trip test confirms they survive write+load; core suite green. --- packages/core/src/mdl_core/ir.py | 11 ++++ packages/core/tests/test_erwin_ir_fields.py | 65 +++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 packages/core/tests/test_erwin_ir_fields.py diff --git a/packages/core/src/mdl_core/ir.py b/packages/core/src/mdl_core/ir.py index e498c54..21919a5 100644 --- a/packages/core/src/mdl_core/ir.py +++ b/packages/core/src/mdl_core/ir.py @@ -290,6 +290,9 @@ class CodeSet(_Base): class Attribute(_OntologyMixin, _Base): id: ULID name: str + # The physical column name when it differs from the logical `name` (erwin's + # Physical_Name). Optional; None means the physical name follows the logical one. + physical_name: str | None = None definition: str | None = None domain: str | None = None # name-ref to a Domain object role: Literal["business_key", "surrogate_key", "attribute", "measure"] = "attribute" @@ -304,6 +307,9 @@ class LogicalEntity(_Base): id: ULID kind: Literal[ObjectKind.logical_entity] = ObjectKind.logical_entity name: str + # The physical table name when it differs from the logical `name` (erwin's + # Physical_Name). Optional; None means the physical name follows the logical one. + physical_name: str | None = None realises: ULID | None = None # conceptual entity ULID definition: str | None = None attributes: list[Attribute] = Field(default_factory=list) @@ -336,6 +342,11 @@ class Relationship(_Base): cardinality: Cardinality = "many_to_one" identifying: bool = False optionality: Literal["mandatory", "optional"] = "mandatory" + # Readable role names (erwin's verb phrases): the parent-to-child phrase reads + # " " (e.g. "places"), the inverse reads the other way + # ("is placed by"). Optional; carried through import/export, not required to model. + verb_phrase: str | None = None + inverse_verb_phrase: str | None = None enforce: RelationshipEnforce = Field(default_factory=RelationshipEnforce) udp: Udp | None = None # user-defined properties (erwin UDPs) diff --git a/packages/core/tests/test_erwin_ir_fields.py b/packages/core/tests/test_erwin_ir_fields.py new file mode 100644 index 0000000..6bfa6dc --- /dev/null +++ b/packages/core/tests/test_erwin_ir_fields.py @@ -0,0 +1,65 @@ +"""The erwin-facing IR additions: physical names + relationship verb phrases. + +These are optional, additive fields that erwin import populates and a re-export should +round-trip. A model that doesn't use them behaves exactly as before. +""" + +from __future__ import annotations + +from mdl_core.ids import new_ulid +from mdl_core.ir import ( + Attribute, + ConceptualEntity, + LogicalEntity, + Model, + ProjectConfig, + Relationship, + RelationshipEnd, +) +from mdl_core.repo import ModelRepo +from mdl_reverse.writer import write_model + + +def test_physical_names_and_verb_phrases_round_trip(tmp_path): + m = Model(ProjectConfig(name="p", dbt_target="duckdb")) + ce_c = ConceptualEntity(id=new_ulid(), name="Customer") + ce_o = ConceptualEntity(id=new_ulid(), name="Order") + m.add(ce_c) + m.add(ce_o) + cust = LogicalEntity( + id=new_ulid(), name="customer", physical_name="CUSTOMER", realises=ce_c.id, + attributes=[Attribute(id=new_ulid(), name="customer_id", + physical_name="CUSTOMER_ID", role="business_key")], + ) + order = LogicalEntity(id=new_ulid(), name="order", physical_name="ORDER", realises=ce_o.id, + attributes=[Attribute(id=new_ulid(), name="customer_id")]) + m.add(cust) + m.add(order) + m.add( + Relationship( + id=new_ulid(), name="order_placed_by_customer", + **{"from": RelationshipEnd(entity=order.id, attributes=[order.attributes[0].id])}, + to=RelationshipEnd(entity=cust.id, attributes=[cust.attributes[0].id]), + verb_phrase="places", inverse_verb_phrase="is placed by", + ) + ) + write_model(m, tmp_path) + + reloaded = ModelRepo.load(tmp_path).model + le = next(e for e in reloaded.logical_entities.values() if e.name == "customer") + assert le.physical_name == "CUSTOMER" + assert le.attributes[0].physical_name == "CUSTOMER_ID" + rel = next(iter(reloaded.relationships.values())) + assert rel.verb_phrase == "places" + assert rel.inverse_verb_phrase == "is placed by" + + +def test_absent_fields_stay_clean(tmp_path): + # a model that uses none of the new fields writes YAML without them (no noise) + m = Model(ProjectConfig(name="p", dbt_target="duckdb")) + ce = ConceptualEntity(id=new_ulid(), name="Thing") + m.add(ce) + m.add(LogicalEntity(id=new_ulid(), name="thing", realises=ce.id)) + write_model(m, tmp_path) + text = (tmp_path / "logical" / "entities" / "thing.yaml").read_text() + assert "physical_name" not in text From e7610d460a1d188b7ca2c30c4613129957056c70 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 26 Sep 2026 18:46:15 +1000 Subject: [PATCH 2/9] reverse: rewrite the erwin importer against the REAL erwin XML schema The old import_erwin was written against an invented flat shape (, parent=/child= by name) that does not match real erwin exports at all, so it silently produced an empty model. Rewritten against the actual erwin Data Modeler format (from erwin/schemas/*.xsd): - Namespaced {http://www.erwin.com/dm}erwin -> {.../dm/data}Model, GUID-referenced objects, properties as element TEXT inside a sibling (not XML attributes). - Streaming parse (ElementTree.iterparse + elem.clear() per top-level object) so a 7-10MB export never materialises as a DOM. Verified on a 2000-entity >1MB fixture. - Two-pass: index every object by id, then resolve cross-refs (Parent/Child_Entity_Ref, Key_Group_Member/Attribute_Ref, Parent_Domain_Ref, subject-area attached-object arrays). - Maps: Entity -> Conceptual+Logical (logical name lowercased, Physical_Name kept), per-entity Key_Group -> KeyGroup (pk/alternate/unique), Relationship -> Relationship (child=from/many, parent=to/one, verb phrases, cardinality, identifying), subtype relationship -> Category (with discriminator), Subject_Area -> SubjectArea membership, Domain -> Domain. FK columns inherit the parent PK's domain (erwin type migration). - Defensive: unknown groups (View, ER_Diagram, transforms) and dangling refs skip with a warning, never fatal. Returns ErwinImport(model, warnings). 9 tests on a real-shaped fixture: entities/attrs/keys, relationship + verb phrases, subtype->Category, subject-area membership, domains, skip-with-warnings, the imported model validates clean, and the streaming large-export path. CLI wiring follows in the next commit. --- packages/reverse/src/mdl_reverse/erwin.py | 651 +++++++++++++++++----- packages/reverse/tests/test_erwin.py | 291 +++++++--- 2 files changed, 708 insertions(+), 234 deletions(-) diff --git a/packages/reverse/src/mdl_reverse/erwin.py b/packages/reverse/src/mdl_reverse/erwin.py index 5ee373f..4c8bf84 100644 --- a/packages/reverse/src/mdl_reverse/erwin.py +++ b/packages/reverse/src/mdl_reverse/erwin.py @@ -1,36 +1,46 @@ -"""erwin XML import (spec §6.4). - -Nobody migrates greenfield, so this is the go-to-market path. We map erwin's -subject areas, entities, attributes, domains, relationships and naming standards -into the IR. - -erwin has several export shapes (the native erwin XML and an XMLSchema variant). -We parse defensively: tag/attribute names are matched case-insensitively and we -accept the common element layout below. Unrecognised structure is skipped rather -than fatal, so a partial import still yields a usable model the modeller refines. - -Expected-ish shape (simplified erwin XML): - - - - - - - - - +"""erwin XML import — the real erwin Data Modeler export format (spec §6.4). + +Nobody migrates to a modeling tool greenfield, so this is the go-to-market on-ramp: point +Modelith at an erwin export and get a committable model back. + +The real erwin XML (per the erwin XSDs at erwin/schemas/) is nothing like a flat table +dump. It is: + + - **Namespaced.** Root `{http://www.erwin.com/dm}erwin` -> `{.../dm/data}Model`, and + every object element lives in the data namespace. + - **GUID-referenced.** Every object carries a required `id` attribute (a GUID); objects + point at each other by that id, held in `<..._Ref>` elements (text) or index-ordered + `<..._Ref>` children of a `<..._Ref_Array>`. + - **Props-as-text.** An object's scalar data lives in a sibling `` element + whose CHILDREN are the properties, each carrying its value as element text — + `Customer...`, never an XML attribute. + +Objects are grouped in `_Groups` containers under Model (Entity_Groups -> Entity, +Relationship_Groups -> Relationship, ...). Keys live inside each Entity as `Key_Group`s. + +**Streaming.** Real exports run 7-10MB, so we parse with ElementTree.iterparse and clear +each top-level object element as it closes — the full document is never held as a DOM. We +index every object by id in one streaming pass, then resolve the cross-references. + +**Defensive.** Unknown object groups, unknown property values, and dangling references are +skipped with a warning, never fatal — a novel or partial export yields a usable model plus +notes rather than a crash. """ from __future__ import annotations import xml.etree.ElementTree as ET +from dataclasses import dataclass, field +from io import BytesIO, StringIO from pathlib import Path from mdl_core.ids import new_ulid from mdl_core.ir import ( Attribute, + Category, ConceptualEntity, + Domain, + KeyGroup, LogicalEntity, Model, ProjectConfig, @@ -39,26 +49,46 @@ SubjectArea, ) -# erwin datatype -> abstract base type. +# erwin namespaces (see the XSD imports). +_NS_DATA = "http://www.erwin.com/dm/data" + +# erwin logical/physical datatype -> Modelith abstract base type. Values look like +# "VARCHAR(40)" / "BIGINT" / "NUMBER(18,2)"; strip the size and lowercase. _ERWIN_TYPE = { "bigint": "bigint", "integer": "integer", "int": "integer", "smallint": "integer", + "tinyint": "integer", "numeric": "decimal", "decimal": "decimal", "number": "decimal", + "float": "decimal", + "double": "decimal", + "real": "decimal", + "money": "decimal", "varchar": "string", + "varchar2": "string", + "nvarchar": "string", + "nvarchar2": "string", "char": "string", + "nchar": "string", "text": "string", - "nvarchar": "string", + "clob": "string", "boolean": "boolean", "bit": "boolean", "date": "date", "datetime": "timestamp", + "datetime2": "timestamp", "timestamp": "timestamp", + "time": "timestamp", } +# The maximum export we'll attempt to hold the object index for. iterparse streams, but the +# extracted-record index still grows with object count; ~50MB of XML is already an enormous +# model and a sane upper bound. Above it we warn and parse anyway (best-effort). +_SIZE_WARN_BYTES = 50 * 1024 * 1024 + def _base_type(erwin_type: str | None) -> str: if not erwin_type: @@ -67,175 +97,488 @@ def _base_type(erwin_type: str | None) -> str: return _ERWIN_TYPE.get(t, "string") -def _lc(tag: str) -> str: - return tag.split("}")[-1].lower() # strip namespace, lowercase +def _local(tag: str) -> str: + """Local element name, namespace stripped.""" + return tag.split("}")[-1] + + +@dataclass +class _Obj: + """A lightweight extracted record for one erwin object, kept in the id index so the + full XML element can be cleared from memory after extraction.""" + + kind: str # local element name, e.g. "Entity", "Relationship", "Domain" + id: str + props: dict[str, str] # single-value props: name -> text + refs: dict[str, str] # single refs: prop-name -> target GUID + ref_arrays: dict[str, list[str]] # array refs: prop-name -> ordered target GUIDs + children: list[_Obj] = field(default_factory=list) # nested objects (attrs, key groups) + + +@dataclass +class ErwinImport: + model: Model + warnings: list[str] = field(default_factory=list) + + +# --- streaming parse ------------------------------------------------------------ + + +def _extract(el: ET.Element) -> _Obj: + """Extract an object element (Entity / Relationship / ...) into a record. Reads its + `` child's properties (text values, single refs, ref arrays) and recurses + into nested `_Groups` for child objects (attributes, key groups, members).""" + obj = _Obj( + kind=_local(el.tag), + id=el.get("id") or new_ulid(), + props={}, + refs={}, + ref_arrays={}, + ) + for child in el: + name = _local(child.tag) + if name.endswith("Props"): + _read_props(child, obj) + elif name.endswith("_Groups"): + # a container of nested objects (Attribute_Groups -> Attribute, ...) + for sub in child: + if sub.get("id") is not None or list(sub): + obj.children.append(_extract(sub)) + return obj + + +def _read_props(props_el: ET.Element, obj: _Obj) -> None: + """Read a `` element's children into the object's props/refs/ref_arrays.""" + for p in props_el: + name = _local(p.tag) + if name.endswith("_Ref_Array"): + key = name[: -len("_Array")] # Foo_Ref_Array -> Foo_Ref + items: list[tuple[int, str]] = [] + for ref in p: + guid = (ref.text or "").strip() + if guid: + try: + idx = int(ref.get("index", "0")) + except ValueError: + idx = len(items) + items.append((idx, guid)) + obj.ref_arrays[key] = [g for _, g in sorted(items)] + elif name.endswith("_Ref"): + guid = (p.text or "").strip() + if guid: + obj.refs[name] = guid + else: + text = (p.text or "").strip() + if text: + obj.props[name] = text + + +# Top-level object element names we index (inside their *_Groups). Nested objects +# (Attribute, Key_Group, Key_Group_Member) are captured via _extract recursion. +_TOP_OBJECTS = frozenset( + {"Entity", "Relationship", "Subject_Area", "Domain", "View", "ER_Diagram"} +) + +# Object names that appear NESTED inside a mapped object (consumed by _extract, not at the +# Model level). Their `_Groups` fire an `end` before the parent object's, so they must not +# be reported as "skipped Model-level objects". +_NESTED_OBJECTS = frozenset( + { + "Attribute", + "Attribute_X", + "Key_Group", + "Key_Group_X", + "Key_Group_Member", + "Key_Sequence_Column", + "Check_Constraint_Usage", + "Constraint_X", + "Extended_Notes", + "History_Information", + "Display_Style_Sheet", + } +) + +def _parse_stream(source) -> tuple[dict[str, _Obj], list[_Obj], list[str]]: + """Stream the erwin XML, returning (id -> object index, top-level objects in order, + warnings). Clears each processed top-level element so a 10MB file never fully + materialises as a DOM.""" + index: dict[str, _Obj] = {} + ordered: list[_Obj] = [] + warnings: list[str] = [] + skipped_groups: set[str] = set() -def _attr(el: ET.Element, *names: str) -> str | None: - """Case-insensitive attribute lookup across candidate names.""" - lower = {k.lower(): v for k, v in el.attrib.items()} - for n in names: - if n.lower() in lower: - return lower[n.lower()] - return None + # iterparse fires `end` for a child BEFORE its parent, so we act only on TOP-LEVEL + # object elements (Entity, Relationship, …). Their _extract recurses into the still-intact + # nested groups (Attribute_Groups, Key_Group_Groups) from the subtree, then we clear the + # whole subtree. A `*_Groups` whose object is neither a top-level mapped object nor a + # known nested one is a genuinely-unmapped Model-level object type — record it once. + context = ET.iterparse(source, events=("end",)) + for _event, el in context: + name = _local(el.tag) + if name in _TOP_OBJECTS: + obj = _extract(el) + ordered.append(obj) + _index(obj, index) + el.clear() # release this object's subtree (children already extracted) + elif name.endswith("_Groups") and name != "Model": + base = name[: -len("_Groups")] + if base and base not in _TOP_OBJECTS and base not in _NESTED_OBJECTS: + skipped_groups.add(base) + + for base in sorted(skipped_groups): + warnings.append(f"skipped erwin objects of type {base!r} (not mapped to Modelith)") + return index, ordered, warnings + + +def _index(obj: _Obj, index: dict[str, _Obj]) -> None: + index[obj.id] = obj + for child in obj.children: + _index(child, index) + + +# --- value mapping -------------------------------------------------------------- + + +def _name_of(obj: _Obj, fallback: str | None = None) -> str | None: + return obj.props.get("Name") or fallback + + +def _is_key_group_pk(kg: _Obj) -> bool: + t = (kg.props.get("Key_Group_Type") or "").strip().lower() + return t in {"primary_key", "pk", "0", "primary"} -def _truthy(v: str | None) -> bool: - return str(v).strip().lower() in {"true", "1", "yes", "y"} - - -def _nullable(el: ET.Element, is_key: bool) -> bool: - """Resolve nullability from erwin's varied spellings. - - `notnull="true"` => not nullable - - `null="false"` => not nullable - - keys default to not-null; everything else defaults to nullable.""" - notnull = _attr(el, "notnull", "not_null") - if notnull is not None: - return not _truthy(notnull) - null = _attr(el, "null", "nullable") - if null is not None: - return _truthy(null) - return not is_key - - -def import_erwin(text: str, *, project_name: str | None = None) -> Model: - root = ET.fromstring(text) - model_name = project_name or _attr(root, "name") or "erwin_import" - model = Model(ProjectConfig(name=model_name)) - - subject_areas: dict[str, str] = {} # name -> ULID - sa_objs: dict[str, SubjectArea] = {} # name -> object, so members can be filled - # erwin's is a CONTAINER of objects; the flat subject_area="..." - # attribute is only its home tag. Nested / children carry the - # real many-to-many, so collect both shapes. - nested_members: dict[str, list[str]] = {} # SA name -> entity names - entities_by_name: dict[str, LogicalEntity] = {} - ce_by_name: dict[str, ConceptualEntity] = {} - - # Walk all descendants so we tolerate wrapper elements. - for el in root.iter(): - tag = _lc(el.tag) - if tag in ("subjectarea", "subject_area"): - name = _attr(el, "name") - if name and name not in subject_areas: - sa_id = new_ulid() - subject_areas[name] = sa_id - sa = SubjectArea(id=sa_id, name=name) - sa_objs[name] = sa - model.add(sa) - if name: - for child in el.iter(): - if child is el: - continue - if _lc(child.tag) not in ("entity", "entityref", "entity_ref", "table"): - continue - child_name = _attr(child, "name") - if child_name: - nested_members.setdefault(name, []).append(child_name) - - for el in root.iter(): - if _lc(el.tag) not in ("entity", "table"): +def _key_group_ir_type(kg: _Obj) -> str: + t = (kg.props.get("Key_Group_Type") or "").strip().lower() + if t in {"primary_key", "pk", "0", "primary"}: + return "pk" + if t in {"alternate_key", "ak", "alternate"}: + return "alternate" + if t in {"inversion_entry", "inversion", "index"}: + return "index" + return "unique" + + +def _cardinality(v: str | None) -> str: + m = (v or "").strip().lower() + if m in {"zero_or_one", "one", "1:1", "exactly_one"}: + return "one_to_one" + if m in {"one_or_more", "zero_one_or_more", "many", "1:n", "1:m"}: + return "many_to_one" + if m in {"many_to_many", "m:n", "m:m"}: + return "many_to_many" + return "many_to_one" + + +def _nullable(null_option: str | None, is_key: bool) -> bool: + m = (null_option or "").strip().lower() + if m in {"not_null", "notnull", "no_nulls", "1"}: + return False + if m in {"null", "nulls_allowed", "0"}: + return True + return not is_key # keys default not-null, everything else nullable + + +# --- build the Model ------------------------------------------------------------ + + +def import_erwin(source: str | Path, *, project_name: str | None = None) -> ErwinImport: + """Import a real erwin XML export into a Modelith Model. `source` may be a path, an + XML string, or an already-opened binary/text stream. Returns the model + warnings for + anything the import couldn't carry (views, transforms, layout, unknown groups).""" + stream, size_hint = _open(source) + warnings: list[str] = [] + if size_hint and size_hint > _SIZE_WARN_BYTES: + warnings.append( + f"erwin export is very large ({size_hint // (1024 * 1024)}MB); import may be slow" + ) + + index, ordered, parse_warnings = _parse_stream(stream) + warnings.extend(parse_warnings) + + model = Model(ProjectConfig(name=project_name or "erwin_import")) + + # pass 1: entities (+ conceptual), attributes, key groups, domains, subject areas. + domain_name_by_id: dict[str, str] = {} + for obj in ordered: + if obj.kind == "Domain": + dname = _name_of(obj) + if dname: + domain_name_by_id[obj.id] = dname + + ce_id_by_entity: dict[str, str] = {} # erwin entity id -> Modelith conceptual id + le_by_erwin_id: dict[str, LogicalEntity] = {} # erwin entity id -> LogicalEntity + attr_le_by_erwin_id: dict[str, str] = {} # erwin attribute id -> Modelith attribute id + attr_ir_by_erwin_id: dict[str, Attribute] = {} + + # domains first (attributes reference them by name) + for obj in ordered: + if obj.kind != "Domain": continue - name = _attr(el, "name") - if not name: + dname = _name_of(obj) + if not dname: continue - sa_name = _attr(el, "subject_area", "subjectarea") - ce_id = new_ulid() + model.add( + Domain( + id=new_ulid(), + name=dname, + base_type=_base_type( + obj.props.get("Logical_Data_Type") or obj.props.get("Physical_Data_Type") + ), + definition=obj.props.get("Definition"), + ) + ) + + for obj in ordered: + if obj.kind != "Entity": + continue + ename = _name_of(obj) + if not ename: + continue + # which attributes are in the pk key group -> business_key role + pk_attr_ids: set[str] = set() + for kg in obj.children: + if kg.kind == "Key_Group" and _is_key_group_pk(kg): + for member in kg.children: + ar = member.refs.get("Attribute_Ref") + if ar: + pk_attr_ids.add(ar) + + attrs: list[Attribute] = [] + for a in obj.children: + if a.kind != "Attribute": + continue + aname = _name_of(a) + if not aname: + continue + is_key = a.id in pk_attr_ids + domain_ref = a.refs.get("Parent_Domain_Ref") + domain = domain_name_by_id.get(domain_ref) if domain_ref else None + if not domain: + raw_type = a.props.get("Logical_Data_Type") or a.props.get("Physical_Data_Type") + domain = _base_type(raw_type) + attr = Attribute( + id=new_ulid(), + name=aname, + physical_name=a.props.get("Physical_Name"), + definition=a.props.get("Definition"), + domain=domain, + role="business_key" if is_key else "attribute", + nullable=_nullable(a.props.get("Null_Option_Type"), is_key), + ) + attrs.append(attr) + attr_le_by_erwin_id[a.id] = attr.id + attr_ir_by_erwin_id[a.id] = attr + ce = ConceptualEntity( - id=ce_id, - name=_titleize(name), - subject_area=subject_areas.get(sa_name) if sa_name else None, - definition=_attr(el, "definition", "comment"), + id=new_ulid(), name=_titleize(ename), definition=obj.props.get("Definition") ) - ce_by_name[name] = ce model.add(ce) + ce_id_by_entity[obj.id] = ce.id + le = LogicalEntity( + id=new_ulid(), + # a logical name is a dbt-ish identifier: lowercase the erwin display name + # (the proper-case original is preserved as the conceptual name + physical_name). + name=_ident(ename), + physical_name=obj.props.get("Physical_Name") or ename, + realises=ce.id, + definition=obj.props.get("Definition"), + attributes=attrs, + ) + model.add(le) + le_by_erwin_id[obj.id] = le - attrs = [] - for child in el.iter(): - if _lc(child.tag) not in ("attribute", "column"): + # non-pk key groups -> KeyGroup objects (pk keys are also emitted, for clarity) + for kg in obj.children: + if kg.kind != "Key_Group": continue - aname = _attr(child, "name") - if not aname: + members = [ + attr_le_by_erwin_id[m.refs["Attribute_Ref"]] + for m in kg.children + if m.refs.get("Attribute_Ref") in attr_le_by_erwin_id + ] + if not members: continue - is_key = _truthy(_attr(child, "key", "primary_key", "pk")) - nullable = _nullable(child, is_key) - attrs.append( - Attribute( + model.add( + KeyGroup( id=new_ulid(), - name=aname, - domain=_base_type(_attr(child, "datatype", "type", "domain")), - role="business_key" if is_key else "attribute", - nullable=nullable, + entity=le.id, + name=_name_of(kg) or f"{_key_group_ir_type(kg)}_{ename}", + type=_key_group_ir_type(kg), + members=members, ) ) - le = LogicalEntity(id=new_ulid(), name=name.lower(), realises=ce_id, attributes=attrs) - entities_by_name[name] = le - model.add(le) - # seed members from the home tag, so a flat export round-trips cleanly and - # imports free of MDL-W114 - if sa_name and sa_name in sa_objs and ce_id not in sa_objs[sa_name].members: - sa_objs[sa_name].members.append(ce_id) - - # nested membership: an entity may appear under several subject areas - for sa_name, names in nested_members.items(): - sa = sa_objs.get(sa_name) - if sa is None: + + # FK-domain inheritance: a migrated FK column (Parent_Attribute_Ref) inherits its + # parent PK column's domain, so the domains match (erwin migrates the type; a bare + # child column would otherwise default to 'string' and fail FK domain validation). + for erwin_aid, attr in attr_ir_by_erwin_id.items(): + src = index.get(erwin_aid) + if src is None: continue - for n in names: - ce = ce_by_name.get(n) - if ce is not None and ce.id not in sa.members: - sa.members.append(ce.id) + parent_ref = src.refs.get("Parent_Attribute_Ref") + if parent_ref and parent_ref in attr_ir_by_erwin_id: + attr.domain = attr_ir_by_erwin_id[parent_ref].domain + + # pass 2: relationships (+ detect subtype -> Category) and subject-area membership. + _build_relationships( + ordered, index, model, le_by_erwin_id, attr_le_by_erwin_id, ce_id_by_entity, warnings + ) + _build_subject_areas(ordered, model, ce_id_by_entity, warnings) + + # notes for what erwin had that we dropped + if any(o.kind == "ER_Diagram" for o in ordered): + warnings.append("ER diagram layout (positions) was not imported; Modelith auto-layouts") + if any(o.kind == "View" for o in ordered): + warnings.append("erwin views were not imported (Modelith models entities, not views)") + + if not model.logical_entities: + warnings.append( + "no entities found — is this a valid erwin export? " + "(expected a namespaced … document)" + ) + + return ErwinImport(model=model, warnings=warnings) + - for el in root.iter(): - if _lc(el.tag) not in ("relationship", "fk", "foreignkey"): +def _build_relationships( + ordered, index, model, le_by_erwin_id, attr_le_by_erwin_id, ce_id_by_entity, warnings +) -> None: + subtype_children_by_parent: dict[str, list[str]] = {} + subtype_discriminator_by_parent: dict[str, str] = {} # parent erwin id -> attr erwin id + for obj in ordered: + if obj.kind != "Relationship": continue - parent = _attr(el, "parent", "parent_entity", "to") # one side - child = _attr(el, "child", "child_entity", "from") # many side - p_le = entities_by_name.get(parent) if parent else None - c_le = entities_by_name.get(child) if child else None + parent = obj.refs.get("Parent_Entity_Ref") + child = obj.refs.get("Child_Entity_Ref") + p_le = le_by_erwin_id.get(parent) if parent else None + c_le = le_by_erwin_id.get(child) if child else None if not p_le or not c_le: + warnings.append( + f"relationship {_name_of(obj) or obj.id!r} references an unknown entity; skipped" + ) + continue + + rtype = (obj.props.get("Type") or "").strip().lower() + disc_ref = obj.refs.get("Subtype_Discriminator_Ref") + if "subtype" in rtype or disc_ref: + # supertype = parent, subtype = child; collect for a Category below + subtype_children_by_parent.setdefault(parent, []).append(child) + if disc_ref and parent not in subtype_discriminator_by_parent: + subtype_discriminator_by_parent[parent] = disc_ref continue - child_attr = _attr(el, "child_attr", "fk_column", "column") - from_attr = _attr_id(c_le, child_attr) if child_attr else None + + # FK attribute mapping: prefer a child attribute whose Parent_Relationship_Ref is + # this relationship; else fall back to the relationship's own Attribute_Ref. + from_attr = _fk_child_attr(index, obj.id, child, attr_le_by_erwin_id) to_bk = next((a.id for a in p_le.attributes if a.role == "business_key"), None) - rel_name = _attr(el, "name") or f"{c_le.name}_has_{p_le.name}" model.add( Relationship( id=new_ulid(), - name=rel_name, + name=_name_of(obj) or f"{c_le.name}_has_{p_le.name}", + definition=obj.props.get("Definition"), **{ "from": RelationshipEnd( entity=c_le.id, attributes=[from_attr] if from_attr else [] ) }, to=RelationshipEnd(entity=p_le.id, attributes=[to_bk] if to_bk else []), - cardinality=_cardinality(_attr(el, "cardinality")), + cardinality=_cardinality(obj.props.get("Cardinality")), + identifying="identifying" in rtype, + verb_phrase=obj.props.get("Parent_To_Child_Verb_Phrase"), + inverse_verb_phrase=obj.props.get("Child_To_Parent_Verb_Phrase"), ) ) - return model - -def import_erwin_file(path: str | Path, **kw) -> Model: - return import_erwin(Path(path).read_text(encoding="utf-8"), **kw) + # emit a Category per supertype that had subtype relationships + for parent_id, child_ids in subtype_children_by_parent.items(): + sup = le_by_erwin_id.get(parent_id) + subs = [le_by_erwin_id[c].id for c in child_ids if c in le_by_erwin_id] + if sup is None or not subs: + continue + disc_erwin = subtype_discriminator_by_parent.get(parent_id) + disc = attr_le_by_erwin_id.get(disc_erwin) if disc_erwin else None + # The default materialization (single_table) needs a discriminator; when erwin + # gave none, use table_per_subtype so the model still validates. + model.add( + Category( + id=new_ulid(), + name=f"{sup.name}_category", + supertype=sup.id, + subtypes=subs, + discriminator=disc, + materialization="single_table" if disc else "table_per_subtype", + ) + ) -def _attr_id(le: LogicalEntity, col: str) -> str | None: - for a in le.attributes: - if a.name == col: - return a.id +def _fk_child_attr(index, rel_id, child_entity_id, attr_le_by_erwin_id) -> str | None: + """The child attribute that migrated from this relationship (its + Parent_Relationship_Ref points at rel_id), mapped to the Modelith attribute id.""" + child = index.get(child_entity_id) + if child is None: + return None + for a in child.children: + if a.kind == "Attribute" and a.refs.get("Parent_Relationship_Ref") == rel_id: + mapped = attr_le_by_erwin_id.get(a.id) + if mapped: + return mapped return None -def _cardinality(v: str | None) -> str: - m = (v or "").strip().lower() - if m in {"one_to_one", "1:1"}: - return "one_to_one" - if m in {"one_to_many", "1:m", "1:n"}: - return "one_to_many" - if m in {"many_to_many", "m:n", "m:m"}: - return "many_to_many" - return "many_to_one" +def _build_subject_areas(ordered, model, ce_id_by_entity, warnings) -> None: + for obj in ordered: + if obj.kind != "Subject_Area": + continue + sname = _name_of(obj) + if not sname: + continue + member_guids: list[str] = [] + for key in ("User_Attached_Objects_Ref", "Auto_Attached_Objects_Ref"): + member_guids.extend(obj.ref_arrays.get(key, [])) + members: list[str] = [] + seen: set[str] = set() + for guid in member_guids: + ce = ce_id_by_entity.get(guid) # only keep refs that resolve to an entity + if ce and ce not in seen: + seen.add(ce) + members.append(ce) + model.add( + SubjectArea( + id=new_ulid(), + name=sname, + definition=obj.props.get("Definition"), + members=members, + ) + ) + + +def _open(source: str | Path): + """Return (stream, size_hint_bytes|None) for iterparse. Accepts a path, an XML string, + or an open stream.""" + if isinstance(source, Path): + return str(source), source.stat().st_size if source.exists() else None + if isinstance(source, str): + # a filesystem path vs raw XML: a path won't start with '<' and will exist + p = Path(source) + if len(source) < 4096 and not source.lstrip().startswith("<") and p.exists(): + return source, p.stat().st_size + return StringIO(source), len(source.encode("utf-8")) + if isinstance(source, bytes): + return BytesIO(source), len(source) + return source, None # already a stream + + +def import_erwin_file(path: str | Path, **kw) -> ErwinImport: + return import_erwin(Path(path), **kw) def _titleize(name: str) -> str: return "".join(p.capitalize() for p in name.replace(" ", "_").split("_")) + + +def _ident(name: str) -> str: + """A dbt-ish logical identifier from an erwin display name: lowercase, spaces to + underscores. 'Counterparty' -> 'counterparty', 'Trade Event' -> 'trade_event'.""" + return name.strip().lower().replace(" ", "_") diff --git a/packages/reverse/tests/test_erwin.py b/packages/reverse/tests/test_erwin.py index bce52c5..77ab210 100644 --- a/packages/reverse/tests/test_erwin.py +++ b/packages/reverse/tests/test_erwin.py @@ -1,4 +1,11 @@ -"""erwin XML import tests (spec §6.4).""" +"""erwin XML import — against the REAL erwin export shape (spec §6.4). + +The fixture mirrors real erwin: namespaced, GUID-referenced, properties as element text +inside , keys as per-entity Key_Group, relationships linking entities by +Parent/Child_Entity_Ref, subject-area membership via attached-object ref arrays, and a +subtype relationship that becomes a Category. Unknown groups (a View, an ER_Diagram) are +skipped with warnings, not errors. +""" from __future__ import annotations @@ -6,98 +13,222 @@ from mdl_core.validate import validate from mdl_reverse.erwin import import_erwin -_ERWIN = """\ - - - - - - - - - - - - +_ERWIN = """ + + + TradingModel + + + id_typeBIGINT + + + + + CounterpartyCOUNTERPARTY + A trading partner. + + + + counterparty_idD1 + Not_Null + + + legal_nameVARCHAR(255) + + + + + pk_counterpartyPrimary_Key + + + + A_CP1 + + + + + + Trade + + trade_id + + counterparty_id + R1 + A_CP1 + + + + + pk_tradePrimary_Key + + A_TR1 + + + + + Institution + + counterparty_id + + + + + + trade_has_counterparty + E_CPE_TR + Zero_One_or_More + is party to + is with + + + cpty_is_institutionSubtype_Complete + E_CPE_INST + A_CP2 + + + + + Trading + + E_CP + E_TR + + + + v_active_trades + + Main + + """ -def test_import_erwin_entities_and_attributes(): - m = import_erwin(_ERWIN) +def _import(): + return import_erwin(_ERWIN) + + +def test_entities_and_attributes(): + r = _import() + m = r.model names = {le.name for le in m.logical_entities.values()} - assert names == {"counterparty", "trade"} - cpty = next(le for le in m.logical_entities.values() if le.name == "counterparty") - bk = next(a for a in cpty.attributes if a.name == "counterparty_id") + assert names == {"counterparty", "trade", "institution"} + cp = next(le for le in m.logical_entities.values() if le.name == "counterparty") + assert cp.physical_name == "COUNTERPARTY" + bk = next(a for a in cp.attributes if a.name == "counterparty_id") assert bk.role == "business_key" assert bk.nullable is False - assert bk.domain == "bigint" + assert bk.domain == "id_type" # resolved via Parent_Domain_Ref + ln = next(a for a in cp.attributes if a.name == "legal_name") + assert ln.domain == "string" and ln.nullable is True + +def test_primary_key_group(): + m = _import().model + cp = next(le for le in m.logical_entities.values() if le.name == "counterparty") + kg = next(k for k in m.key_groups.values() if k.entity == cp.id and k.type == "pk") + assert len(kg.members) == 1 + assert kg.members[0] == cp.attributes[0].id # counterparty_id -def test_import_erwin_subject_area(): - m = import_erwin(_ERWIN) - assert len(m.subject_areas) == 1 + +def test_relationship_with_verb_phrases(): + m = _import().model + rel = next(r for r in m.relationships.values() if r.name == "trade_has_counterparty") + trade = next(le for le in m.logical_entities.values() if le.name == "trade") + cp = next(le for le in m.logical_entities.values() if le.name == "counterparty") + assert rel.from_.entity == trade.id # child = many side + assert rel.to.entity == cp.id # parent = one side + assert rel.verb_phrase == "is party to" + assert rel.inverse_verb_phrase == "is with" + # the FK child attribute (Parent_Relationship_Ref -> R1) is the from-attribute + fk = next(a for a in trade.attributes if a.name == "counterparty_id") + assert rel.from_.attributes == [fk.id] + + +def test_subtype_becomes_category(): + m = _import().model + cp = next(le for le in m.logical_entities.values() if le.name == "counterparty") + inst = next(le for le in m.logical_entities.values() if le.name == "institution") + cat = next(iter(m.categories.values())) + assert cat.supertype == cp.id + assert inst.id in cat.subtypes + # the subtype relationship is NOT also a plain relationship + assert all(r.name != "cpty_is_institution" for r in m.relationships.values()) + + +def test_subject_area_membership(): + m = _import().model sa = next(iter(m.subject_areas.values())) assert sa.name == "Trading" - cpty_ce = next(c for c in m.conceptual_entities.values() if c.name == "Counterparty") - assert cpty_ce.subject_area == sa.id + cp = next(le for le in m.logical_entities.values() if le.name == "counterparty") + tr = next(le for le in m.logical_entities.values() if le.name == "trade") + assert cp.realises in sa.members + assert tr.realises in sa.members -def test_import_erwin_relationship(): - m = import_erwin(_ERWIN) - assert len(m.relationships) == 1 - rel = next(iter(m.relationships.values())) - assert rel.cardinality == "many_to_one" - # many side = Trade, one side = Counterparty - trade = next(le for le in m.logical_entities.values() if le.name == "trade") - cpty = next(le for le in m.logical_entities.values() if le.name == "counterparty") - assert rel.from_.entity == trade.id - assert rel.to.entity == cpty.id +def test_domain_imported(): + m = _import().model + assert any(d.name == "id_type" for d in m.domains.values()) -def test_imported_erwin_model_validates(): - m = import_erwin(_ERWIN) - diags = validate(m) - assert not diags.has(Severity.error), [d.message for d in diags.items] +def test_unknown_objects_warn_not_fail(): + r = _import() + joined = " ".join(r.warnings).lower() + assert "view" in joined # a View was skipped + assert "layout" in joined or "diagram" in joined # ER_Diagram layout dropped + # nested Attribute/Key_Group are NOT reported as skipped Model-level objects + assert "attribute" not in joined + assert "key_group" not in joined -def test_import_erwin_tolerates_unknown_elements(): - xml = """\ - - - - ignored - -""" - m = import_erwin(xml) - assert len(m.logical_entities) == 1 - - -def test_import_populates_subject_area_members(): - """erwin's is a container of objects, not just a tag. The flat - form seeds members from the home tag; the nested form carries the real - many-to-many, where one entity sits in several areas.""" - xml = """ - - - - - - - - - - - - - - """ - model = import_erwin(xml) - by_name = {sa.name: sa for sa in model.subject_areas.values()} - ce = {c.name: c.id for c in model.conceptual_entities.values()} - - # flat form: seeded from the home tag - assert set(by_name["Trading"].members) == {ce["Counterparty"], ce["Trade"]} - # nested form: the same objects also belong to the use-case view - assert set(by_name["UseCase-StressTesting"].members) == {ce["Counterparty"], ce["Trade"]} - assert validate(model).by_min_severity(Severity.error) == [] +def test_imported_model_validates(): + m = _import().model + diags = validate(m) + # a clean import should not produce ERROR diagnostics (warnings are fine) + assert not diags.has(Severity.error), [d.message for d in diags.items if d.severity == Severity.error] + + +def _big_erwin(n_entities: int) -> str: + """Generate a large real-shaped erwin export with n_entities, each with 8 attributes + and a pk, plus a chain of relationships — to exercise the streaming parser.""" + parts = [ + '', + '', + '', + "Big", + ] + for i in range(n_entities): + attrs = "".join( + f'col_{j}' + f"VARCHAR(50)" + for j in range(8) + ) + parts.append( + f'entity_{i}' + f"id_{i}" + f"{attrs}" + f'pk_{i}' + f"Primary_Key" + f'' + f"A{i}_pk" + f"" + ) + parts.append("") + for i in range(1, n_entities): + parts.append( + f'r_{i}' + f"E{i - 1}" + f"E{i}" + ) + parts.append("") + return "".join(parts) + + +def test_streaming_large_export(): + # ~a few thousand entities: a multi-MB document the streaming parser handles. + xml = _big_erwin(2000) + assert len(xml.encode()) > 1_000_000 # >1MB + r = import_erwin(xml) + assert len(r.model.logical_entities) == 2000 + assert len(r.model.relationships) == 1999 + # every entity got its pk key group + assert sum(1 for k in r.model.key_groups.values() if k.type == "pk") == 2000 From 827b54352525c9656982eb2f74ba1232bf61ea38 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 26 Sep 2026 18:48:45 +1000 Subject: [PATCH 3/9] =?UTF-8?q?imports:=20model=5Fto=5Fcommands=20?= =?UTF-8?q?=E2=80=94=20rich=20IR=20Model=20=E2=86=92=20command=20list=20(e?= =?UTF-8?q?rwin's=20apply=20bridge)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas /api/import and the CLI apply path consume to_commands's {op,payload} list, but to_commands only accepts the thin table-oriented ImportedModel. Erwin produces a full IR Model (subject areas, categories, domains, conceptual entities, keys) that would be flattened away going through ImportedModel. model_to_commands(model) walks a full Model and emits the same op vocabulary (create_subject_area, create_domain, create_entity with subject-area home tag, set_subject_area_members for the inclusion list, add_attribute, create_key_group, create_relationship, create_category with discriminator), minting fresh ULIDs in a dependency-safe order. This lets the erwin importer ride the exact preview/apply path the simple importers use, without losing its richness. 3 round-trip tests (structure reconstructs, the result validates clean, a category with a discriminator round-trips). apply_command reconstructs the model faithfully. --- .../src/mdl_emit_erd/imports/model.py | 156 +++++++++++++++++- .../emit-erd/tests/test_model_to_commands.py | 107 ++++++++++++ 2 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 packages/emit-erd/tests/test_model_to_commands.py diff --git a/packages/emit-erd/src/mdl_emit_erd/imports/model.py b/packages/emit-erd/src/mdl_emit_erd/imports/model.py index ffaebc7..d7c624c 100644 --- a/packages/emit-erd/src/mdl_emit_erd/imports/model.py +++ b/packages/emit-erd/src/mdl_emit_erd/imports/model.py @@ -1,14 +1,23 @@ """Neutral imported-model view + conversion to a Modelith command list. The parsers all produce an ImportedModel; `to_commands()` is the single place that -maps it onto the editing engine's ops, so every import path behaves identically.""" +maps it onto the editing engine's ops, so every import path behaves identically. + +`model_to_commands()` is the richer sibling: it turns a full `mdl_core.ir.Model` (subject +areas, categories, domains, conceptual entities, keys, relationships — everything a thin +ImportedModel drops) into the same op vocabulary, so a rich importer (erwin) can ride the +exact preview/apply path the simple ones use without flattening its structure away.""" from __future__ import annotations from dataclasses import dataclass, field +from typing import TYPE_CHECKING from mdl_core.ids import new_ulid +if TYPE_CHECKING: + from mdl_core.ir import Model + @dataclass class ImportedColumn: @@ -130,3 +139,148 @@ def to_commands(m: ImportedModel) -> list[dict]: commands.append({"op": "create_relationship", "payload": payload}) return commands + + +def model_to_commands(model: Model) -> list[dict]: + """Turn a full IR Model into an ordered {op, payload} change list that `apply_command` + reconstructs, preserving subject areas, domains, keys, relationships, and categories. + + Fresh ULIDs are minted for every object (the source Model's ids are not reused), and + order is dependency-safe: subject areas + domains, then entities (carrying their + subject-area membership), then attributes, key groups, relationships, and categories + that reference them.""" + commands: list[dict] = [] + ent_id: dict[str, str] = {} # source logical-entity id -> new entity id + attr_id: dict[str, str] = {} # source attribute id -> new attribute id + sa_id: dict[str, str] = {} # source subject-area id -> new subject-area id + ce_id: dict[str, str] = {} # source conceptual id -> new conceptual id + + # 1. subject areas (entities reference them at create time) + for sa in model.subject_areas.values(): + nid = new_ulid() + sa_id[sa.id] = nid + p: dict = {"id": nid, "name": sa.name} + if sa.definition: + p["definition"] = sa.definition + commands.append({"op": "create_subject_area", "payload": p}) + + # which subject area each conceptual entity belongs to (SA.members holds conceptual ids) + ce_to_sa: dict[str, str] = {} + for sa in model.subject_areas.values(): + for ce in sa.members: + ce_to_sa.setdefault(ce, sa_id[sa.id]) + + # 2. domains + for dom in model.domains.values(): + p = {"id": new_ulid(), "name": dom.name, "base_type": dom.base_type} + if dom.definition: + p["definition"] = dom.definition + if dom.allowed_values: + p["allowed_values"] = list(dom.allowed_values) + commands.append({"op": "create_domain", "payload": p}) + + # 3. entities (+ their subject-area home tag via the conceptual entity) + for le in model.logical_entities.values(): + nid = new_ulid() + ent_id[le.id] = nid + new_ce = new_ulid() + if le.realises: + ce_id[le.realises] = new_ce + p = {"id": nid, "name": le.name, "conceptual_id": new_ce, "display_name": le.name} + if le.definition: + p["definition"] = le.definition + sa_for = ce_to_sa.get(le.realises) if le.realises else None + if sa_for: + p["subject_area"] = sa_for + commands.append({"op": "create_entity", "payload": p}) + + # 3b. subject-area membership (the inclusion list; distinct from the home tag). Emitted + # after entities so the new conceptual ids exist. Members are conceptual ids. + for sa in model.subject_areas.values(): + members = [ce_id[ce] for ce in sa.members if ce in ce_id] + if members: + commands.append( + { + "op": "set_subject_area_members", + "payload": {"id": sa_id[sa.id], "members": members}, + } + ) + + # 4. attributes + for le in model.logical_entities.values(): + for a in le.attributes: + aid = new_ulid() + attr_id[a.id] = aid + commands.append( + { + "op": "add_attribute", + "payload": { + "entity_id": ent_id[le.id], + "id": aid, + "name": a.name, + "domain": a.domain or "string", + "nullable": a.nullable, + "role": a.role, + }, + } + ) + + # 5. key groups (pk + alternate/unique) + for kg in model.key_groups.values(): + if kg.entity not in ent_id: + continue + members = [attr_id[m] for m in kg.members if m in attr_id] + if not members: + continue + commands.append( + { + "op": "create_key_group", + "payload": { + "entity": ent_id[kg.entity], + "id": new_ulid(), + "name": kg.name, + "type": kg.type, + "members": members, + }, + } + ) + + # 6. relationships + for rel in model.relationships.values(): + if rel.from_.entity not in ent_id or rel.to.entity not in ent_id: + continue + p = { + "id": new_ulid(), + "name": rel.name, + "from_entity": ent_id[rel.from_.entity], + "to_entity": ent_id[rel.to.entity], + "cardinality": rel.cardinality, + } + if rel.from_.attributes and rel.from_.attributes[0] in attr_id: + p["from_attribute"] = attr_id[rel.from_.attributes[0]] + if rel.to.attributes and rel.to.attributes[0] in attr_id: + p["to_attribute"] = attr_id[rel.to.attributes[0]] + commands.append({"op": "create_relationship", "payload": p}) + + # 7. categories (subtype/supertype) + for cat in model.categories.values(): + if cat.supertype not in ent_id: + continue + subs = [ent_id[s] for s in cat.subtypes if s in ent_id] + if not subs: + continue + p = { + "id": new_ulid(), + "name": cat.name, + "supertype": ent_id[cat.supertype], + "subtypes": subs, + "materialization": cat.materialization, + } + if cat.discriminator and cat.discriminator in attr_id: + p["discriminator"] = attr_id[cat.discriminator] + else: + # no discriminator carried -> use table_per_subtype so it applies cleanly + p["materialization"] = "table_per_subtype" + commands.append({"op": "create_category", "payload": p}) + + return commands diff --git a/packages/emit-erd/tests/test_model_to_commands.py b/packages/emit-erd/tests/test_model_to_commands.py new file mode 100644 index 0000000..a9302e4 --- /dev/null +++ b/packages/emit-erd/tests/test_model_to_commands.py @@ -0,0 +1,107 @@ +"""model_to_commands: a rich IR Model -> command list that apply_command reconstructs. + +This is the bridge that lets the erwin importer (which produces a full Model, not a thin +table view) ride the canvas/CLI apply path without flattening subject areas, categories, +domains, or keys away. +""" + +from __future__ import annotations + +from pathlib import Path + +from mdl_emit_erd.imports.model import model_to_commands + +from mdl_core.commands import apply_command +from mdl_core.diagnostics import Severity +from mdl_core.ids import new_ulid +from mdl_core.ir import ( + Attribute, + Category, + ConceptualEntity, + Domain, + KeyGroup, + LogicalEntity, + Model, + ProjectConfig, + Relationship, + RelationshipEnd, + SubjectArea, +) +from mdl_core.repo import ModelRepo +from mdl_core.validate import validate + + +def _source_model() -> Model: + m = Model(ProjectConfig(name="src", dbt_target="duckdb")) + m.add(Domain(id=new_ulid(), name="id_type", base_type="bigint")) + ce_c = ConceptualEntity(id=new_ulid(), name="Customer") + ce_o = ConceptualEntity(id=new_ulid(), name="Order") + m.add(ce_c) + m.add(ce_o) + a_cid = Attribute(id=new_ulid(), name="customer_id", role="business_key", domain="id_type") + cust = LogicalEntity(id=new_ulid(), name="customer", realises=ce_c.id, attributes=[a_cid]) + a_oid = Attribute(id=new_ulid(), name="order_id", role="business_key", domain="id_type") + a_ofk = Attribute(id=new_ulid(), name="customer_id", domain="id_type") + order = LogicalEntity(id=new_ulid(), name="order", realises=ce_o.id, attributes=[a_oid, a_ofk]) + m.add(cust) + m.add(order) + m.add(KeyGroup(id=new_ulid(), entity=cust.id, name="pk_customer", type="pk", members=[a_cid.id])) + m.add(KeyGroup(id=new_ulid(), entity=order.id, name="pk_order", type="pk", members=[a_oid.id])) + m.add( + Relationship( + id=new_ulid(), name="order_placed_by_customer", + **{"from": RelationshipEnd(entity=order.id, attributes=[a_ofk.id])}, + to=RelationshipEnd(entity=cust.id, attributes=[a_cid.id]), + verb_phrase="places", + ) + ) + m.add(SubjectArea(id=new_ulid(), name="Sales", members=[ce_c.id, ce_o.id])) + return m + + +def _apply(model: Model, tmp_path: Path) -> Model: + (tmp_path / "mdl-project.yaml").write_text("name: t\ndbt_target: duckdb\n") + for c in model_to_commands(model): + apply_command(tmp_path, c["op"], c["payload"]) + return ModelRepo.load(tmp_path).model + + +def test_round_trip_reconstructs_structure(tmp_path): + out = _apply(_source_model(), tmp_path) + assert {le.name for le in out.logical_entities.values()} == {"customer", "order"} + assert sum(1 for k in out.key_groups.values() if k.type == "pk") == 2 + assert len(out.relationships) == 1 + assert any(d.name == "id_type" for d in out.domains.values()) + sa = next(iter(out.subject_areas.values())) + assert sa.name == "Sales" and len(sa.members) == 2 + + +def test_reconstructed_model_validates(tmp_path): + out = _apply(_source_model(), tmp_path) + diags = validate(out) + assert not diags.has(Severity.error), [ + d.message for d in diags.items if d.severity == Severity.error + ] + + +def test_category_with_discriminator_round_trips(tmp_path): + m = Model(ProjectConfig(name="src", dbt_target="duckdb")) + ce = ConceptualEntity(id=new_ulid(), name="Party") + ce_p = ConceptualEntity(id=new_ulid(), name="Person") + m.add(ce) + m.add(ce_p) + disc = Attribute(id=new_ulid(), name="party_type", role="attribute", domain="string") + pk = Attribute(id=new_ulid(), name="party_id", role="business_key", domain="string") + party = LogicalEntity(id=new_ulid(), name="party", realises=ce.id, attributes=[pk, disc]) + person = LogicalEntity(id=new_ulid(), name="person", realises=ce_p.id, + attributes=[Attribute(id=new_ulid(), name="party_id", role="business_key", + domain="string")]) + m.add(party) + m.add(person) + m.add(KeyGroup(id=new_ulid(), entity=party.id, name="pk_party", type="pk", members=[pk.id])) + m.add(Category(id=new_ulid(), name="party_category", supertype=party.id, + subtypes=[person.id], discriminator=disc.id, materialization="single_table")) + out = _apply(m, tmp_path) + cat = next(iter(out.categories.values())) + assert cat.discriminator is not None + assert not validate(out).has(Severity.error) From 006ff61364818c3bea274ee09323a36be310c6b3 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 26 Sep 2026 18:51:13 +1000 Subject: [PATCH 4/9] =?UTF-8?q?cli:=20real=20`mdl=20import=20erwin`=20?= =?UTF-8?q?=E2=80=94=20write=20a=20model=20(default)=20or=20--apply=20into?= =?UTF-8?q?=20an=20existing=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the erwin import command for the new streaming reader + rich Model: - passes the file PATH to import_erwin so the 7-10MB streaming path is used - default: write a fresh model at --out (an erwin export is a whole model, like reverse), with the same no-clobber divert to a -reversed-v sibling unless --force - --apply: merge the erwin objects INTO an existing model at --out via model_to_commands -> apply_command (the validated mutation engine), for adding erwin to a model you have - surfaces the reader's warnings as yellow note: lines 3 CLI tests: write-a-model (validates, notes shown), --apply into an existing model (merges, validates), and the divert-on-existing behaviour. 471 tests pass across reverse/cli/emit-erd/core. --- packages/cli/src/mdl_cli/main.py | 52 +++++++++++++++-- packages/cli/tests/test_import_erwin_cli.py | 63 +++++++++++++++++++++ 2 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 packages/cli/tests/test_import_erwin_cli.py diff --git a/packages/cli/src/mdl_cli/main.py b/packages/cli/src/mdl_cli/main.py index fb5f4f3..157663b 100644 --- a/packages/cli/src/mdl_cli/main.py +++ b/packages/cli/src/mdl_cli/main.py @@ -2727,16 +2727,56 @@ def import_osi_cmd( @import_app.command("erwin") def import_erwin_cmd( - file: Path = typer.Argument(..., help="erwin XML export"), + file: Path = typer.Argument(..., help="erwin XML export (.xml)"), out: Path = typer.Option(Path("model"), "--out", "-o"), name: str = typer.Option(None, "--name"), + apply: bool = typer.Option( + False, + "--apply", + help="Merge the erwin model INTO the existing model at --out via the editing " + "engine, instead of writing a fresh model. Use when adding erwin objects to a " + "model you already have.", + ), + force: bool = typer.Option( + False, "--force", help="Overwrite --out in place instead of diverting to a sibling." + ), ) -> None: - """Import an erwin XML export into the IR (spec §6.4).""" - model = import_erwin(file.read_text(encoding="utf-8"), project_name=name) - write_reversed(model, out) + """Import a real erwin XML export into a Modelith model (spec §6.4). + + Default: write a fresh model at --out (like `mdl reverse` — an erwin export is a whole + model). If --out already holds a model it diverts to a `-reversed-v` sibling unless + --force. With --apply, merge the erwin objects into the existing model at --out through + the same validated mutation engine manual edits use.""" + # streaming reader handles 7-10MB files; pass the PATH so it reads the file directly. + result = import_erwin(file, project_name=name) + for w in result.warnings: + typer.secho(f" note: {w}", fg=typer.colors.YELLOW) + + if apply: + from mdl_emit_erd.imports.model import model_to_commands + + from mdl_core.commands import CommandError, apply_command + + applied = 0 + for c in model_to_commands(result.model): + try: + apply_command(out, c["op"], c["payload"]) + applied += 1 + except (CommandError, FileNotFoundError) as e: + typer.secho(f" skipped {c['op']}: {e}", fg=typer.colors.YELLOW) + typer.secho( + f"merged {len(result.model.logical_entities)} entities from erwin into {out} " + f"({applied} change(s) applied)", + fg=typer.colors.GREEN, + ) + return + + if not force: + out = _nonclobbering_out(out) + write_reversed(result.model, out) typer.secho( - f"imported {len(model.logical_entities)} entities, " - f"{len(model.relationships)} relationships from erwin to {out}", + f"imported {len(result.model.logical_entities)} entities, " + f"{len(result.model.relationships)} relationships from erwin to {out}", fg=typer.colors.GREEN, ) diff --git a/packages/cli/tests/test_import_erwin_cli.py b/packages/cli/tests/test_import_erwin_cli.py new file mode 100644 index 0000000..e0fee13 --- /dev/null +++ b/packages/cli/tests/test_import_erwin_cli.py @@ -0,0 +1,63 @@ +"""CLI: `mdl import erwin` — write-a-model default + --apply into an existing model.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from typer.testing import CliRunner + +from mdl_cli.main import app + +runner = CliRunner() + +# the real-shaped erwin fixture (reuse the reader's test fixture) +_FIXTURE = ( + Path(__file__).resolve().parents[2] / "reverse" / "tests" / "test_erwin.py" +) +sys.path.insert(0, str(_FIXTURE.parent)) + + +def _erwin_xml() -> str: + text = _FIXTURE.read_text() + return text.split('_ERWIN = """')[1].split('"""')[0] + + +def test_import_erwin_writes_a_model(tmp_path): + xml = tmp_path / "trading.xml" + xml.write_text(_erwin_xml()) + out = tmp_path / "model" + r = runner.invoke(app, ["import", "erwin", str(xml), "-o", str(out)]) + assert r.exit_code == 0, r.output + assert (out / "logical" / "entities" / "counterparty.yaml").exists() + # warnings surface as notes + assert "note:" in r.output + # and the written model validates + v = runner.invoke(app, ["validate", "-m", str(out)]) + assert v.exit_code == 0, v.output + + +def test_import_erwin_apply_into_existing(tmp_path): + xml = tmp_path / "trading.xml" + xml.write_text(_erwin_xml()) + existing = tmp_path / "existing" + existing.mkdir() + (existing / "mdl-project.yaml").write_text("name: existing\ndbt_target: duckdb\n") + r = runner.invoke(app, ["import", "erwin", str(xml), "-o", str(existing), "--apply"]) + assert r.exit_code == 0, r.output + assert "merged" in r.output + assert (existing / "logical" / "entities" / "trade.yaml").exists() + v = runner.invoke(app, ["validate", "-m", str(existing)]) + assert v.exit_code == 0, v.output + + +def test_import_erwin_diverts_on_existing_model(tmp_path): + # a second write into a dir that already holds a model diverts to a sibling + xml = tmp_path / "trading.xml" + xml.write_text(_erwin_xml()) + out = tmp_path / "model" + assert runner.invoke(app, ["import", "erwin", str(xml), "-o", str(out)]).exit_code == 0 + r = runner.invoke(app, ["import", "erwin", str(xml), "-o", str(out)]) + assert r.exit_code == 0, r.output + # the original is untouched; a -reversed-v1 sibling was written + assert (tmp_path / "model-reversed-v1" / "mdl-project.yaml").exists() From aeefb90546cd07883e7aa400cba40cdbeeeb520f Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 26 Sep 2026 18:52:23 +1000 Subject: [PATCH 5/9] import: wire erwin into the canvas Import wizard + server /api/import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Server /api/import gains an `erwin` branch: import_erwin(content) → model_to_commands, returning the same {ok, changes, tables, warnings} shape as the other formats, so the canvas previews/applies it through the existing path. Uses the rich Model bridge, not the thin table parsers, so subject areas / categories / domains survive. - Canvas Import menu: adds an "erwin XML" format option and `.xml` to the file accept. Rebuilt into the server static. 2 server tests (erwin returns the right ops + warnings; empty content is 422). Canvas typechecks + builds. --- canvas/src/sme/ImportExportMenu.tsx | 3 +- packages/server/src/mdl_server/app.py | 18 ++++++++-- ...m-B_FcBTxi.js => ModelDiagram-CEOzUQbJ.js} | 2 +- .../{main-CkEXCrfC.js => main-DS5zhMab.js} | 2 +- .../{sme-eqoZCV5z.js => sme-WKO1CSM1.js} | 4 +-- ...{styles-DijBTcQg.js => styles-B2ouEC7_.js} | 2 +- .../server/src/mdl_server/static/index.html | 4 +-- .../server/src/mdl_server/static/sme.html | 4 +-- .../server/tests/test_import_erwin_api.py | 33 +++++++++++++++++++ 9 files changed, 60 insertions(+), 12 deletions(-) rename packages/server/src/mdl_server/static/assets/{ModelDiagram-B_FcBTxi.js => ModelDiagram-CEOzUQbJ.js} (97%) rename packages/server/src/mdl_server/static/assets/{main-CkEXCrfC.js => main-DS5zhMab.js} (99%) rename packages/server/src/mdl_server/static/assets/{sme-eqoZCV5z.js => sme-WKO1CSM1.js} (99%) rename packages/server/src/mdl_server/static/assets/{styles-DijBTcQg.js => styles-B2ouEC7_.js} (99%) create mode 100644 packages/server/tests/test_import_erwin_api.py diff --git a/canvas/src/sme/ImportExportMenu.tsx b/canvas/src/sme/ImportExportMenu.tsx index 04ad18e..3b01cb4 100644 --- a/canvas/src/sme/ImportExportMenu.tsx +++ b/canvas/src/sme/ImportExportMenu.tsx @@ -136,6 +136,7 @@ const IMPORT_FORMATS = [ { id: "sql", label: "SQL DDL", hint: "CREATE TABLE … PRIMARY KEY / FOREIGN KEY" }, { id: "mermaid", label: "Mermaid erDiagram", hint: "structural: entities, attributes, relationships" }, { id: "json-schema", label: "JSON Schema", hint: "each object definition becomes an entity" }, + { id: "erwin", label: "erwin XML", hint: "entities, keys, relationships, subject areas, subtypes" }, ]; function ImportPanel({ @@ -230,7 +231,7 @@ function ImportPanel({ onFile(e.target.files?.[0] ?? null)} /> {format === "mermaid" && ( diff --git a/packages/server/src/mdl_server/app.py b/packages/server/src/mdl_server/app.py index e43b95c..8eb043c 100644 --- a/packages/server/src/mdl_server/app.py +++ b/packages/server/src/mdl_server/app.py @@ -218,15 +218,29 @@ def export_formats() -> JSONResponse: @app.post("/api/import") def import_model(body: dict) -> JSONResponse: """Parse an interchange document into a change list the client can preview and - propose. Accepts {format: sql|mermaid|json-schema, content, dialect?}. Writes + propose. Accepts {format: sql|mermaid|json-schema|erwin, content, dialect?}. Writes nothing itself.""" from mdl_emit_erd.imports import parse_json_schema, parse_mermaid, parse_sql_ddl - from mdl_emit_erd.imports.model import to_commands + from mdl_emit_erd.imports.model import model_to_commands, to_commands fmt = body.get("format", "") content = body.get("content", "") if not content: return JSONResponse({"ok": False, "error": "content is empty"}, status_code=422) + if fmt == "erwin": + # erwin produces a RICH Model (subject areas, categories, domains) → the + # model_to_commands bridge, not the thin table-oriented parsers. + from mdl_reverse.erwin import import_erwin + + result = import_erwin(content) + return JSONResponse( + { + "ok": True, + "changes": model_to_commands(result.model), + "tables": len(result.model.logical_entities), + "warnings": result.warnings, + } + ) if fmt == "sql": imported = parse_sql_ddl(content, dialect=body.get("dialect") or "postgres") elif fmt == "mermaid": diff --git a/packages/server/src/mdl_server/static/assets/ModelDiagram-B_FcBTxi.js b/packages/server/src/mdl_server/static/assets/ModelDiagram-CEOzUQbJ.js similarity index 97% rename from packages/server/src/mdl_server/static/assets/ModelDiagram-B_FcBTxi.js rename to packages/server/src/mdl_server/static/assets/ModelDiagram-CEOzUQbJ.js index 3addb64..f226e7e 100644 --- a/packages/server/src/mdl_server/static/assets/ModelDiagram-B_FcBTxi.js +++ b/packages/server/src/mdl_server/static/assets/ModelDiagram-CEOzUQbJ.js @@ -1 +1 @@ -import{r,j as s}from"./client-DOmNpTln.js";import{f as T,t as _,u as O,v as R,E as D,B as P,C as z,w as A}from"./styles-DijBTcQg.js";const V={entity:D},$={relationship:R},l={breaking:"#f87171",additive:"#34d399",cosmetic:"#fcd34d"},N=["#5eead4","#fcd34d","#a78bfa","#fb923c","#38bdf8","#f472b6","#a3e635"],d="#64748b";function Y({subjectArea:u,severityByUlid:a,focusUlid:m,onSelect:c}){const[t,g]=r.useState(null),[f,w]=r.useState(null),[h,E]=r.useState(!0);r.useEffect(()=>{g(null),T(u).then(g).catch(n=>w(String(n)))},[u]);const{nodes:k,edges:M}=r.useMemo(()=>{if(!t)return{nodes:[],edges:[]};const n=new Map;t.subject_areas.forEach(e=>{let o=2166136261;for(let i=0;i[e.id,e])),b=a&&a.size>0,C=t.entities.map(e=>{var j;const o=(a==null?void 0:a.get(e.id))??(e.conceptual?a==null?void 0:a.get(e.conceptual.id):void 0),i=b?o?l[o]??d:d:(j=e.conceptual)!=null&&j.subject_area?n.get(e.conceptual.subject_area.id)??d:d;return{id:e.id,type:"entity",position:{x:0,y:0},data:{entity:e,color:i,dimmed:!!(b&&!o),highlighted:e.id===m,showTypes:h}}}),x=t.relationships.map(e=>({id:e.id,source:e.from.entity,target:e.to.entity,type:"relationship",data:{relationship:e,dimmed:!1}}));return{nodes:_(C,x,p),edges:x}},[t,a,m,h]);return f?s.jsx("p",{className:"sme-splash error",children:f}):t?t.entities.length?s.jsxs("div",{className:"erd-wrap",children:[s.jsxs("div",{className:"erd-bar",children:[s.jsxs("span",{className:"erd-count",children:[t.entities.length," entities · ",t.relationships.length," relationships",t.scope&&" · scoped"]}),s.jsxs("label",{className:"erd-toggle",children:[s.jsx("input",{type:"checkbox",checked:h,onChange:n=>E(n.target.checked)}),"show types"]}),a&&a.size>0&&s.jsxs("span",{className:"erd-legend",children:[s.jsx("span",{className:"sw",style:{background:l.breaking}})," breaking",s.jsx("span",{className:"sw",style:{background:l.additive}})," added",s.jsx("span",{className:"sw",style:{background:l.cosmetic}})," changed",s.jsx("span",{className:"sw",style:{background:d}})," unchanged"]})]}),s.jsx("div",{className:"erd-canvas",children:s.jsxs(O,{nodes:k,edges:M,nodeTypes:V,edgeTypes:$,fitView:!0,minZoom:.1,nodesConnectable:!1,onNodeClick:(n,p)=>c==null?void 0:c(p.id),onPaneClick:()=>c==null?void 0:c(null),onlyRenderVisibleElements:!0,proOptions:{hideAttribution:!0},children:[s.jsx(P,{gap:16,color:"#1f2937"}),s.jsx(z,{showInteractive:!1}),s.jsx(A,{pannable:!0,zoomable:!0})]})})]}):s.jsx("p",{className:"sme-placeholder",children:"Nothing to draw — this subject area has no entities in it yet."}):s.jsx("div",{className:"sme-splash",children:"◮ drawing the model…"})}export{Y as ModelDiagram}; +import{r,j as s}from"./client-DOmNpTln.js";import{f as T,t as _,u as O,v as R,E as D,B as P,C as z,w as A}from"./styles-B2ouEC7_.js";const V={entity:D},$={relationship:R},l={breaking:"#f87171",additive:"#34d399",cosmetic:"#fcd34d"},N=["#5eead4","#fcd34d","#a78bfa","#fb923c","#38bdf8","#f472b6","#a3e635"],d="#64748b";function Y({subjectArea:u,severityByUlid:a,focusUlid:m,onSelect:c}){const[t,g]=r.useState(null),[f,w]=r.useState(null),[h,E]=r.useState(!0);r.useEffect(()=>{g(null),T(u).then(g).catch(n=>w(String(n)))},[u]);const{nodes:k,edges:M}=r.useMemo(()=>{if(!t)return{nodes:[],edges:[]};const n=new Map;t.subject_areas.forEach(e=>{let o=2166136261;for(let i=0;i[e.id,e])),b=a&&a.size>0,C=t.entities.map(e=>{var j;const o=(a==null?void 0:a.get(e.id))??(e.conceptual?a==null?void 0:a.get(e.conceptual.id):void 0),i=b?o?l[o]??d:d:(j=e.conceptual)!=null&&j.subject_area?n.get(e.conceptual.subject_area.id)??d:d;return{id:e.id,type:"entity",position:{x:0,y:0},data:{entity:e,color:i,dimmed:!!(b&&!o),highlighted:e.id===m,showTypes:h}}}),x=t.relationships.map(e=>({id:e.id,source:e.from.entity,target:e.to.entity,type:"relationship",data:{relationship:e,dimmed:!1}}));return{nodes:_(C,x,p),edges:x}},[t,a,m,h]);return f?s.jsx("p",{className:"sme-splash error",children:f}):t?t.entities.length?s.jsxs("div",{className:"erd-wrap",children:[s.jsxs("div",{className:"erd-bar",children:[s.jsxs("span",{className:"erd-count",children:[t.entities.length," entities · ",t.relationships.length," relationships",t.scope&&" · scoped"]}),s.jsxs("label",{className:"erd-toggle",children:[s.jsx("input",{type:"checkbox",checked:h,onChange:n=>E(n.target.checked)}),"show types"]}),a&&a.size>0&&s.jsxs("span",{className:"erd-legend",children:[s.jsx("span",{className:"sw",style:{background:l.breaking}})," breaking",s.jsx("span",{className:"sw",style:{background:l.additive}})," added",s.jsx("span",{className:"sw",style:{background:l.cosmetic}})," changed",s.jsx("span",{className:"sw",style:{background:d}})," unchanged"]})]}),s.jsx("div",{className:"erd-canvas",children:s.jsxs(O,{nodes:k,edges:M,nodeTypes:V,edgeTypes:$,fitView:!0,minZoom:.1,nodesConnectable:!1,onNodeClick:(n,p)=>c==null?void 0:c(p.id),onPaneClick:()=>c==null?void 0:c(null),onlyRenderVisibleElements:!0,proOptions:{hideAttribution:!0},children:[s.jsx(P,{gap:16,color:"#1f2937"}),s.jsx(z,{showInteractive:!1}),s.jsx(A,{pannable:!0,zoomable:!0})]})})]}):s.jsx("p",{className:"sme-placeholder",children:"Nothing to draw — this subject area has no entities in it yet."}):s.jsx("div",{className:"sme-splash",children:"◮ drawing the model…"})}export{Y as ModelDiagram}; diff --git a/packages/server/src/mdl_server/static/assets/main-CkEXCrfC.js b/packages/server/src/mdl_server/static/assets/main-DS5zhMab.js similarity index 99% rename from packages/server/src/mdl_server/static/assets/main-CkEXCrfC.js rename to packages/server/src/mdl_server/static/assets/main-DS5zhMab.js index f5618d1..5609463 100644 --- a/packages/server/src/mdl_server/static/assets/main-CkEXCrfC.js +++ b/packages/server/src/mdl_server/static/assets/main-DS5zhMab.js @@ -1,2 +1,2 @@ -import{j as e,r as a,c as Y,R as Z}from"./client-DOmNpTln.js";import{I as O,R as ee,P as U,f as _,a as V,g as z,s as te,A as ne,b as se,M as ae,S as re}from"./styles-DijBTcQg.js";function oe(n){return{canEdit:!n,mode:"direct",canArbitrate:!n,canCommit:!n}}function le({doc:n,diagnostics:l,query:x,onQuery:u,onSubmitQuery:m,showTypes:d,onToggleTypes:h,collapseDetail:j,onToggleCollapse:y,onFitView:T,onRelayout:R,onRefresh:P,saColors:I,readOnly:g,dirty:L,panelTab:A,onPanelTab:B,onNewEntity:M,exec:b,onImported:E,onImportBatch:F,openImport:v}){const C=(l==null?void 0:l.items.filter(o=>o.severity==="error").length)??0,w=(l==null?void 0:l.items.filter(o=>o.severity==="warning").length)??0,i=(o,N,K,f)=>e.jsx("button",{className:"tool-btn"+(A===o?" active":"")+(f?" badged":""),onClick:()=>B(o),title:K,children:N});return e.jsxs("header",{className:"topbar",children:[e.jsxs("div",{className:"brand",children:[e.jsx("span",{className:"logo",children:"◮"}),e.jsx("span",{className:"brand-name",children:"Modelith"}),e.jsx("span",{className:"project-name",children:n.project.name}),g&&e.jsx("span",{className:"chip",children:"read-only"})]}),e.jsx("input",{className:"search",type:"search",placeholder:"Search entities & attributes… ( / , Enter jumps )",value:x,onChange:o=>u(o.target.value),onKeyDown:o=>{o.key==="Enter"&&m()},id:"mdl-search"}),e.jsx("div",{className:"legend",children:n.subject_areas.map(o=>e.jsxs("span",{className:"legend-item",children:[e.jsx("span",{className:"swatch",style:{background:I.get(o.id)}}),o.name]},o.id))}),e.jsxs("div",{className:"stats",children:[e.jsxs("span",{title:"entities",children:[n.counts.entities," ⬛"]}),e.jsxs("span",{title:"relationships",children:[n.counts.relationships," ⤳"]}),l&&e.jsx("span",{className:"diag-chip"+(C?" err":w?" warn":" ok"),title:l.items.map(o=>`${o.code} ${o.message}`).join(` +import{j as e,r as a,c as Y,R as Z}from"./client-DOmNpTln.js";import{I as O,R as ee,P as U,f as _,a as V,g as z,s as te,A as ne,b as se,M as ae,S as re}from"./styles-B2ouEC7_.js";function oe(n){return{canEdit:!n,mode:"direct",canArbitrate:!n,canCommit:!n}}function le({doc:n,diagnostics:l,query:x,onQuery:u,onSubmitQuery:m,showTypes:d,onToggleTypes:h,collapseDetail:j,onToggleCollapse:y,onFitView:T,onRelayout:R,onRefresh:P,saColors:I,readOnly:g,dirty:L,panelTab:A,onPanelTab:B,onNewEntity:M,exec:b,onImported:E,onImportBatch:F,openImport:v}){const C=(l==null?void 0:l.items.filter(o=>o.severity==="error").length)??0,w=(l==null?void 0:l.items.filter(o=>o.severity==="warning").length)??0,i=(o,N,K,f)=>e.jsx("button",{className:"tool-btn"+(A===o?" active":"")+(f?" badged":""),onClick:()=>B(o),title:K,children:N});return e.jsxs("header",{className:"topbar",children:[e.jsxs("div",{className:"brand",children:[e.jsx("span",{className:"logo",children:"◮"}),e.jsx("span",{className:"brand-name",children:"Modelith"}),e.jsx("span",{className:"project-name",children:n.project.name}),g&&e.jsx("span",{className:"chip",children:"read-only"})]}),e.jsx("input",{className:"search",type:"search",placeholder:"Search entities & attributes… ( / , Enter jumps )",value:x,onChange:o=>u(o.target.value),onKeyDown:o=>{o.key==="Enter"&&m()},id:"mdl-search"}),e.jsx("div",{className:"legend",children:n.subject_areas.map(o=>e.jsxs("span",{className:"legend-item",children:[e.jsx("span",{className:"swatch",style:{background:I.get(o.id)}}),o.name]},o.id))}),e.jsxs("div",{className:"stats",children:[e.jsxs("span",{title:"entities",children:[n.counts.entities," ⬛"]}),e.jsxs("span",{title:"relationships",children:[n.counts.relationships," ⤳"]}),l&&e.jsx("span",{className:"diag-chip"+(C?" err":w?" warn":" ok"),title:l.items.map(o=>`${o.code} ${o.message}`).join(` `)||"model valid",children:C?`${C} ✗`:w?`${w} ⚠`:"✓ valid"})]}),e.jsxs("div",{className:"actions",children:[!g&&e.jsx("button",{className:"tool-btn primary",onClick:M,title:"New entity (n)",children:"+ Entity"}),i("ontology","⬡","Ontology browser"),i("layers","≣","Four-layer stack & coverage"),i("changes","±",L?"Uncommitted changes!":"Changes",L),i("decisions","⚖","Decision ledger"),e.jsx("span",{className:"divider"}),e.jsx("button",{className:"tool-btn"+(d?" active":""),onClick:h,title:"Toggle data types",children:"{T}"}),e.jsx("button",{className:"tool-btn"+(j?" active":""),onClick:y,title:"Collapse to keys (PK + FK only) — readable for large models",children:"⇕"}),e.jsx("button",{className:"tool-btn",onClick:P,title:"Refresh from disk (picks up terminal / external edits)",children:"⟳"}),e.jsx(ce,{onRelayout:R}),e.jsx("button",{className:"tool-btn",onClick:T,title:"Fit view",children:"⛶"}),e.jsx("span",{className:"divider"}),e.jsx(O,{exec:b,canEdit:!g,onImported:E,submitLabel:"Import",buttonClass:"tool-btn",applyBatch:F,openImport:v})]})]})}function ce({onRelayout:n}){const[l,x]=a.useState(!1),u=a.useRef(null);a.useEffect(()=>{if(!l)return;const d=h=>{u.current&&!u.current.contains(h.target)&&x(!1)};return document.addEventListener("mousedown",d),()=>document.removeEventListener("mousedown",d)},[l]);const m=d=>{n(d),x(!1)};return e.jsxs("div",{className:"layout-picker",ref:u,children:[e.jsx("button",{className:"tool-btn",onClick:()=>n("auto"),title:"Auto-layout",children:"⌗"}),e.jsx("button",{className:"tool-btn caret"+(l?" active":""),onClick:()=>x(d=>!d),title:"Layout style",children:"▾"}),l&&e.jsxs("div",{className:"layout-menu",role:"menu",children:[e.jsx("button",{onClick:()=>m("auto"),children:"Auto (by size)"}),e.jsx("button",{onClick:()=>m("hierarchical"),children:"Hierarchical"}),e.jsx("button",{onClick:()=>m("grid"),children:"Grid"})]})]})}function ie(){const[n,l]=a.useState(null),[x,u]=a.useState(null),[m,d]=a.useState(null),[h,j]=a.useState(null),[y,T]=a.useState(""),[R,P]=a.useState(!0),[I,g]=a.useState(!1),[L,A]=a.useState(()=>new Set),[B,M]=a.useState(null),[b,E]=a.useState(null),[F,v]=a.useState(!1),[C,w]=a.useState(0),i=a.useRef(null),o=a.useRef(""),N=a.useRef(!1),K=a.useMemo(()=>{const t=new Map;return n==null||n.subject_areas.forEach((r,s)=>t.set(r.id,U[s%U.length])),t},[n]),f=(n==null?void 0:n.read_only)??!0,G=a.useMemo(()=>oe(f),[f]),D=a.useMemo(()=>new URLSearchParams(window.location.search),[]),k=D.get("minimal")==="1";a.useEffect(()=>{k&&g(!0)},[k]);const H=a.useCallback(t=>{A(r=>{const s=new Set(r);return s.has(t)?s.delete(t):s.add(t),s})},[]),X=D.get("import")==="1",$=a.useRef(!1),p=a.useCallback(()=>{_().then(l).catch(t=>d(String(t))),V().then(u).catch(()=>u(null)),z().then(t=>v(!!(t.git&&!t.clean))).catch(()=>v(!1)),w(t=>t+1)},[]);a.useEffect(p,[p]),a.useEffect(()=>{const t=setInterval(()=>{_().then(r=>{l(s=>s&&r.fingerprint===s.fingerprint?s:(N.current||(o.current=r.fingerprint),k||(V().then(u).catch(()=>u(null)),z().then(c=>v(!!(c.git&&!c.clean))).catch(()=>v(!1))),w(c=>c+1),r))}).catch(()=>{})},3e3);return()=>clearInterval(t)},[k]),a.useEffect(()=>{n!=null&&n.fingerprint&&(o.current=n.fingerprint)},[n]);const q=a.useCallback(async(t,r)=>{if(n){N.current=!0;try{const s=await te(t,r,o.current||n.fingerprint);return s!=null&&s.fingerprint&&(o.current=s.fingerprint),p(),s}catch(s){throw s instanceof ne&&s.status===409?(j("Model changed on disk — view refreshed, please retry."),p()):j(s instanceof Error?s.message:String(s)),s}finally{N.current=!1}}},[n,p]);a.useEffect(()=>{if(!h)return;const t=setTimeout(()=>j(null),5e3);return()=>clearTimeout(t)},[h]),a.useEffect(()=>{if(!n||$.current)return;const t=D.get("focus");if(!t)return;const r=n.entities.find(s=>{var c;return s.name===t||((c=s.conceptual)==null?void 0:c.name)===t});r&&($.current=!0,requestAnimationFrame(()=>{var s;return(s=i.current)==null?void 0:s.focusEntity(r.id)}))},[n,D]);const J=a.useCallback(()=>{var s;if(!n)return;const t=y.trim().toLowerCase();if(!t)return;const r=n.entities.find(c=>{var S;return(c.name.toLowerCase()+" "+(((S=c.conceptual)==null?void 0:S.name.toLowerCase())??"")+" "+c.attributes.map(W=>W.name.toLowerCase()).join(" ")).includes(t)});r&&((s=i.current)==null||s.focusEntity(r.id))},[n,y]);return a.useEffect(()=>{const t=r=>{var c,Q,S;const s=(c=document.activeElement)==null?void 0:c.tagName;s==="INPUT"||s==="TEXTAREA"||s==="SELECT"||(r.key==="/"&&(r.preventDefault(),(Q=document.getElementById("mdl-search"))==null||Q.focus()),r.key==="n"&&!f&&((S=i.current)==null||S.newEntity()),r.key==="Escape"&&(M(null),T(""),E(null)))};return window.addEventListener("keydown",t),()=>window.removeEventListener("keydown",t)},[f]),m?e.jsxs("div",{className:"splash error",children:[e.jsxs("h1",{children:["◮"," Modelith"]}),e.jsx("p",{children:"Could not load the model:"}),e.jsx("pre",{children:m}),e.jsxs("p",{children:["Is ",e.jsx("code",{children:"mdl serve"})," pointed at a model directory?"]})]}):n?e.jsxs("div",{className:"app",children:[!k&&e.jsx(le,{doc:n,diagnostics:x,query:y,onQuery:T,onSubmitQuery:J,showTypes:R,onToggleTypes:()=>P(t=>!t),collapseDetail:I,onToggleCollapse:()=>g(t=>!t),onFitView:()=>{var t;return(t=i.current)==null?void 0:t.fitView()},onRelayout:t=>{var r;return(r=i.current)==null?void 0:r.relayout(t)},onRefresh:p,saColors:K,readOnly:f,dirty:F,panelTab:b,onPanelTab:t=>E(r=>r===t?null:t),onNewEntity:()=>{var t;return(t=i.current)==null?void 0:t.newEntity()},exec:q,onImported:p,onImportBatch:se,openImport:X}),e.jsxs("div",{className:"canvas-wrap",children:[e.jsx(ae,{doc:n,caps:G,exec:q,selectedId:b?null:B,onSelect:M,query:y,showTypes:R,collapseDetail:I,expandedEntities:L,onToggleExpand:H,onError:j,handleRef:i}),b&&e.jsx(re,{tab:b,onClose:()=>E(null),readOnly:f,refreshKey:C,onModelChanged:p,onFocusEntity:t=>{var r;E(null),(r=i.current)==null||r.focusEntity(t)}}),h&&e.jsx("div",{className:"toast",children:h})]})]}):e.jsxs("div",{className:"splash",children:["◮"," loading model…"]})}function ue(){return e.jsx(ee,{children:e.jsx(ie,{})})}Y.createRoot(document.getElementById("root")).render(e.jsx(Z.StrictMode,{children:e.jsx(ue,{})})); diff --git a/packages/server/src/mdl_server/static/assets/sme-eqoZCV5z.js b/packages/server/src/mdl_server/static/assets/sme-WKO1CSM1.js similarity index 99% rename from packages/server/src/mdl_server/static/assets/sme-eqoZCV5z.js rename to packages/server/src/mdl_server/static/assets/sme-WKO1CSM1.js index 81eb83c..12345f5 100644 --- a/packages/server/src/mdl_server/static/assets/sme-eqoZCV5z.js +++ b/packages/server/src/mdl_server/static/assets/sme-WKO1CSM1.js @@ -1,2 +1,2 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/ModelDiagram-B_FcBTxi.js","assets/client-DOmNpTln.js","assets/styles-DijBTcQg.js","assets/styles-CJ3Gp-TF.css"])))=>i.map(i=>d[i]); -import{r as n,j as e,c as Ae,R as Ee}from"./client-DOmNpTln.js";import{p as pe,c as je,d as xe,e as $e,h as be,I as Re,R as Me,M as De,O as Pe,L as Ie,i as Oe,j as Te,A as Be,k as Le,l as Fe,m as ze,o as qe,s as We,f as le,n as Ge,q as He,r as Ve}from"./styles-DijBTcQg.js";/* empty css */const Ke="0123456789ABCDEFGHJKMNPQRSTVWXYZ";function ce(s,t){let a="";for(let l=0;l>=5n;return a}function ge(){const s=BigInt(Date.now())&(1n<<48n)-1n,t=new Uint8Array(10);crypto.getRandomValues(t);let a=0n;for(const l of t)a=a<<8n|BigInt(l);return ce(s,10)+ce(a,16)}const Ue={create_entity:["id","conceptual_id"],create_relationship:["id","from_fk_id"],create_subject_area:["id"],create_term:["id"],create_domain:["id"],create_code_set:["id"],create_key_group:["id"],create_category:["id"],add_attribute:["id"]};function Ye(s,t){const a=Ue[s];if(!a)return t;const l={...t};for(const i of a)l[i]||(l[i]=ge());return l}function fe(s,t){if(s.startsWith("create_")||s==="add_attribute")return`${s}:${t.id??t.attribute_id}`;const a=t.id??t.entity_id??"",l=t.attribute_id??"";return l?`${s}:${a}:${l}`:`${s}:${a}`}function Je({subjectArea:s,describe:t}){const[a,l]=n.useState([]),[i,x]=n.useState(null),[c,r]=n.useState(!1),m=n.useRef(null),N=n.useCallback(async(h,g)=>{g=Ye(h,g),h==="set_alignment"&&g.status===void 0&&(g={...g,status:"proposed"});const _=fe(h,g),{label:C,before:T,after:S}=t(h,g);return l(y=>{const A=y.filter(k=>k.key!==_);if(h.startsWith("delete_")){const k=g.id??g.entity_id;if(k&&y.some(o=>o.op.startsWith("create_")&&o.payload.id===k))return A.filter(o=>o.payload.id!==k)}return[...A,{op:h,payload:g,label:C,before:T,after:S,key:_}]}),{ok:!0,fingerprint:"",created_id:g.id??null,diagnostics:[]}},[t]);n.useEffect(()=>{if(!a.length){x(null);return}const h=setTimeout(()=>{var _;(_=m.current)==null||_.abort();const g=new AbortController;m.current=g,r(!0),pe(a.map(C=>({op:C.op,payload:C.payload})),s,g.signal).then(x).catch(C=>{C.name!=="AbortError"&&x(null)}).finally(()=>r(!1))},250);return()=>clearTimeout(h)},[a,s]);const b=n.useCallback(h=>{l(g=>g.filter(_=>_.key!==h))},[]),f=n.useCallback(()=>l([]),[]);return n.useMemo(()=>({pending:a,previewDoc:(i==null?void 0:i.model)??null,previewDiff:(i==null?void 0:i.diff)??null,diagnostics:(i==null?void 0:i.diagnostics)??[],failure:i&&i.failed_index!==null?{index:i.failed_index,error:i.error??"invalid change"}:null,busy:c,exec:N,drop:b,clear:f}),[a,i,c,N,b,f])}function Qe(s){const t=new Map;for(const l of s)for(const i of["id","conceptual_id","from_fk_id"]){const x=l.payload[i];l.op.startsWith("create_")&&typeof x=="string"&&t.set(x,l.key)}if(!t.size)return new Set;const a=new Set;for(const l of s)if(!l.op.startsWith("create_"))for(const i of Object.values(l.payload))typeof i=="string"&&t.has(i)&&(a.add(l.key),a.add(t.get(i)));return a}function Xe({user:s}){const[t,a]=n.useState(null);return n.useEffect(()=>{je(s).then(a).catch(()=>{})},[s]),!(t!=null&&t.ok)||!t.git?null:t.read_only?e.jsxs("div",{className:"git-banner",children:[e.jsx("code",{children:t.branch}),e.jsx("span",{children:"browse only — editing is disabled on this deployment"})]}):t.dirty?e.jsxs("div",{className:"git-banner warn",children:[e.jsx("code",{children:t.branch}),e.jsx("span",{children:"uncommitted changes in the working tree — a proposal will be refused until they are committed or discarded"})]}):t.on_base?e.jsxs("div",{className:"git-banner",children:[e.jsx("code",{children:t.branch}),e.jsx("span",{children:"protected — your edits become a pull request"})]}):e.jsxs("div",{className:"git-banner",children:[e.jsx("code",{children:t.branch}),e.jsx("span",{children:"your proposal branch"}),t.behind?e.jsxs("span",{className:"dim",children:["· ",t.base_branch," is ",t.behind," ahead"]}):null]})}function Ze({exec:s,canEdit:t,stagedMembers:a}){const[l,i]=n.useState([]),[x,c]=n.useState(""),[r,m]=n.useState(null),[N,b]=n.useState(""),[f,h]=n.useState(new Set),[g,_]=n.useState("ancestors"),[C,T]=n.useState(1),[S,y]=n.useState(null),[A,k]=n.useState(new Set),[R,o]=n.useState(null);n.useEffect(()=>{xe().then(d=>{i(d.subject_areas),c(w=>{var M;return w||((M=d.subject_areas[0])==null?void 0:M.id)||""})}).catch(d=>o(String(d)))},[]),n.useEffect(()=>{x&&(y(null),h(new Set),$e(x).then(m).catch(d=>o(String(d))))},[x]);const E=n.useMemo(()=>a[x]??(r==null?void 0:r.members)??[],[a,x,r]),$=n.useMemo(()=>{const d=new Map;for(const w of[...(r==null?void 0:r.included)??[],...(r==null?void 0:r.available)??[]])d.set(w.id,w);return d},[r]),D=N.trim().toLowerCase(),B=d=>!D||d.name.toLowerCase().includes(D),u=n.useMemo(()=>new Set(E),[E]),P=n.useMemo(()=>E.map(d=>$.get(d)).filter(d=>!!d&&B(d)),[E,$,D]),F=n.useMemo(()=>[...$.values()].filter(d=>!u.has(d.id)&&B(d)),[$,u,D]),I=n.useCallback(d=>{if(!r)return;const w=d.filter(q=>!(r.members??[]).includes(q)).length,M=(r.members??[]).filter(q=>!d.includes(q)).length;s("set_subject_area_members",{id:r.id,members:d,__label:`${r.name}: ${d.length} objects (+${w} −${M})`})},[r,s]),z=(d,w)=>{const M=w?[...E,...d.filter(q=>!u.has(q))]:E.filter(q=>!d.includes(q));I(M),h(new Set)},W=(d,w,M)=>{const q=new Set(d);q.has(w)?q.delete(w):q.add(w),M(q)},U=()=>{if(!r)return;const d=f.size?[...f]:E;if(!d.length){o("add an object first, or select one to expand from");return}o(null),be(r.id,{seeds:d,direction:g,levels:C}).then(w=>{y(w.hops),k(new Set(w.added))}).catch(w=>o(String(w)))},K=()=>{z([...A],!0),y(null)};if(R&&!r)return e.jsx("p",{className:"sme-splash error",children:R});if(!l.length)return e.jsx("p",{className:"sme-placeholder",children:"No subject areas yet."});const j=((r==null?void 0:r.inconsistent)??[]).filter(d=>!u.has(d));return e.jsxs("div",{className:"sa-editor",children:[e.jsx("nav",{className:"sme-nav",children:l.map(d=>e.jsxs("button",{className:"sme-sa"+(d.id===x?" active":""),onClick:()=>c(d.id),title:d.definition??void 0,children:[d.name,e.jsx("span",{className:"sme-sa-count",children:(a[d.id]??d.members).length})]},d.id))}),e.jsxs("div",{className:"sa-main",children:[e.jsxs("div",{className:"sa-head",children:[e.jsx("h2",{children:(r==null?void 0:r.name)??"…"}),(r==null?void 0:r.definition)&&e.jsx("p",{className:"sme-muted",children:r.definition}),e.jsx("input",{className:"sme-search sa-filter",type:"search",placeholder:"Filter both lists…",value:N,onChange:d=>b(d.target.value)})]}),j.length>0&&t&&e.jsxs("div",{className:"sa-nudge",children:[j.length," object",j.length>1?"s are":" is"," homed in this area but not included.",e.jsxs("button",{className:"sme-link",onClick:()=>z(j,!0),children:["Add ",j.length>1?"them all":"it"]})]}),e.jsxs("div",{className:"sa-panes",children:[e.jsx(Q,{title:`Available (${F.length})`,rows:F,picked:f,onToggle:d=>W(f,d,h),homedHere:(r==null?void 0:r.homed_here)??[]}),e.jsxs("div",{className:"sa-controls",children:[e.jsx("button",{className:"sme-secondary",disabled:!t||!f.size,onClick:()=>z([...f].filter(d=>!u.has(d)),!0),title:"Add the selected objects",children:"→"}),e.jsx("button",{className:"sme-secondary",disabled:!t||!F.length,onClick:()=>z(F.map(d=>d.id),!0),title:"Add everything matching the filter",children:"»"}),e.jsx("button",{className:"sme-secondary",disabled:!t||!f.size,onClick:()=>z([...f].filter(d=>u.has(d)),!1),title:"Remove the selected objects",children:"←"}),e.jsx("button",{className:"sme-secondary",disabled:!t||!P.length,onClick:()=>z(P.map(d=>d.id),!1),title:"Remove everything matching the filter",children:"«"})]}),e.jsx(Q,{title:`Included (${P.length})`,rows:P,picked:f,onToggle:d=>W(f,d,h),homedHere:(r==null?void 0:r.homed_here)??[]})]}),e.jsxs("div",{className:"sa-expand",children:[e.jsx("h3",{children:"Add related objects"}),e.jsxs("div",{className:"sa-expand-row",children:[e.jsxs("select",{value:g,onChange:d=>_(d.target.value),children:[e.jsx("option",{value:"ancestors",children:"ancestors — the parents its keys point at"}),e.jsx("option",{value:"descendants",children:"descendants — what points at it"}),e.jsx("option",{value:"both",children:"both"})]}),e.jsxs("label",{children:["levels",e.jsx("input",{type:"number",min:1,max:10,value:C,onChange:d=>T(Math.max(1,Math.min(10,Number(d.target.value))))})]}),e.jsx("button",{className:"sme-secondary",onClick:U,disabled:!t,children:"Preview"})]}),R&&e.jsx("p",{className:"sme-error-line",children:R}),S&&e.jsxs("div",{className:"sa-hops",children:[S.length===0&&e.jsx("p",{className:"sme-muted",children:"nothing new to add"}),S.map(d=>e.jsxs("label",{className:"sa-hop",children:[e.jsx("input",{type:"checkbox",checked:A.has(d.id),onChange:()=>W(A,d.id,k)}),e.jsx("span",{className:"sa-hop-name",children:d.name}),e.jsxs("span",{className:"sa-hop-why",children:["via ",d.via_name," · ",d.direction.replace(/s$/,""),", level ",d.level]})]},d.id)),S.length>0&&e.jsxs("div",{className:"sa-hop-actions",children:[e.jsx("button",{className:"sme-secondary",onClick:()=>y(null),children:"Cancel"}),e.jsxs("button",{className:"sme-primary",onClick:K,disabled:!A.size,children:["Add ",A.size," object",A.size===1?"":"s"]})]})]})]})]})]})}function Q({title:s,rows:t,picked:a,onToggle:l,homedHere:i}){const x=n.useMemo(()=>new Set(i),[i]);return e.jsxs("div",{className:"sa-pane",children:[e.jsx("h3",{children:s}),e.jsxs("ul",{children:[t.map(c=>e.jsxs("li",{className:"sa-row"+(a.has(c.id)?" picked":""),onClick:()=>l(c.id),children:[e.jsx("input",{type:"checkbox",readOnly:!0,checked:a.has(c.id)}),e.jsx("span",{className:"sa-row-name",children:c.name}),x.has(c.id)?e.jsx("span",{className:"sme-badge",children:"home"}):e.jsx("span",{className:"sme-badge borrowed",children:"borrowed"})]},c.id)),!t.length&&e.jsx("li",{className:"sme-empty",children:"nothing here"})]})]})}function es({objects:s,onCreate:t,onClose:a}){const[l,i]=n.useState(""),[x,c]=n.useState(""),[r]=n.useState(s),[m,N]=n.useState([]),[b,f]=n.useState(new Set),[h,g]=n.useState(""),[_,C]=n.useState("ancestors"),[T,S]=n.useState(1),[y,A]=n.useState(null),[k,R]=n.useState(new Set),[o,E]=n.useState(""),[$,D]=n.useState(null);n.useEffect(()=>{xe().then(j=>{j.subject_areas[0]&&E(j.subject_areas[0].id)}).catch(()=>{})},[]);const B=n.useMemo(()=>new Set(m),[m]),u=h.trim().toLowerCase(),P=j=>!u||j.name.toLowerCase().includes(u),F=n.useMemo(()=>m.map(j=>r.find(d=>d.id===j)).filter(j=>!!j&&P(j)),[m,r,u]),I=n.useMemo(()=>r.filter(j=>!B.has(j.id)&&P(j)),[r,B,u]),z=(j,d)=>{N(w=>d?[...w,...j.filter(M=>!w.includes(M))]:w.filter(M=>!j.includes(M))),f(new Set)},W=(j,d,w)=>{const M=new Set(j);M.has(d)?M.delete(d):M.add(d),w(M)},U=()=>{const j=b.size?[...b]:m;if(!j.length){D("pick an object to expand from first");return}o&&(D(null),be(o,{seeds:j,direction:_,levels:T}).then(d=>{const w=d.hops.filter(M=>!B.has(M.id));A(w),R(new Set(w.map(M=>M.id)))}).catch(d=>D(String(d))))},K=()=>{l.trim()&&t(l.trim(),x.trim(),m)};return e.jsx("div",{className:"sme-modal-backdrop",onClick:a,children:e.jsxs("div",{className:"sme-modal sa-modal",onClick:j=>j.stopPropagation(),children:[e.jsx("h2",{children:"New subject area"}),e.jsx("p",{className:"sme-hint",children:"A subject area is a filtered view of the model. Name it and choose what it contains — both land as one proposal."}),e.jsxs("div",{className:"sme-field-row",children:[e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Name"}),e.jsx("input",{autoFocus:!0,value:l,placeholder:"e.g. UseCase-ApraStressTesting",onChange:j=>i(j.target.value),onKeyDown:j=>j.key==="Enter"&&m.length===0&&K()})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Definition (optional)"}),e.jsx("input",{value:x,placeholder:"what this view is for",onChange:j=>c(j.target.value)})]})]}),e.jsx("input",{className:"sme-search sa-filter",type:"search",placeholder:"Filter both lists…",value:h,onChange:j=>g(j.target.value)}),e.jsxs("div",{className:"sa-panes",children:[e.jsx(Q,{title:`Available (${I.length})`,rows:I,picked:b,onToggle:j=>W(b,j,f),homedHere:[]}),e.jsxs("div",{className:"sa-controls",children:[e.jsx("button",{className:"sme-secondary",disabled:!b.size,onClick:()=>z([...b].filter(j=>!B.has(j)),!0),title:"Add the selected objects",children:"→"}),e.jsx("button",{className:"sme-secondary",disabled:!I.length,onClick:()=>z(I.map(j=>j.id),!0),title:"Add everything matching the filter",children:"»"}),e.jsx("button",{className:"sme-secondary",disabled:!b.size,onClick:()=>z([...b].filter(j=>B.has(j)),!1),title:"Remove the selected objects",children:"←"}),e.jsx("button",{className:"sme-secondary",disabled:!F.length,onClick:()=>z(F.map(j=>j.id),!1),title:"Remove everything matching the filter",children:"«"})]}),e.jsx(Q,{title:`Included (${F.length})`,rows:F,picked:b,onToggle:j=>W(b,j,f),homedHere:[]})]}),e.jsxs("div",{className:"sa-expand",children:[e.jsx("h3",{children:"Add related objects"}),e.jsxs("div",{className:"sa-expand-row",children:[e.jsxs("select",{value:_,onChange:j=>C(j.target.value),children:[e.jsx("option",{value:"ancestors",children:"ancestors — the parents its keys point at"}),e.jsx("option",{value:"descendants",children:"descendants — what points at it"}),e.jsx("option",{value:"both",children:"both"})]}),e.jsxs("label",{children:["levels",e.jsx("input",{type:"number",min:1,max:10,value:T,onChange:j=>S(Math.max(1,Math.min(10,Number(j.target.value))))})]}),e.jsx("button",{className:"sme-secondary",onClick:U,disabled:!o,title:o?"Preview related objects to add":"Available once the model has at least one saved subject area to walk the graph from",children:"Preview"})]}),y&&e.jsxs("div",{className:"sa-hops",children:[y.length===0&&e.jsx("p",{className:"sme-muted",children:"nothing new to add"}),y.map(j=>e.jsxs("label",{className:"sa-hop",children:[e.jsx("input",{type:"checkbox",checked:k.has(j.id),onChange:()=>W(k,j.id,R)}),e.jsx("span",{className:"sa-hop-name",children:j.name}),e.jsxs("span",{className:"sa-hop-why",children:["via ",j.via_name," · ",j.direction.replace(/s$/,""),", level ",j.level]})]},j.id)),y.length>0&&e.jsxs("div",{className:"sa-hop-actions",children:[e.jsx("button",{className:"sme-secondary",onClick:()=>A(null),children:"Cancel"}),e.jsxs("button",{className:"sme-primary",disabled:!k.size,onClick:()=>{z([...k],!0),A(null)},children:["Add ",k.size]})]})]})]}),$&&e.jsx("p",{className:"sme-error-line",children:$}),e.jsxs("div",{className:"sme-modal-foot",children:[e.jsx("button",{className:"sme-secondary",onClick:a,children:"Cancel"}),e.jsxs("button",{className:"sme-primary",disabled:!l.trim(),onClick:K,children:["Create",m.length>0&&` with ${m.length} object${m.length===1?"":"s"}`]})]})]})})}const ss=new Set(["set_definition","set_stewardship","set_subject_area","set_pattern","rename_entity","add_attribute","update_attribute","delete_attribute","set_alignment","clear_alignment","set_term_map","clear_term_map","update_synonyms","set_object_definition","set_subject_area_members","create_subject_area","update_subject_area","create_entity","delete_entity","create_relationship","rename_relationship","update_relationship","delete_relationship"]);function ns({doc:s,subjectArea:t,onSubjectArea:a,exec:l,canEdit:i,direct:x=!1,onSelectEntity:c,onImported:r,query:m="",busy:N}){var R;const[b,f]=n.useState(null),[h,g]=n.useState(null),[_,C]=n.useState(!1),[T,S]=n.useState(null),y=n.useRef(null),A=n.useMemo(()=>({canEdit:i,mode:x?"direct":"staged",allow:x?void 0:ss,canArbitrate:x,canCommit:x}),[i,x]),k=n.useCallback(o=>{f(o),o&&(c==null||c(o))},[c]);return e.jsxs("div",{className:"sme-model-view",children:[e.jsxs("nav",{className:"sme-nav",children:[e.jsxs("div",{className:"sme-nav-head",children:[e.jsx("span",{children:"Subject areas"}),i&&e.jsx("button",{className:"sme-nav-add",title:"New subject area",onClick:()=>C(!0),children:"+"})]}),e.jsxs("button",{className:"sme-sa"+(t===""?" active":""),onClick:()=>a(""),children:["Whole model",e.jsx("span",{className:"sme-sa-count",children:((R=s.counts)==null?void 0:R.entities_total)??s.entities.length})]}),s.subject_areas.map(o=>e.jsxs("button",{className:"sme-sa"+(t===o.id?" active":""),onClick:()=>a(o.id),title:o.definition??void 0,children:[o.name,e.jsx("span",{className:"sme-sa-count",children:o.member_count??0})]},o.id))]}),_&&e.jsx(es,{objects:s.entities.flatMap(o=>o.conceptual?[{id:o.conceptual.id,kind:"conceptual_entity",name:o.conceptual.name,definition:o.conceptual.definition??null,synonyms:[],subject_area:o.conceptual.subject_area??null,stewardship:null,ontology:null,where_used:[]}]:[]),onClose:()=>C(!1),onCreate:(o,E,$)=>{const D=ge();l("create_subject_area",{name:o,...E?{definition:E}:{},id:D}),$.length&&l("set_subject_area_members",{id:D,members:$}),C(!1),a(D)}}),e.jsxs("div",{className:"erd-wrap",children:[e.jsxs("div",{className:"erd-bar",children:[e.jsxs("span",{className:"erd-count",children:[s.entities.length," entities · ",s.relationships.length," relationships",s.scope&&" · scoped"]}),i&&e.jsx("button",{className:"erd-action",onClick:()=>{var o;return(o=y.current)==null?void 0:o.newEntity()},title:"Add an entity (n)",children:"+ Entity"}),e.jsx("span",{className:"erd-hint",children:"drag between entities to relate them"}),e.jsxs("span",{className:"erd-group",children:[e.jsxs("button",{className:"erd-action ghost",onClick:()=>{var o;return(o=y.current)==null?void 0:o.relayout()},title:"Auto-arrange the diagram",children:[e.jsx("span",{className:"erd-ico","aria-hidden":"true",children:"⟲"}),"Re-layout"]}),e.jsxs("button",{className:"erd-action ghost",onClick:()=>{var o;return(o=y.current)==null?void 0:o.fitView()},title:"Fit the whole model in view",children:[e.jsx("span",{className:"erd-ico","aria-hidden":"true",children:"⛶"}),"Fit"]})]}),e.jsx("span",{className:"erd-sep","aria-hidden":"true"}),e.jsxs("span",{className:"erd-group",children:[e.jsx("button",{className:"erd-action ghost"+(h==="ontology"?" on":""),onClick:()=>g(o=>o==="ontology"?null:"ontology"),children:"Ontology"}),e.jsx("button",{className:"erd-action ghost"+(h==="layers"?" on":""),onClick:()=>g(o=>o==="layers"?null:"layers"),children:"Layers"})]}),e.jsx("span",{className:"erd-sep","aria-hidden":"true"}),e.jsx(Re,{exec:l,canEdit:i,onImported:r}),N&&e.jsx("span",{className:"erd-busy",children:"updating…"}),!i&&e.jsx("span",{className:"sme-chip",children:"read-only"}),T&&e.jsx("span",{className:"erd-error",children:T})]}),e.jsxs("div",{className:"erd-canvas canvas-wrap",children:[e.jsx(Me,{children:e.jsx(De,{doc:s,caps:A,exec:l,selectedId:b,onSelect:k,query:m,onError:S,handleRef:y})}),h&&e.jsxs("aside",{className:"side-panel",children:[e.jsxs("div",{className:"detail-header",children:[e.jsx("h2",{children:h==="ontology"?"Ontology":"Ontology layers"}),e.jsx("button",{className:"icon-btn",onClick:()=>g(null),children:"✕"})]}),h==="ontology"?e.jsx(Pe,{}):e.jsx(Ie,{refreshKey:0,onFocusEntity:o=>{var E;g(null),(E=y.current)==null||E.focusEntity(o)}})]})]})]})]})}function as({user:s,onView:t}){const[a,l]=n.useState(null),[i,x]=n.useState(null);if(n.useEffect(()=>{Oe(s).then(l).catch(r=>x(String(r)))},[s]),i)return e.jsx("p",{className:"sme-splash error",children:i});if(!a)return e.jsx("div",{className:"sme-splash",children:"◮ looking for your proposals…"});if(!a.git)return e.jsx("p",{className:"sme-placeholder",children:"This model isn't in a git repository."});const c=a.proposals.filter(r=>!r.merged).length;return e.jsxs("div",{className:"pr-wrap",children:[e.jsxs("div",{className:"pr-head",children:[e.jsx("h2",{children:"My proposals"}),e.jsxs("span",{className:"meta",children:[c," open · base: ",a.base]})]}),!a.proposals.length&&e.jsx("p",{className:"sme-placeholder",children:"Nothing proposed yet. Edits you submit for review will appear here."}),a.proposals.map(r=>e.jsx(rs,{p:r,gh:a.gh,onView:()=>t(r.branch)},r.branch)),!a.gh&&a.proposals.length>0&&e.jsxs("div",{className:"pr-degrade",children:["Review status isn't available here (",e.jsx("code",{children:"gh"})," is not installed or not signed in). Branch, contents, merge state and conflicts all still come from git — each card links out to the compare view."]})]})}function ts(s,t){var a,l;return s.merged?{kind:"accepted",text:"✅ Accepted"}:s.pushed?t&&((a=s.pr)==null?void 0:a.state)==="CLOSED"?{kind:"declined",text:"✖ Declined"}:t&&((l=s.pr)==null?void 0:l.reviews)==="CHANGES_REQUESTED"?{kind:"changes",text:"💬 Changes requested"}:{kind:"awaiting",text:"⏳ Awaiting review"}:{kind:"local",text:"📥 Saved locally"}}function rs({p:s,gh:t,onView:a}){const l=ts(s,t);return e.jsxs("div",{className:"pr-card"+(l.kind==="attention"?" attention":""),children:[e.jsxs("div",{className:"pr-top",children:[e.jsx("span",{className:"pr-title",children:s.title}),e.jsx("span",{className:"pr-chip "+l.kind,children:l.text})]}),e.jsxs("div",{className:"pr-branch",children:[s.branch," · ",s.created,s.behind>0&&` · ${s.behind} behind`]}),e.jsxs("div",{className:"pr-foot",children:[e.jsx("span",{className:"pr-link",children:s.merged?e.jsxs(e.Fragment,{children:["merged",t&&s.pr?` · PR #${s.pr.number}`:""]}):s.pushed?t&&s.pr?e.jsxs("a",{href:s.pr.url,target:"_blank",rel:"noreferrer",children:["PR #",s.pr.number]}):s.compare_url?e.jsx("a",{href:s.compare_url,target:"_blank",rel:"noreferrer",children:"open the compare view →"}):"pushed":"not sent yet — no connection to the shared repo"}),e.jsx("span",{className:"pr-btns",children:e.jsx("button",{className:"sme-secondary",onClick:a,children:"View"})})]})]})}function is({changes:s,routeAdvice:t,identity:a,user:l,onUser:i,onDrop:x,onClose:c,onProposed:r}){const m=!!(a&&a.source!=="anonymous"),[N,b]=n.useState(l||(localStorage.getItem("mdl.sme.user")??"")),[f,h]=n.useState(s.length===1?`Update ${s[0].label.toLowerCase()}`:"Glossary updates"),[g,_]=n.useState(""),[C,T]=n.useState(!1),S=ve(C?g:f),[y,A]=n.useState(""),[k,R]=n.useState(!1),[o,E]=n.useState(null),[$,D]=n.useState(null),B=()=>{!m&&!N.trim()||(m||(localStorage.setItem("mdl.sme.user",N.trim()),i==null||i(N.trim())),R(!0),D(null),Te({user:m?"":N.trim(),slug:S,title:f,body:y.trim(),changes:s.map(u=>({op:u.op,payload:u.payload}))}).then(E).catch(u=>D(u instanceof Be?u.message:String(u))).finally(()=>R(!1)))};return o!=null&&o.ok?e.jsx("div",{className:"sme-modal-backdrop",onClick:()=>r(o),children:e.jsxs("div",{className:"sme-modal",onClick:u=>u.stopPropagation(),children:[e.jsx("h2",{children:"✓ Sent for review"}),e.jsx("p",{children:o.message}),o.pr_url&&e.jsx("p",{children:e.jsx("a",{href:o.pr_url,target:"_blank",rel:"noreferrer",children:"View pull request →"})}),o.compare_url&&e.jsx("p",{children:e.jsx("a",{href:o.compare_url,target:"_blank",rel:"noreferrer",children:"Open the pull request →"})}),e.jsxs("p",{className:"sme-muted",children:["Your suggestion is on branch ",e.jsx("code",{children:o.branch}),". A steward will review it."]}),e.jsx("div",{className:"sme-modal-foot",children:e.jsx("button",{className:"sme-primary",onClick:()=>r(o),children:"Done"})})]})}):e.jsx("div",{className:"sme-modal-backdrop",onClick:c,children:e.jsxs("div",{className:"sme-modal",onClick:u=>u.stopPropagation(),children:[e.jsxs("div",{className:"sme-card-head",children:[e.jsx("h2",{children:"Submit for review"}),e.jsx("button",{className:"sme-secondary",onClick:c,children:"✕"})]}),e.jsx("h3",{children:"Your changes"}),e.jsx("ul",{className:"sme-diff",children:s.map((u,P)=>e.jsxs("li",{children:[e.jsxs("div",{className:"sme-diff-head",children:[e.jsx("b",{children:u.label}),e.jsx("button",{className:"sme-link danger",onClick:()=>x(P),children:"discard"})]}),e.jsx("div",{className:"sme-diff-before",children:u.before}),e.jsx("div",{className:"sme-diff-after",children:u.after})]},P))}),m?e.jsxs("p",{className:"sme-signed-in",children:["Signed in as ",e.jsx("strong",{children:a.name}),a.email?` (${a.email})`:""," — this proposal is attributed to you."]}):e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Your name (for attribution)"}),e.jsx("input",{value:N,onChange:u=>b(u.target.value),placeholder:"e.g. a.hough"})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Title"}),e.jsx("input",{value:f,onChange:u=>h(u.target.value)})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Branch name (edit to add a ticket id, e.g. PROJ-123)"}),e.jsx("input",{value:C?g:S,onChange:u=>{T(!0),_(u.target.value)},placeholder:"clarify-counterparty"})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Note for the reviewer (optional)"}),e.jsx("textarea",{rows:2,value:y,onChange:u=>A(u.target.value)})]}),e.jsx(ls,{cl:t,user:m?a.name:N,changeSlug:S}),$&&e.jsx("p",{className:"sme-error-line",children:$}),e.jsxs("div",{className:"sme-modal-foot",children:[e.jsx("button",{className:"sme-secondary",onClick:c,children:"Keep editing"}),e.jsx("button",{className:"sme-primary",disabled:k||!m&&!N.trim()||s.length===0,onClick:B,children:k?"Submitting…":"Submit for review"})]})]})})}function ls({cl:s,user:t,changeSlug:a}){if(!(s!=null&&s.primary))return null;const l=s.reviewers_actual??s.reviewers,i=ve(t||"you"),x=s.routes.length>1;return e.jsxs(e.Fragment,{children:[x&&e.jsxs("div",{className:"sub-advice",children:[e.jsx("span",{className:"i",children:"ⓘ"})," These changes span routes"," ",e.jsx("strong",{children:s.routes.join(" and ")}),". The whole proposal will be reviewed as route ",s.primary," (",s.primary_name,") — the strictest gate wins, so it needs"," ",l.join(", ")," plus ",e.jsx("code",{children:s.gates[s.gates.length-1]}),". Proposing the meaning-only changes separately would get them reviewed faster."]}),e.jsxs("div",{className:"sub-dest",children:[e.jsxs("div",{className:"row",children:[e.jsx("span",{className:"k",children:"Goes to"}),e.jsxs("code",{children:["sme/",i,"/",a||"…"]})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{className:"k",children:"Reviewers"}),e.jsx("span",{children:l.join(", ")})]})]})]})}function ve(s){return s.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")||"change"}const cs="modulepreload",os=function(s){return"/"+s},oe={},ds=function(t,a,l){let i=Promise.resolve();if(a&&a.length>0){document.getElementsByTagName("link");const c=document.querySelector("meta[property=csp-nonce]"),r=(c==null?void 0:c.nonce)||(c==null?void 0:c.getAttribute("nonce"));i=Promise.allSettled(a.map(m=>{if(m=os(m),m in oe)return;oe[m]=!0;const N=m.endsWith(".css"),b=N?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${m}"]${b}`))return;const f=document.createElement("link");if(f.rel=N?"stylesheet":cs,N||(f.as="script"),f.crossOrigin="",f.href=m,r&&f.setAttribute("nonce",r),document.head.appendChild(f),N)return new Promise((h,g)=>{f.addEventListener("load",h),f.addEventListener("error",()=>g(new Error(`Unable to preload CSS for ${m}`)))})}))}function x(c){const r=new Event("vite:preloadError",{cancelable:!0});if(r.payload=c,window.dispatchEvent(r),!r.defaultPrevented)throw c}return i.then(c=>{for(const r of c||[])r.status==="rejected"&&x(r.reason);return t().catch(x)})};function hs({diff:s,selectable:t,selected:a,onToggle:l}){var r;const[i,x]=n.useState(((r=s.objects[0])==null?void 0:r.ulid)??null),c=n.useMemo(()=>s.objects.find(m=>m.ulid===i)??s.objects[0]??null,[s,i]);return s.objects.length?e.jsxs("div",{className:"rv-body",children:[e.jsx("div",{className:"rv-list",children:s.objects.map(m=>e.jsx(us,{obj:m,active:m.ulid===((c==null?void 0:c.ulid)??""),checked:(a==null?void 0:a.has(m.ulid))??!0,selectable:t,onSelect:()=>x(m.ulid),onToggle:()=>l==null?void 0:l(m.ulid)},m.ulid))}),e.jsx("div",{className:"rv-detail",children:c&&e.jsx(ps,{obj:c})})]}):e.jsxs("p",{className:"sme-placeholder",children:["No model changes against ",s.base.label,"."]})}function ms(s){return s.renamed?"renamed":s.severity==="breaking"?"breaking":s.change}function us({obj:s,active:t,checked:a,selectable:l,onSelect:i,onToggle:x}){return e.jsxs("div",{className:"rv-obj"+(t?" active":""),onClick:i,children:[e.jsxs("div",{className:"rv-obj-head",children:[l&&e.jsx("input",{type:"checkbox",checked:a,onClick:c=>c.stopPropagation(),onChange:x,"aria-label":`include ${s.name_before??s.name_after}`}),e.jsx("span",{className:"rv-obj-name",children:s.name_before??s.name_after}),e.jsx("span",{className:"rv-obj-kind",children:s.object_kind_label}),e.jsxs("span",{className:"sev "+(s.renamed?"renamed":s.severity),children:[s.severity==="breaking"&&"⚠ ",ms(s)]})]}),e.jsxs("ul",{className:"rv-obj-fields",children:[s.fields.map((c,r)=>e.jsx("li",{children:c.label},c.field+r)),s.children.flatMap(c=>c.fields.map((r,m)=>e.jsxs("li",{children:[r.label,": ",c.name_before??c.name_after]},c.ulid+m)))]})]})}function ps({obj:s}){const t=s.children.flatMap(a=>a.fields.map(l=>({field:l,on:a.name_before??a.name_after??""})));return e.jsxs(e.Fragment,{children:[e.jsx("h2",{children:s.renamed?`${s.name_before} → ${s.name_after}`:s.name_before??s.name_after}),e.jsxs("div",{className:"rv-detail-kind",children:[s.object_kind_label,s.renamed&&" · renamed",s.path&&e.jsxs("span",{className:"rv-src",children:[" · ",s.path]})]}),s.fields.map((a,l)=>e.jsx(de,{f:a},a.field+l)),t.map(({field:a,on:l},i)=>e.jsx(de,{f:a,on:l},"c"+i)),s.severity==="breaking"&&e.jsxs("div",{className:"rv-note",children:[e.jsx("span",{className:"i",children:"ⓘ"}),e.jsxs("span",{children:["This is a structural change (route B). It will need an architect's review, and CI will run ",e.jsx("code",{children:"mdl drift --check"}),"."]})]})]})}function de({f:s,on:t}){var l,i,x;const a=t?`${s.label}: ${t}`:s.label;if((l=s.breaks)!=null&&l.length)return e.jsxs("div",{className:"rv-section",children:[e.jsx("h3",{children:a}),e.jsxs("div",{className:"rv-break",children:[e.jsxs("div",{className:"rv-break-head",children:[e.jsx("span",{children:"⚠"}),e.jsxs("span",{children:[t??String(s.before??"")," removed"]}),s.detail&&e.jsx("span",{className:"rv-break-meta",children:s.detail})]}),e.jsxs("div",{className:"rv-break-body",children:["This is realised by ",s.breaks.length," dbt model",s.breaks.length>1?"s":"",". Removing it will break ",s.breaks.length>1?"them":"it","."]}),e.jsx("ul",{className:"rv-usage",children:s.breaks.map(c=>e.jsxs("li",{children:[e.jsxs("span",{className:"m",children:["→ ",c.name]}),e.jsxs("span",{className:"p",children:[c.target," · ",c.materialization]})]},c.name))})]})]});if(s.kind==="definition_changed"){const c=String(s.before??""),r=String(s.after??"");return e.jsxs("div",{className:"rv-section",children:[e.jsx("h3",{children:a}),e.jsxs("div",{className:"rv-ba",children:[e.jsxs("div",{className:"rv-ba-col",children:[e.jsx("div",{className:"rv-ba-label",children:"before"}),e.jsx("div",{className:"rv-ba-text",children:me(c,r,"del")})]}),e.jsxs("div",{className:"rv-ba-col",children:[e.jsx("div",{className:"rv-ba-label",children:"after"}),e.jsx("div",{className:"rv-ba-text",children:me(r,c,"ins")})]})]})]})}if(Array.isArray(s.before)||Array.isArray(s.after)){const c=new Set(((i=s.before)==null?void 0:i.map(String))??[]),r=new Set(((x=s.after)==null?void 0:x.map(String))??[]),m=[...r].filter(b=>!c.has(b)),N=[...c].filter(b=>!r.has(b));return e.jsxs("div",{className:"rv-section",children:[e.jsx("h3",{children:a}),e.jsxs("div",{className:"rv-chips",children:[m.map(b=>e.jsxs("span",{className:"rv-chip-add",children:["+ ",b]},"a"+b)),N.map(b=>e.jsxs("span",{className:"rv-chip-del",children:["− ",b]},"r"+b))]})]})}return e.jsxs("div",{className:"rv-section",children:[e.jsx("h3",{children:a}),e.jsxs("div",{className:"rv-scalar",children:[e.jsx("span",{className:"from",children:he(s.before)}),e.jsx("span",{className:"arrow",children:"→"}),e.jsx("span",{className:"to",children:he(s.after)})]})]})}function he(s){return s==null||s===""?"—":typeof s=="boolean"?s?"yes":"no":typeof s=="object"?JSON.stringify(s):String(s)}function me(s,t,a){const l=s.split(/(\s+)/),i=t.split(/(\s+)/),x=h=>h.toLowerCase().replace(/[.,;:]/g,""),c=l.length,r=i.length,m=Array.from({length:c+1},()=>new Array(r+1).fill(0));for(let h=c-1;h>=0;h--)for(let g=r-1;g>=0;g--)m[h][g]=x(l[h])===x(i[g])?m[h+1][g+1]+1:Math.max(m[h+1][g],m[h][g+1]);const N=new Set;let b=0,f=0;for(;b=m[b][f+1]?b++:f++;return l.map((h,g)=>!h.trim()||N.has(g)?e.jsx("span",{children:h},g):a==="del"?e.jsx("del",{children:h},g):e.jsx("ins",{children:h},g))}const js=n.lazy(()=>ds(()=>import("./ModelDiagram-B_FcBTxi.js"),__vite__mapDeps([0,1,2,3])).then(s=>({default:s.ModelDiagram})));function xs({stagedDiff:s,stagedRoute:t,user:a,onBack:l,onSubmit:i,excluded:x,onToggleObject:c,selectable:r}){const[m,N]=n.useState(null),[b,f]=n.useState(null),[h,g]=n.useState(null),[_,C]=n.useState(null),[T,S]=n.useState(null),[y,A]=n.useState("changes"),k=n.useCallback(()=>{je(a).then(o=>{C(o);const E=o.base_branch??"main";s?N(s):Le("HEAD").then($=>$.ok?N($):S($.error??"could not read the model")).catch($=>S(String($))),t?f(t):Fe("HEAD").then(f).catch(()=>{}),ze(E).then(g).catch(()=>{})}).catch(o=>S(String(o)))},[a,s,t]);if(n.useEffect(k,[k]),T)return e.jsxs("div",{className:"rv-shell",children:[e.jsx(ue,{onBack:l,diff:null}),e.jsx("p",{className:"sme-splash error",children:e.jsx("pre",{children:T})})]});if(!m)return e.jsx("div",{className:"sme-splash",children:"◮ reading your changes…"});const R=new Set(m.objects.map(o=>o.ulid).filter(o=>!x.has(o)));return e.jsxs("div",{className:"rv-shell",children:[e.jsx(ue,{onBack:l,diff:m,tab:y,onTab:A}),y==="changes"?e.jsx(hs,{diff:m,selectable:r,selected:R,onToggle:c}):e.jsx(n.Suspense,{fallback:e.jsx("div",{className:"sme-splash",children:"◮ drawing the model…"}),children:e.jsx(js,{severityByUlid:bs(m)})}),e.jsxs("div",{className:"rv-foot",children:[e.jsx(gs,{cl:b}),e.jsx(fs,{conf:h,ctx:_}),e.jsxs("div",{className:"rv-actions",children:[e.jsx("button",{className:"sme-secondary",onClick:l,children:"Keep editing"}),e.jsx("button",{className:"sme-primary",disabled:!m.objects.length||(_?!_.can_propose:!1),title:_&&!_.can_propose?"the working tree has uncommitted changes":"",onClick:()=>i(b),children:"Submit for review →"})]})]})]})}function bs(s){const t=new Map;for(const a of s.objects)t.set(a.ulid,a.severity);return t}function ue({onBack:s,diff:t,tab:a,onTab:l}){const i=t==null?void 0:t.counts;return e.jsxs("div",{className:"rv-head",children:[e.jsxs("div",{children:[e.jsx("button",{className:"rv-back",onClick:s,children:"‹ Back to terms"}),e.jsx("h1",{className:"rv-title",children:"Review your changes"}),i&&e.jsxs("div",{className:"rv-sub",children:[i.objects," object",i.objects===1?"":"s"," ·"," ",(i.breaking??0)+(i.additive??0)+(i.cosmetic??0)," changes",(t==null?void 0:t.has_breaking)&&e.jsxs("span",{className:"warn",children:[" · ⚠ ",i.breaking," breaking"]})," · comparing to ",e.jsx("code",{children:t==null?void 0:t.base.label})]})]}),l&&e.jsxs("div",{className:"rv-tabs",children:[e.jsxs("button",{className:"rv-tab"+(a==="changes"?" active":""),onClick:()=>l("changes"),children:["Changes ",(t==null?void 0:t.objects.length)??0]}),e.jsx("button",{className:"rv-tab"+(a==="diagram"?" active":""),onClick:()=>l("diagram"),children:"Diagram"})]})]})}function gs({cl:s}){if(!(s!=null&&s.primary))return null;const t=s.reviewers_actual??s.reviewers;return e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"rv-route",children:[e.jsxs("span",{className:"rv-route-badge",children:["Route ",s.primary," · ",s.primary_name]}),e.jsx("span",{className:"k",children:"Reviewers"}),e.jsx("span",{children:t.join(", ")}),s.reviewers_actual&&e.jsx("span",{className:"rv-src",children:"from .github/CODEOWNERS"})]}),e.jsxs("div",{className:"rv-gates",children:["CI gates"," ",s.gates.map(a=>e.jsx("code",{children:a},a))]})]})}function fs({conf:s,ctx:t}){return s!=null&&s.ok?s.clean?e.jsxs("div",{className:"rv-conflict ok",children:[e.jsx("span",{children:"✓"}),e.jsxs("span",{children:["Merges cleanly onto ",s.base]}),s.behind>0&&e.jsxs("span",{className:"detail",children:["· ",s.base," is ",s.behind," commit",s.behind===1?"":"s"," ahead"]}),(t==null?void 0:t.dirty)&&e.jsx("span",{className:"detail",children:"· uncommitted changes in the working tree"})]}):e.jsxs("div",{className:"rv-conflict bad",children:[e.jsx("span",{children:"⚠"}),e.jsxs("span",{children:[s.base," has changed and ",s.files.length," file",s.files.length===1?"":"s"," now conflict"]}),e.jsx("span",{className:"detail",children:s.files.map(a=>a.path).join(", ")})]}):null}function vs({term:s,canEdit:t,pendingCount:a,onEdit:l}){var i,x;return e.jsxs("div",{className:"sme-card",children:[e.jsxs("div",{className:"sme-card-head",children:[e.jsxs("div",{children:[e.jsx("h1",{children:s.name}),((i=s.subject_area)==null?void 0:i.name)&&e.jsx("span",{className:"sme-chip",children:s.subject_area.name}),s.kind==="term"&&e.jsx("span",{className:"sme-chip",children:"glossary term"})]}),t&&e.jsx("button",{className:"sme-primary",onClick:l,children:a>0?"Continue editing":"Suggest an edit"})]}),e.jsx("p",{className:"sme-definition",children:s.definition??e.jsx("em",{children:"No definition yet."})}),s.synonyms.length>0&&e.jsxs("section",{children:[e.jsx("h3",{children:"Also called"}),e.jsx("div",{children:s.synonyms.map(c=>e.jsx("span",{className:"sme-chip",children:c},c))})]}),s.stewardship&&(s.stewardship.owner||s.stewardship.steward)&&e.jsxs("section",{children:[e.jsx("h3",{children:"Who owns this"}),s.stewardship.owner&&e.jsxs("div",{className:"sme-kv",children:[e.jsx("span",{className:"k",children:"owner"}),e.jsx("span",{children:s.stewardship.owner})]}),s.stewardship.steward&&e.jsxs("div",{className:"sme-kv",children:[e.jsx("span",{className:"k",children:"steward"}),e.jsx("span",{children:s.stewardship.steward})]})]}),((x=s.ontology)==null?void 0:x.aligns_to)&&e.jsxs("section",{children:[e.jsx("h3",{children:"Standard it maps to"}),e.jsxs("div",{className:"sme-kv",children:[e.jsx("span",{className:"k",children:"aligned to"}),e.jsx("code",{children:s.ontology.aligns_to}),s.ontology.status==="proposed"&&e.jsx("span",{className:"sme-badge proposed",children:"awaiting architect"})]})]}),e.jsxs("section",{children:[e.jsx("h3",{children:"Where it's used"}),s.where_used.length===0?e.jsx("p",{className:"sme-muted",children:s.kind==="term"?"A glossary term — not yet realised by a data model.":"Not yet realised by any data model."}):e.jsx("ul",{className:"sme-usage",children:s.where_used.map(c=>e.jsxs("li",{children:[e.jsx("span",{className:"sme-usage-model",children:c.logical_entity}),c.unmanaged&&e.jsx("span",{className:"sme-badge",children:"engineer-owned"}),c.physical.length>0&&e.jsx("span",{className:"sme-usage-phys",children:c.physical.map(r=>r.name).join(", ")})]},c.logical_id))})]})]})}function Ns({term:s,pending:t,onStage:a,onDone:l,catalogOwned:i=[],catalog:x=null}){var E,$,D,B;const c=u=>i.includes(u),r=u=>t.find(P=>P.label===u),m=(u,P)=>{const F=r(u);return F?F.after:P},[N,b]=n.useState(m("Definition",s.definition??"")),[f,h]=n.useState(s.synonyms),[g,_]=n.useState(""),[C,T]=n.useState(((E=s.stewardship)==null?void 0:E.owner)??""),[S,y]=n.useState((($=s.stewardship)==null?void 0:$.steward)??""),A=()=>{N.trim()!==(s.definition??"").trim()&&a({op:"set_definition",payload:{id:s.id,definition:N.trim()},label:"Definition",before:s.definition??"(none)",after:N.trim()||"(none)"})},k=(u,P)=>{var F,I;a({op:"set_stewardship",payload:{id:s.id,owner:u||null,steward:P||null},label:"Stewardship",before:`owner: ${((F=s.stewardship)==null?void 0:F.owner)??"—"} · steward: ${((I=s.stewardship)==null?void 0:I.steward)??"—"}`,after:`owner: ${u||"—"} · steward: ${P||"—"}`})},R=u=>{h(u),a({op:"update_synonyms",payload:{id:s.id,synonyms:u},label:"Also called",before:s.synonyms.join(", ")||"(none)",after:u.join(", ")||"(none)"})},o=s.kind==="term";return e.jsxs("div",{className:"sme-editor",children:[e.jsxs("div",{className:"sme-card-head",children:[e.jsxs("h1",{children:["Suggest an edit — ",s.name]}),e.jsx("button",{className:"sme-secondary",onClick:l,children:"Back"})]}),e.jsxs("p",{className:"sme-hint",children:["You can refine what this term ",e.jsx("em",{children:"means"}),". Structure (keys, relationships, how it's built) stays with the data team."]}),(c("definition")||c("synonyms")||c("stewardship"))&&x&&e.jsxs("div",{className:"sme-catalog-banner",children:["Definitions, synonyms, and ownership are mastered in ",e.jsx("b",{children:x.name}),". Edit them there — this glossary mirrors them read-only.",x.url&&e.jsxs(e.Fragment,{children:[" ",e.jsxs("a",{href:x.url,target:"_blank",rel:"noreferrer",children:["Open in ",x.name," →"]})]}),e.jsx("div",{className:"sme-catalog-note",children:"You can still propose a standard mapping below."})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Definition"}),c("definition")?e.jsx("p",{className:"sme-readonly-value",children:s.definition??"—"}):e.jsx("textarea",{rows:4,value:N,onChange:u=>b(u.target.value),onBlur:A,placeholder:"A plain-language business definition…"})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Also called (synonyms)"}),c("synonyms")?e.jsx("div",{className:"sme-syn-row",children:s.synonyms.length?s.synonyms.map(u=>e.jsx("span",{className:"sme-chip",children:u},u)):e.jsx("span",{className:"sme-muted",children:"none"})}):e.jsxs("div",{className:"sme-syn-row",children:[f.map(u=>e.jsxs("span",{className:"sme-chip removable",onClick:()=>R(f.filter(P=>P!==u)),children:[u," ✕"]},u)),e.jsx("input",{value:g,placeholder:"add + Enter",onChange:u=>_(u.target.value),onKeyDown:u=>{u.key==="Enter"&&g.trim()&&(R([...f,g.trim()]),_(""))}})]})]}),!o&&(c("stewardship")?e.jsxs("div",{className:"sme-field-row",children:[e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Owner"}),e.jsx("p",{className:"sme-readonly-value",children:((D=s.stewardship)==null?void 0:D.owner)??"—"})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Steward"}),e.jsx("p",{className:"sme-readonly-value",children:((B=s.stewardship)==null?void 0:B.steward)??"—"})]})]}):e.jsxs("div",{className:"sme-field-row",children:[e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Owner"}),e.jsx("input",{value:C,onChange:u=>T(u.target.value),onBlur:()=>k(C,S)})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Steward"}),e.jsx("input",{value:S,onChange:u=>y(u.target.value),onBlur:()=>k(C,S)})]})]})),e.jsx(ys,{term:s,onStage:a}),e.jsx("p",{className:"sme-note",children:t.length>0?`${t.length} change(s) staged — use “Submit for review” at the top when you're done.`:"Changes you make are collected until you submit them for review."})]})}const ws=["skos:exactMatch","skos:closeMatch","skos:broadMatch","skos:narrowMatch"];function ys({term:s,onStage:t}){var f;const[a,l]=n.useState(!1),[i,x]=n.useState(s.name),[c,r]=n.useState([]),[m,N]=n.useState("skos:closeMatch"),b=h=>{x(h),qe(h).then(g=>r(g.results)).catch(()=>r([]))};return a?e.jsxs("div",{className:"sme-field sme-align",children:[e.jsx("span",{children:"Propose a standard mapping (a suggestion; the architect confirms)"}),e.jsx("input",{value:i,onChange:h=>b(h.target.value),placeholder:"Search standards…",autoFocus:!0}),e.jsx("div",{className:"sme-align-results",children:c.map(h=>e.jsxs("button",{className:"sme-align-card",onClick:()=>{var g;t({op:"set_alignment",payload:{id:s.id,aligns_to:h.prefixed,alignment:m,status:"proposed"},label:"Standard mapping",before:((g=s.ontology)==null?void 0:g.aligns_to)??"(none)",after:`${h.prefixed} (${m}, proposed)`}),l(!1)},children:[e.jsx("strong",{children:h.label})," ",e.jsx("code",{children:h.prefixed}),h.definition&&e.jsx("span",{className:"sme-align-def",children:h.definition})]},h.iri))}),e.jsx("select",{value:m,onChange:h=>N(h.target.value),children:ws.map(h=>e.jsx("option",{children:h},h))})]}):e.jsxs("div",{className:"sme-field",children:[e.jsx("span",{children:"Standard mapping"}),e.jsxs("div",{children:[(f=s.ontology)!=null&&f.aligns_to?e.jsx("code",{className:"sme-current-align",children:s.ontology.aligns_to}):e.jsx("span",{className:"sme-muted",children:"not mapped to a standard"}),e.jsx("button",{className:"sme-link",onClick:()=>l(!0),children:"Propose a mapping…"})]})]})}function _s(){const[s,t]=n.useState(null),[a,l]=n.useState(!0),[i,x]=n.useState(null),[c,r]=n.useState(""),[m,N]=n.useState(null),[b,f]=n.useState(""),[h,g]=n.useState(""),[_,C]=n.useState(null),[T,S]=n.useState(!1),[y,A]=n.useState([]),[k,R]=n.useState(!1),[o,E]=n.useState(window.location.hash==="#proposals"?"proposals":"browse"),[$,D]=n.useState(new Set),[B,u]=n.useState(()=>localStorage.getItem("mdl.sme.user")??""),[P,F]=n.useState(null),[I,z]=n.useState(null),[W,U]=n.useState(!1),[K,j]=n.useState(null),d=n.useCallback((p,v)=>{var ae;const O=J=>{var te;const V=I==null?void 0:I.entities.find(re=>{var ie;return re.id===J||((ie=re.conceptual)==null?void 0:ie.id)===J});return((te=V==null?void 0:V.conceptual)==null?void 0:te.name)??(V==null?void 0:V.name)??"object"},L=O(v.id??v.entity_id);switch(p){case"create_entity":return{label:`New entity: ${v.name}`,before:"—",after:String(v.name??"")};case"create_relationship":return{label:`New relationship: ${O(v.from_entity)} → ${O(v.to_entity)}`,before:"—",after:String(v.cardinality??"many_to_one")};case"delete_entity":return{label:`Deleted ${L}`,before:L,after:"—"};case"delete_relationship":return{label:"Relationship removed",before:"",after:"—"};case"update_relationship":return{label:"Relationship changed",before:"",after:[v.cardinality,v.optionality,v.identifying?"identifying":""].filter(Boolean).join(", ")};case"set_definition":return{label:`Definition: ${L}`,before:"",after:String(v.definition??"")};case"add_attribute":return{label:`Attribute added to ${L}`,before:"—",after:String(v.name??"")};case"delete_attribute":return{label:`Attribute removed from ${L}`,before:"",after:"—"};case"update_attribute":return{label:`Attribute changed on ${L}`,before:"",after:JSON.stringify(v)};case"rename_entity":return{label:`Renamed ${L}`,before:L,after:String(v.name??"")};case"set_subject_area":return{label:`Subject area: ${L}`,before:"",after:String(v.subject_area??"none")};case"set_stewardship":return{label:`Stewardship: ${L}`,before:"",after:String(v.steward??"")};case"set_subject_area_members":{const J=v.__label,V=((ae=v.members)==null?void 0:ae.length)??0;return{label:J??"Subject area members",before:"",after:`${V} object${V===1?"":"s"}`}}case"set_alignment":return{label:`Alignment: ${L}`,before:"",after:String(v.aligns_to??"")};default:return{label:`${p.replace(/_/g," ")}: ${L}`,before:"",after:""}}},[I]),w=Je({subjectArea:b,describe:d}),M=n.useCallback(async(p,v)=>{const O=(I==null?void 0:I.fingerprint)??"",L=await We(p,v,O);return await le(b||void 0).then(z),L},[I,b]),q=n.useMemo(()=>{const p={};for(const v of w.pending)v.op==="set_subject_area_members"&&(p[v.payload.id]=v.payload.members??[]);return p},[w.pending]),G=n.useMemo(()=>[...y,...w.pending],[y,w.pending]),[Ne,X]=n.useState(null),[we,Z]=n.useState(null);n.useEffect(()=>{if(!G.length){X(null),Z(null);return}const p=new AbortController,v=G.map(O=>({op:O.op,payload:O.payload}));return pe(v,b||void 0,p.signal).then(O=>X(O.diff??null)).catch(O=>{O.name!=="AbortError"&&X(null)}),Ge(v).then(Z).catch(()=>Z(null)),()=>p.abort()},[G,b]);const ye=n.useMemo(()=>Qe(G),[G]).size{E(p),window.location.hash=p==="browse"?"":`#${p}`},[]);n.useEffect(()=>{const p=()=>{const v=window.location.hash;E(v==="#proposals"?"proposals":v==="#review"?"review":v==="#model"?"model":v==="#areas"?"areas":"browse")};return p(),window.addEventListener("hashchange",p),()=>window.removeEventListener("hashchange",p)},[]);const ee=n.useCallback(()=>{He({subject_area:b,q:h}).then(t).catch(p=>N(String(p)))},[b,h]);n.useEffect(ee,[ee]),n.useEffect(()=>{le(b||void 0).then(p=>{l(p.read_only),r(p.project.name),z(p),U(!!p.direct),F(p.identity??null),p.identity&&p.identity.source!=="anonymous"&&p.identity.name&&u(p.identity.name)}).catch(()=>{}),Ve().then(x).catch(()=>{})},[b]);const se=n.useMemo(()=>(s==null?void 0:s.terms.find(p=>p.id===_))??null,[s,_]),_e=n.useCallback(p=>{const v=p.key??fe(p.op,p.payload);A(O=>[...O.filter(L=>L.key!==v),{...p,key:v}])},[]),Se=p=>A(v=>v.filter((O,L)=>L!==p)),ke=()=>{A([]),R(!1),S(!1),ee()};if(m)return e.jsxs("div",{className:"sme-splash error",children:[e.jsx("h1",{children:"◮ Glossary"}),e.jsx("pre",{children:m}),e.jsxs("p",{children:["Is ",e.jsx("code",{children:"mdl glossary"})," pointed at a model?"]})]});if(!s)return e.jsx("div",{className:"sme-splash",children:"◮ loading glossary…"});const Y=!a,ne=(i==null?void 0:i.catalog_owned_fields)??[],Ce=ne.length>0;return e.jsxs("div",{className:"sme",children:[e.jsxs("header",{className:"sme-top",children:[e.jsxs("div",{className:"sme-brand",children:[e.jsx("span",{className:"sme-logo",children:"◮"}),e.jsx("span",{children:"Glossary"}),e.jsx("span",{className:"sme-project",children:c}),a&&e.jsx("span",{className:"sme-chip",children:"read-only"}),Ce&&e.jsxs("span",{className:"sme-chip catalog",title:`Definitions are mastered in ${i==null?void 0:i.catalog_name}`,children:["mirrors ",i==null?void 0:i.catalog_name]})]}),e.jsx("input",{className:"sme-search",type:"search",placeholder:"Search terms & definitions…",value:h,onChange:p=>g(p.target.value)}),e.jsxs("nav",{className:"sme-tabs",children:[e.jsx("button",{className:"sme-tab"+(o==="browse"?" active":""),onClick:()=>H("browse"),children:"Terms"}),e.jsx("button",{className:"sme-tab"+(o==="model"?" active":""),onClick:()=>H("model"),children:"Model"}),e.jsx("button",{className:"sme-tab"+(o==="areas"?" active":""),onClick:()=>H("areas"),children:"Subject areas"}),e.jsx("button",{className:"sme-tab"+(o==="proposals"?" active":""),onClick:()=>H("proposals"),children:"My proposals"})]}),G.length>0&&e.jsxs("button",{className:"sme-tray",onClick:()=>H("review"),children:[G.length," change",G.length>1?"s":""," · Review →"]})]}),o==="browse"&&e.jsx(Xe,{user:B}),o==="review"?e.jsx(xs,{stagedDiff:Ne,stagedRoute:we,user:B,selectable:ye,excluded:$,onToggleObject:p=>D(v=>{const O=new Set(v);return O.has(p)?O.delete(p):O.add(p),O}),onBack:()=>H("browse"),onSubmit:p=>{j(p),R(!0)}}):o==="model"?w.previewDoc??I?e.jsx(ns,{doc:w.previewDoc??I,subjectArea:b,onSubjectArea:f,exec:W?M:w.exec,canEdit:Y,direct:W,onImported:()=>H("review"),query:h,busy:w.busy}):e.jsx("div",{className:"sme-splash",children:"◮ loading the model…"}):o==="areas"?e.jsx(Ze,{exec:w.exec,canEdit:Y,stagedMembers:q}):o==="proposals"?e.jsx(as,{user:B,onView:()=>H("review")}):e.jsxs("div",{className:"sme-body",children:[e.jsxs("nav",{className:"sme-nav",children:[e.jsx("button",{className:"sme-sa"+(b===""?" active":""),onClick:()=>f(""),children:"All subject areas"}),s.subject_areas.map(p=>e.jsx("button",{className:"sme-sa"+(b===p.id?" active":""),onClick:()=>f(p.id),title:p.definition??void 0,children:p.name},p.id))]}),e.jsxs("ul",{className:"sme-list",children:[s.terms.map(p=>{var v;return e.jsxs("li",{className:"sme-list-item"+(p.id===_?" active":""),onClick:()=>{C(p.id),S(!1)},children:[e.jsx("span",{className:"sme-term-name",children:p.name}),p.kind==="term"&&e.jsx("span",{className:"sme-kind",children:"term"}),((v=p.ontology)==null?void 0:v.status)==="proposed"&&e.jsx("span",{className:"sme-badge proposed",title:"alignment awaiting architect",children:"proposed"}),e.jsx("span",{className:"sme-term-def",children:p.definition??"no definition yet"})]},p.id)}),s.terms.length===0&&e.jsx("li",{className:"sme-empty",children:"no terms match"})]}),e.jsx("section",{className:"sme-detail",children:se?T&&Y?e.jsx(Ns,{term:se,pending:y,onStage:_e,onDone:()=>S(!1),catalogOwned:ne,catalog:i?{name:i.catalog_name,url:i.catalog_url}:null}):e.jsx(vs,{term:se,canEdit:Y,pendingCount:y.length,onEdit:()=>S(!0)}):e.jsx("div",{className:"sme-placeholder",children:"Select a term to see its definition."})})]}),k&&e.jsx(is,{changes:G,routeAdvice:K,identity:P,user:B,onUser:p=>{u(p),localStorage.setItem("mdl.sme.user",p)},onDrop:Se,onClose:()=>R(!1),onProposed:()=>{ke(),w.clear(),D(new Set),H("proposals")}})]})}Ae.createRoot(document.getElementById("root")).render(e.jsx(Ee.StrictMode,{children:e.jsx(_s,{})})); +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/ModelDiagram-CEOzUQbJ.js","assets/client-DOmNpTln.js","assets/styles-B2ouEC7_.js","assets/styles-CJ3Gp-TF.css"])))=>i.map(i=>d[i]); +import{r as n,j as e,c as Ae,R as Ee}from"./client-DOmNpTln.js";import{p as pe,c as je,d as xe,e as $e,h as be,I as Re,R as Me,M as De,O as Pe,L as Ie,i as Oe,j as Te,A as Be,k as Le,l as Fe,m as ze,o as qe,s as We,f as le,n as Ge,q as He,r as Ve}from"./styles-B2ouEC7_.js";/* empty css */const Ke="0123456789ABCDEFGHJKMNPQRSTVWXYZ";function ce(s,t){let a="";for(let l=0;l>=5n;return a}function ge(){const s=BigInt(Date.now())&(1n<<48n)-1n,t=new Uint8Array(10);crypto.getRandomValues(t);let a=0n;for(const l of t)a=a<<8n|BigInt(l);return ce(s,10)+ce(a,16)}const Ue={create_entity:["id","conceptual_id"],create_relationship:["id","from_fk_id"],create_subject_area:["id"],create_term:["id"],create_domain:["id"],create_code_set:["id"],create_key_group:["id"],create_category:["id"],add_attribute:["id"]};function Ye(s,t){const a=Ue[s];if(!a)return t;const l={...t};for(const i of a)l[i]||(l[i]=ge());return l}function fe(s,t){if(s.startsWith("create_")||s==="add_attribute")return`${s}:${t.id??t.attribute_id}`;const a=t.id??t.entity_id??"",l=t.attribute_id??"";return l?`${s}:${a}:${l}`:`${s}:${a}`}function Je({subjectArea:s,describe:t}){const[a,l]=n.useState([]),[i,x]=n.useState(null),[c,r]=n.useState(!1),m=n.useRef(null),N=n.useCallback(async(h,g)=>{g=Ye(h,g),h==="set_alignment"&&g.status===void 0&&(g={...g,status:"proposed"});const _=fe(h,g),{label:C,before:T,after:S}=t(h,g);return l(y=>{const A=y.filter(k=>k.key!==_);if(h.startsWith("delete_")){const k=g.id??g.entity_id;if(k&&y.some(o=>o.op.startsWith("create_")&&o.payload.id===k))return A.filter(o=>o.payload.id!==k)}return[...A,{op:h,payload:g,label:C,before:T,after:S,key:_}]}),{ok:!0,fingerprint:"",created_id:g.id??null,diagnostics:[]}},[t]);n.useEffect(()=>{if(!a.length){x(null);return}const h=setTimeout(()=>{var _;(_=m.current)==null||_.abort();const g=new AbortController;m.current=g,r(!0),pe(a.map(C=>({op:C.op,payload:C.payload})),s,g.signal).then(x).catch(C=>{C.name!=="AbortError"&&x(null)}).finally(()=>r(!1))},250);return()=>clearTimeout(h)},[a,s]);const b=n.useCallback(h=>{l(g=>g.filter(_=>_.key!==h))},[]),f=n.useCallback(()=>l([]),[]);return n.useMemo(()=>({pending:a,previewDoc:(i==null?void 0:i.model)??null,previewDiff:(i==null?void 0:i.diff)??null,diagnostics:(i==null?void 0:i.diagnostics)??[],failure:i&&i.failed_index!==null?{index:i.failed_index,error:i.error??"invalid change"}:null,busy:c,exec:N,drop:b,clear:f}),[a,i,c,N,b,f])}function Qe(s){const t=new Map;for(const l of s)for(const i of["id","conceptual_id","from_fk_id"]){const x=l.payload[i];l.op.startsWith("create_")&&typeof x=="string"&&t.set(x,l.key)}if(!t.size)return new Set;const a=new Set;for(const l of s)if(!l.op.startsWith("create_"))for(const i of Object.values(l.payload))typeof i=="string"&&t.has(i)&&(a.add(l.key),a.add(t.get(i)));return a}function Xe({user:s}){const[t,a]=n.useState(null);return n.useEffect(()=>{je(s).then(a).catch(()=>{})},[s]),!(t!=null&&t.ok)||!t.git?null:t.read_only?e.jsxs("div",{className:"git-banner",children:[e.jsx("code",{children:t.branch}),e.jsx("span",{children:"browse only — editing is disabled on this deployment"})]}):t.dirty?e.jsxs("div",{className:"git-banner warn",children:[e.jsx("code",{children:t.branch}),e.jsx("span",{children:"uncommitted changes in the working tree — a proposal will be refused until they are committed or discarded"})]}):t.on_base?e.jsxs("div",{className:"git-banner",children:[e.jsx("code",{children:t.branch}),e.jsx("span",{children:"protected — your edits become a pull request"})]}):e.jsxs("div",{className:"git-banner",children:[e.jsx("code",{children:t.branch}),e.jsx("span",{children:"your proposal branch"}),t.behind?e.jsxs("span",{className:"dim",children:["· ",t.base_branch," is ",t.behind," ahead"]}):null]})}function Ze({exec:s,canEdit:t,stagedMembers:a}){const[l,i]=n.useState([]),[x,c]=n.useState(""),[r,m]=n.useState(null),[N,b]=n.useState(""),[f,h]=n.useState(new Set),[g,_]=n.useState("ancestors"),[C,T]=n.useState(1),[S,y]=n.useState(null),[A,k]=n.useState(new Set),[R,o]=n.useState(null);n.useEffect(()=>{xe().then(d=>{i(d.subject_areas),c(w=>{var M;return w||((M=d.subject_areas[0])==null?void 0:M.id)||""})}).catch(d=>o(String(d)))},[]),n.useEffect(()=>{x&&(y(null),h(new Set),$e(x).then(m).catch(d=>o(String(d))))},[x]);const E=n.useMemo(()=>a[x]??(r==null?void 0:r.members)??[],[a,x,r]),$=n.useMemo(()=>{const d=new Map;for(const w of[...(r==null?void 0:r.included)??[],...(r==null?void 0:r.available)??[]])d.set(w.id,w);return d},[r]),D=N.trim().toLowerCase(),B=d=>!D||d.name.toLowerCase().includes(D),u=n.useMemo(()=>new Set(E),[E]),P=n.useMemo(()=>E.map(d=>$.get(d)).filter(d=>!!d&&B(d)),[E,$,D]),F=n.useMemo(()=>[...$.values()].filter(d=>!u.has(d.id)&&B(d)),[$,u,D]),I=n.useCallback(d=>{if(!r)return;const w=d.filter(q=>!(r.members??[]).includes(q)).length,M=(r.members??[]).filter(q=>!d.includes(q)).length;s("set_subject_area_members",{id:r.id,members:d,__label:`${r.name}: ${d.length} objects (+${w} −${M})`})},[r,s]),z=(d,w)=>{const M=w?[...E,...d.filter(q=>!u.has(q))]:E.filter(q=>!d.includes(q));I(M),h(new Set)},W=(d,w,M)=>{const q=new Set(d);q.has(w)?q.delete(w):q.add(w),M(q)},U=()=>{if(!r)return;const d=f.size?[...f]:E;if(!d.length){o("add an object first, or select one to expand from");return}o(null),be(r.id,{seeds:d,direction:g,levels:C}).then(w=>{y(w.hops),k(new Set(w.added))}).catch(w=>o(String(w)))},K=()=>{z([...A],!0),y(null)};if(R&&!r)return e.jsx("p",{className:"sme-splash error",children:R});if(!l.length)return e.jsx("p",{className:"sme-placeholder",children:"No subject areas yet."});const j=((r==null?void 0:r.inconsistent)??[]).filter(d=>!u.has(d));return e.jsxs("div",{className:"sa-editor",children:[e.jsx("nav",{className:"sme-nav",children:l.map(d=>e.jsxs("button",{className:"sme-sa"+(d.id===x?" active":""),onClick:()=>c(d.id),title:d.definition??void 0,children:[d.name,e.jsx("span",{className:"sme-sa-count",children:(a[d.id]??d.members).length})]},d.id))}),e.jsxs("div",{className:"sa-main",children:[e.jsxs("div",{className:"sa-head",children:[e.jsx("h2",{children:(r==null?void 0:r.name)??"…"}),(r==null?void 0:r.definition)&&e.jsx("p",{className:"sme-muted",children:r.definition}),e.jsx("input",{className:"sme-search sa-filter",type:"search",placeholder:"Filter both lists…",value:N,onChange:d=>b(d.target.value)})]}),j.length>0&&t&&e.jsxs("div",{className:"sa-nudge",children:[j.length," object",j.length>1?"s are":" is"," homed in this area but not included.",e.jsxs("button",{className:"sme-link",onClick:()=>z(j,!0),children:["Add ",j.length>1?"them all":"it"]})]}),e.jsxs("div",{className:"sa-panes",children:[e.jsx(Q,{title:`Available (${F.length})`,rows:F,picked:f,onToggle:d=>W(f,d,h),homedHere:(r==null?void 0:r.homed_here)??[]}),e.jsxs("div",{className:"sa-controls",children:[e.jsx("button",{className:"sme-secondary",disabled:!t||!f.size,onClick:()=>z([...f].filter(d=>!u.has(d)),!0),title:"Add the selected objects",children:"→"}),e.jsx("button",{className:"sme-secondary",disabled:!t||!F.length,onClick:()=>z(F.map(d=>d.id),!0),title:"Add everything matching the filter",children:"»"}),e.jsx("button",{className:"sme-secondary",disabled:!t||!f.size,onClick:()=>z([...f].filter(d=>u.has(d)),!1),title:"Remove the selected objects",children:"←"}),e.jsx("button",{className:"sme-secondary",disabled:!t||!P.length,onClick:()=>z(P.map(d=>d.id),!1),title:"Remove everything matching the filter",children:"«"})]}),e.jsx(Q,{title:`Included (${P.length})`,rows:P,picked:f,onToggle:d=>W(f,d,h),homedHere:(r==null?void 0:r.homed_here)??[]})]}),e.jsxs("div",{className:"sa-expand",children:[e.jsx("h3",{children:"Add related objects"}),e.jsxs("div",{className:"sa-expand-row",children:[e.jsxs("select",{value:g,onChange:d=>_(d.target.value),children:[e.jsx("option",{value:"ancestors",children:"ancestors — the parents its keys point at"}),e.jsx("option",{value:"descendants",children:"descendants — what points at it"}),e.jsx("option",{value:"both",children:"both"})]}),e.jsxs("label",{children:["levels",e.jsx("input",{type:"number",min:1,max:10,value:C,onChange:d=>T(Math.max(1,Math.min(10,Number(d.target.value))))})]}),e.jsx("button",{className:"sme-secondary",onClick:U,disabled:!t,children:"Preview"})]}),R&&e.jsx("p",{className:"sme-error-line",children:R}),S&&e.jsxs("div",{className:"sa-hops",children:[S.length===0&&e.jsx("p",{className:"sme-muted",children:"nothing new to add"}),S.map(d=>e.jsxs("label",{className:"sa-hop",children:[e.jsx("input",{type:"checkbox",checked:A.has(d.id),onChange:()=>W(A,d.id,k)}),e.jsx("span",{className:"sa-hop-name",children:d.name}),e.jsxs("span",{className:"sa-hop-why",children:["via ",d.via_name," · ",d.direction.replace(/s$/,""),", level ",d.level]})]},d.id)),S.length>0&&e.jsxs("div",{className:"sa-hop-actions",children:[e.jsx("button",{className:"sme-secondary",onClick:()=>y(null),children:"Cancel"}),e.jsxs("button",{className:"sme-primary",onClick:K,disabled:!A.size,children:["Add ",A.size," object",A.size===1?"":"s"]})]})]})]})]})]})}function Q({title:s,rows:t,picked:a,onToggle:l,homedHere:i}){const x=n.useMemo(()=>new Set(i),[i]);return e.jsxs("div",{className:"sa-pane",children:[e.jsx("h3",{children:s}),e.jsxs("ul",{children:[t.map(c=>e.jsxs("li",{className:"sa-row"+(a.has(c.id)?" picked":""),onClick:()=>l(c.id),children:[e.jsx("input",{type:"checkbox",readOnly:!0,checked:a.has(c.id)}),e.jsx("span",{className:"sa-row-name",children:c.name}),x.has(c.id)?e.jsx("span",{className:"sme-badge",children:"home"}):e.jsx("span",{className:"sme-badge borrowed",children:"borrowed"})]},c.id)),!t.length&&e.jsx("li",{className:"sme-empty",children:"nothing here"})]})]})}function es({objects:s,onCreate:t,onClose:a}){const[l,i]=n.useState(""),[x,c]=n.useState(""),[r]=n.useState(s),[m,N]=n.useState([]),[b,f]=n.useState(new Set),[h,g]=n.useState(""),[_,C]=n.useState("ancestors"),[T,S]=n.useState(1),[y,A]=n.useState(null),[k,R]=n.useState(new Set),[o,E]=n.useState(""),[$,D]=n.useState(null);n.useEffect(()=>{xe().then(j=>{j.subject_areas[0]&&E(j.subject_areas[0].id)}).catch(()=>{})},[]);const B=n.useMemo(()=>new Set(m),[m]),u=h.trim().toLowerCase(),P=j=>!u||j.name.toLowerCase().includes(u),F=n.useMemo(()=>m.map(j=>r.find(d=>d.id===j)).filter(j=>!!j&&P(j)),[m,r,u]),I=n.useMemo(()=>r.filter(j=>!B.has(j.id)&&P(j)),[r,B,u]),z=(j,d)=>{N(w=>d?[...w,...j.filter(M=>!w.includes(M))]:w.filter(M=>!j.includes(M))),f(new Set)},W=(j,d,w)=>{const M=new Set(j);M.has(d)?M.delete(d):M.add(d),w(M)},U=()=>{const j=b.size?[...b]:m;if(!j.length){D("pick an object to expand from first");return}o&&(D(null),be(o,{seeds:j,direction:_,levels:T}).then(d=>{const w=d.hops.filter(M=>!B.has(M.id));A(w),R(new Set(w.map(M=>M.id)))}).catch(d=>D(String(d))))},K=()=>{l.trim()&&t(l.trim(),x.trim(),m)};return e.jsx("div",{className:"sme-modal-backdrop",onClick:a,children:e.jsxs("div",{className:"sme-modal sa-modal",onClick:j=>j.stopPropagation(),children:[e.jsx("h2",{children:"New subject area"}),e.jsx("p",{className:"sme-hint",children:"A subject area is a filtered view of the model. Name it and choose what it contains — both land as one proposal."}),e.jsxs("div",{className:"sme-field-row",children:[e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Name"}),e.jsx("input",{autoFocus:!0,value:l,placeholder:"e.g. UseCase-ApraStressTesting",onChange:j=>i(j.target.value),onKeyDown:j=>j.key==="Enter"&&m.length===0&&K()})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Definition (optional)"}),e.jsx("input",{value:x,placeholder:"what this view is for",onChange:j=>c(j.target.value)})]})]}),e.jsx("input",{className:"sme-search sa-filter",type:"search",placeholder:"Filter both lists…",value:h,onChange:j=>g(j.target.value)}),e.jsxs("div",{className:"sa-panes",children:[e.jsx(Q,{title:`Available (${I.length})`,rows:I,picked:b,onToggle:j=>W(b,j,f),homedHere:[]}),e.jsxs("div",{className:"sa-controls",children:[e.jsx("button",{className:"sme-secondary",disabled:!b.size,onClick:()=>z([...b].filter(j=>!B.has(j)),!0),title:"Add the selected objects",children:"→"}),e.jsx("button",{className:"sme-secondary",disabled:!I.length,onClick:()=>z(I.map(j=>j.id),!0),title:"Add everything matching the filter",children:"»"}),e.jsx("button",{className:"sme-secondary",disabled:!b.size,onClick:()=>z([...b].filter(j=>B.has(j)),!1),title:"Remove the selected objects",children:"←"}),e.jsx("button",{className:"sme-secondary",disabled:!F.length,onClick:()=>z(F.map(j=>j.id),!1),title:"Remove everything matching the filter",children:"«"})]}),e.jsx(Q,{title:`Included (${F.length})`,rows:F,picked:b,onToggle:j=>W(b,j,f),homedHere:[]})]}),e.jsxs("div",{className:"sa-expand",children:[e.jsx("h3",{children:"Add related objects"}),e.jsxs("div",{className:"sa-expand-row",children:[e.jsxs("select",{value:_,onChange:j=>C(j.target.value),children:[e.jsx("option",{value:"ancestors",children:"ancestors — the parents its keys point at"}),e.jsx("option",{value:"descendants",children:"descendants — what points at it"}),e.jsx("option",{value:"both",children:"both"})]}),e.jsxs("label",{children:["levels",e.jsx("input",{type:"number",min:1,max:10,value:T,onChange:j=>S(Math.max(1,Math.min(10,Number(j.target.value))))})]}),e.jsx("button",{className:"sme-secondary",onClick:U,disabled:!o,title:o?"Preview related objects to add":"Available once the model has at least one saved subject area to walk the graph from",children:"Preview"})]}),y&&e.jsxs("div",{className:"sa-hops",children:[y.length===0&&e.jsx("p",{className:"sme-muted",children:"nothing new to add"}),y.map(j=>e.jsxs("label",{className:"sa-hop",children:[e.jsx("input",{type:"checkbox",checked:k.has(j.id),onChange:()=>W(k,j.id,R)}),e.jsx("span",{className:"sa-hop-name",children:j.name}),e.jsxs("span",{className:"sa-hop-why",children:["via ",j.via_name," · ",j.direction.replace(/s$/,""),", level ",j.level]})]},j.id)),y.length>0&&e.jsxs("div",{className:"sa-hop-actions",children:[e.jsx("button",{className:"sme-secondary",onClick:()=>A(null),children:"Cancel"}),e.jsxs("button",{className:"sme-primary",disabled:!k.size,onClick:()=>{z([...k],!0),A(null)},children:["Add ",k.size]})]})]})]}),$&&e.jsx("p",{className:"sme-error-line",children:$}),e.jsxs("div",{className:"sme-modal-foot",children:[e.jsx("button",{className:"sme-secondary",onClick:a,children:"Cancel"}),e.jsxs("button",{className:"sme-primary",disabled:!l.trim(),onClick:K,children:["Create",m.length>0&&` with ${m.length} object${m.length===1?"":"s"}`]})]})]})})}const ss=new Set(["set_definition","set_stewardship","set_subject_area","set_pattern","rename_entity","add_attribute","update_attribute","delete_attribute","set_alignment","clear_alignment","set_term_map","clear_term_map","update_synonyms","set_object_definition","set_subject_area_members","create_subject_area","update_subject_area","create_entity","delete_entity","create_relationship","rename_relationship","update_relationship","delete_relationship"]);function ns({doc:s,subjectArea:t,onSubjectArea:a,exec:l,canEdit:i,direct:x=!1,onSelectEntity:c,onImported:r,query:m="",busy:N}){var R;const[b,f]=n.useState(null),[h,g]=n.useState(null),[_,C]=n.useState(!1),[T,S]=n.useState(null),y=n.useRef(null),A=n.useMemo(()=>({canEdit:i,mode:x?"direct":"staged",allow:x?void 0:ss,canArbitrate:x,canCommit:x}),[i,x]),k=n.useCallback(o=>{f(o),o&&(c==null||c(o))},[c]);return e.jsxs("div",{className:"sme-model-view",children:[e.jsxs("nav",{className:"sme-nav",children:[e.jsxs("div",{className:"sme-nav-head",children:[e.jsx("span",{children:"Subject areas"}),i&&e.jsx("button",{className:"sme-nav-add",title:"New subject area",onClick:()=>C(!0),children:"+"})]}),e.jsxs("button",{className:"sme-sa"+(t===""?" active":""),onClick:()=>a(""),children:["Whole model",e.jsx("span",{className:"sme-sa-count",children:((R=s.counts)==null?void 0:R.entities_total)??s.entities.length})]}),s.subject_areas.map(o=>e.jsxs("button",{className:"sme-sa"+(t===o.id?" active":""),onClick:()=>a(o.id),title:o.definition??void 0,children:[o.name,e.jsx("span",{className:"sme-sa-count",children:o.member_count??0})]},o.id))]}),_&&e.jsx(es,{objects:s.entities.flatMap(o=>o.conceptual?[{id:o.conceptual.id,kind:"conceptual_entity",name:o.conceptual.name,definition:o.conceptual.definition??null,synonyms:[],subject_area:o.conceptual.subject_area??null,stewardship:null,ontology:null,where_used:[]}]:[]),onClose:()=>C(!1),onCreate:(o,E,$)=>{const D=ge();l("create_subject_area",{name:o,...E?{definition:E}:{},id:D}),$.length&&l("set_subject_area_members",{id:D,members:$}),C(!1),a(D)}}),e.jsxs("div",{className:"erd-wrap",children:[e.jsxs("div",{className:"erd-bar",children:[e.jsxs("span",{className:"erd-count",children:[s.entities.length," entities · ",s.relationships.length," relationships",s.scope&&" · scoped"]}),i&&e.jsx("button",{className:"erd-action",onClick:()=>{var o;return(o=y.current)==null?void 0:o.newEntity()},title:"Add an entity (n)",children:"+ Entity"}),e.jsx("span",{className:"erd-hint",children:"drag between entities to relate them"}),e.jsxs("span",{className:"erd-group",children:[e.jsxs("button",{className:"erd-action ghost",onClick:()=>{var o;return(o=y.current)==null?void 0:o.relayout()},title:"Auto-arrange the diagram",children:[e.jsx("span",{className:"erd-ico","aria-hidden":"true",children:"⟲"}),"Re-layout"]}),e.jsxs("button",{className:"erd-action ghost",onClick:()=>{var o;return(o=y.current)==null?void 0:o.fitView()},title:"Fit the whole model in view",children:[e.jsx("span",{className:"erd-ico","aria-hidden":"true",children:"⛶"}),"Fit"]})]}),e.jsx("span",{className:"erd-sep","aria-hidden":"true"}),e.jsxs("span",{className:"erd-group",children:[e.jsx("button",{className:"erd-action ghost"+(h==="ontology"?" on":""),onClick:()=>g(o=>o==="ontology"?null:"ontology"),children:"Ontology"}),e.jsx("button",{className:"erd-action ghost"+(h==="layers"?" on":""),onClick:()=>g(o=>o==="layers"?null:"layers"),children:"Layers"})]}),e.jsx("span",{className:"erd-sep","aria-hidden":"true"}),e.jsx(Re,{exec:l,canEdit:i,onImported:r}),N&&e.jsx("span",{className:"erd-busy",children:"updating…"}),!i&&e.jsx("span",{className:"sme-chip",children:"read-only"}),T&&e.jsx("span",{className:"erd-error",children:T})]}),e.jsxs("div",{className:"erd-canvas canvas-wrap",children:[e.jsx(Me,{children:e.jsx(De,{doc:s,caps:A,exec:l,selectedId:b,onSelect:k,query:m,onError:S,handleRef:y})}),h&&e.jsxs("aside",{className:"side-panel",children:[e.jsxs("div",{className:"detail-header",children:[e.jsx("h2",{children:h==="ontology"?"Ontology":"Ontology layers"}),e.jsx("button",{className:"icon-btn",onClick:()=>g(null),children:"✕"})]}),h==="ontology"?e.jsx(Pe,{}):e.jsx(Ie,{refreshKey:0,onFocusEntity:o=>{var E;g(null),(E=y.current)==null||E.focusEntity(o)}})]})]})]})]})}function as({user:s,onView:t}){const[a,l]=n.useState(null),[i,x]=n.useState(null);if(n.useEffect(()=>{Oe(s).then(l).catch(r=>x(String(r)))},[s]),i)return e.jsx("p",{className:"sme-splash error",children:i});if(!a)return e.jsx("div",{className:"sme-splash",children:"◮ looking for your proposals…"});if(!a.git)return e.jsx("p",{className:"sme-placeholder",children:"This model isn't in a git repository."});const c=a.proposals.filter(r=>!r.merged).length;return e.jsxs("div",{className:"pr-wrap",children:[e.jsxs("div",{className:"pr-head",children:[e.jsx("h2",{children:"My proposals"}),e.jsxs("span",{className:"meta",children:[c," open · base: ",a.base]})]}),!a.proposals.length&&e.jsx("p",{className:"sme-placeholder",children:"Nothing proposed yet. Edits you submit for review will appear here."}),a.proposals.map(r=>e.jsx(rs,{p:r,gh:a.gh,onView:()=>t(r.branch)},r.branch)),!a.gh&&a.proposals.length>0&&e.jsxs("div",{className:"pr-degrade",children:["Review status isn't available here (",e.jsx("code",{children:"gh"})," is not installed or not signed in). Branch, contents, merge state and conflicts all still come from git — each card links out to the compare view."]})]})}function ts(s,t){var a,l;return s.merged?{kind:"accepted",text:"✅ Accepted"}:s.pushed?t&&((a=s.pr)==null?void 0:a.state)==="CLOSED"?{kind:"declined",text:"✖ Declined"}:t&&((l=s.pr)==null?void 0:l.reviews)==="CHANGES_REQUESTED"?{kind:"changes",text:"💬 Changes requested"}:{kind:"awaiting",text:"⏳ Awaiting review"}:{kind:"local",text:"📥 Saved locally"}}function rs({p:s,gh:t,onView:a}){const l=ts(s,t);return e.jsxs("div",{className:"pr-card"+(l.kind==="attention"?" attention":""),children:[e.jsxs("div",{className:"pr-top",children:[e.jsx("span",{className:"pr-title",children:s.title}),e.jsx("span",{className:"pr-chip "+l.kind,children:l.text})]}),e.jsxs("div",{className:"pr-branch",children:[s.branch," · ",s.created,s.behind>0&&` · ${s.behind} behind`]}),e.jsxs("div",{className:"pr-foot",children:[e.jsx("span",{className:"pr-link",children:s.merged?e.jsxs(e.Fragment,{children:["merged",t&&s.pr?` · PR #${s.pr.number}`:""]}):s.pushed?t&&s.pr?e.jsxs("a",{href:s.pr.url,target:"_blank",rel:"noreferrer",children:["PR #",s.pr.number]}):s.compare_url?e.jsx("a",{href:s.compare_url,target:"_blank",rel:"noreferrer",children:"open the compare view →"}):"pushed":"not sent yet — no connection to the shared repo"}),e.jsx("span",{className:"pr-btns",children:e.jsx("button",{className:"sme-secondary",onClick:a,children:"View"})})]})]})}function is({changes:s,routeAdvice:t,identity:a,user:l,onUser:i,onDrop:x,onClose:c,onProposed:r}){const m=!!(a&&a.source!=="anonymous"),[N,b]=n.useState(l||(localStorage.getItem("mdl.sme.user")??"")),[f,h]=n.useState(s.length===1?`Update ${s[0].label.toLowerCase()}`:"Glossary updates"),[g,_]=n.useState(""),[C,T]=n.useState(!1),S=ve(C?g:f),[y,A]=n.useState(""),[k,R]=n.useState(!1),[o,E]=n.useState(null),[$,D]=n.useState(null),B=()=>{!m&&!N.trim()||(m||(localStorage.setItem("mdl.sme.user",N.trim()),i==null||i(N.trim())),R(!0),D(null),Te({user:m?"":N.trim(),slug:S,title:f,body:y.trim(),changes:s.map(u=>({op:u.op,payload:u.payload}))}).then(E).catch(u=>D(u instanceof Be?u.message:String(u))).finally(()=>R(!1)))};return o!=null&&o.ok?e.jsx("div",{className:"sme-modal-backdrop",onClick:()=>r(o),children:e.jsxs("div",{className:"sme-modal",onClick:u=>u.stopPropagation(),children:[e.jsx("h2",{children:"✓ Sent for review"}),e.jsx("p",{children:o.message}),o.pr_url&&e.jsx("p",{children:e.jsx("a",{href:o.pr_url,target:"_blank",rel:"noreferrer",children:"View pull request →"})}),o.compare_url&&e.jsx("p",{children:e.jsx("a",{href:o.compare_url,target:"_blank",rel:"noreferrer",children:"Open the pull request →"})}),e.jsxs("p",{className:"sme-muted",children:["Your suggestion is on branch ",e.jsx("code",{children:o.branch}),". A steward will review it."]}),e.jsx("div",{className:"sme-modal-foot",children:e.jsx("button",{className:"sme-primary",onClick:()=>r(o),children:"Done"})})]})}):e.jsx("div",{className:"sme-modal-backdrop",onClick:c,children:e.jsxs("div",{className:"sme-modal",onClick:u=>u.stopPropagation(),children:[e.jsxs("div",{className:"sme-card-head",children:[e.jsx("h2",{children:"Submit for review"}),e.jsx("button",{className:"sme-secondary",onClick:c,children:"✕"})]}),e.jsx("h3",{children:"Your changes"}),e.jsx("ul",{className:"sme-diff",children:s.map((u,P)=>e.jsxs("li",{children:[e.jsxs("div",{className:"sme-diff-head",children:[e.jsx("b",{children:u.label}),e.jsx("button",{className:"sme-link danger",onClick:()=>x(P),children:"discard"})]}),e.jsx("div",{className:"sme-diff-before",children:u.before}),e.jsx("div",{className:"sme-diff-after",children:u.after})]},P))}),m?e.jsxs("p",{className:"sme-signed-in",children:["Signed in as ",e.jsx("strong",{children:a.name}),a.email?` (${a.email})`:""," — this proposal is attributed to you."]}):e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Your name (for attribution)"}),e.jsx("input",{value:N,onChange:u=>b(u.target.value),placeholder:"e.g. a.hough"})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Title"}),e.jsx("input",{value:f,onChange:u=>h(u.target.value)})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Branch name (edit to add a ticket id, e.g. PROJ-123)"}),e.jsx("input",{value:C?g:S,onChange:u=>{T(!0),_(u.target.value)},placeholder:"clarify-counterparty"})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Note for the reviewer (optional)"}),e.jsx("textarea",{rows:2,value:y,onChange:u=>A(u.target.value)})]}),e.jsx(ls,{cl:t,user:m?a.name:N,changeSlug:S}),$&&e.jsx("p",{className:"sme-error-line",children:$}),e.jsxs("div",{className:"sme-modal-foot",children:[e.jsx("button",{className:"sme-secondary",onClick:c,children:"Keep editing"}),e.jsx("button",{className:"sme-primary",disabled:k||!m&&!N.trim()||s.length===0,onClick:B,children:k?"Submitting…":"Submit for review"})]})]})})}function ls({cl:s,user:t,changeSlug:a}){if(!(s!=null&&s.primary))return null;const l=s.reviewers_actual??s.reviewers,i=ve(t||"you"),x=s.routes.length>1;return e.jsxs(e.Fragment,{children:[x&&e.jsxs("div",{className:"sub-advice",children:[e.jsx("span",{className:"i",children:"ⓘ"})," These changes span routes"," ",e.jsx("strong",{children:s.routes.join(" and ")}),". The whole proposal will be reviewed as route ",s.primary," (",s.primary_name,") — the strictest gate wins, so it needs"," ",l.join(", ")," plus ",e.jsx("code",{children:s.gates[s.gates.length-1]}),". Proposing the meaning-only changes separately would get them reviewed faster."]}),e.jsxs("div",{className:"sub-dest",children:[e.jsxs("div",{className:"row",children:[e.jsx("span",{className:"k",children:"Goes to"}),e.jsxs("code",{children:["sme/",i,"/",a||"…"]})]}),e.jsxs("div",{className:"row",children:[e.jsx("span",{className:"k",children:"Reviewers"}),e.jsx("span",{children:l.join(", ")})]})]})]})}function ve(s){return s.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"")||"change"}const cs="modulepreload",os=function(s){return"/"+s},oe={},ds=function(t,a,l){let i=Promise.resolve();if(a&&a.length>0){document.getElementsByTagName("link");const c=document.querySelector("meta[property=csp-nonce]"),r=(c==null?void 0:c.nonce)||(c==null?void 0:c.getAttribute("nonce"));i=Promise.allSettled(a.map(m=>{if(m=os(m),m in oe)return;oe[m]=!0;const N=m.endsWith(".css"),b=N?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${m}"]${b}`))return;const f=document.createElement("link");if(f.rel=N?"stylesheet":cs,N||(f.as="script"),f.crossOrigin="",f.href=m,r&&f.setAttribute("nonce",r),document.head.appendChild(f),N)return new Promise((h,g)=>{f.addEventListener("load",h),f.addEventListener("error",()=>g(new Error(`Unable to preload CSS for ${m}`)))})}))}function x(c){const r=new Event("vite:preloadError",{cancelable:!0});if(r.payload=c,window.dispatchEvent(r),!r.defaultPrevented)throw c}return i.then(c=>{for(const r of c||[])r.status==="rejected"&&x(r.reason);return t().catch(x)})};function hs({diff:s,selectable:t,selected:a,onToggle:l}){var r;const[i,x]=n.useState(((r=s.objects[0])==null?void 0:r.ulid)??null),c=n.useMemo(()=>s.objects.find(m=>m.ulid===i)??s.objects[0]??null,[s,i]);return s.objects.length?e.jsxs("div",{className:"rv-body",children:[e.jsx("div",{className:"rv-list",children:s.objects.map(m=>e.jsx(us,{obj:m,active:m.ulid===((c==null?void 0:c.ulid)??""),checked:(a==null?void 0:a.has(m.ulid))??!0,selectable:t,onSelect:()=>x(m.ulid),onToggle:()=>l==null?void 0:l(m.ulid)},m.ulid))}),e.jsx("div",{className:"rv-detail",children:c&&e.jsx(ps,{obj:c})})]}):e.jsxs("p",{className:"sme-placeholder",children:["No model changes against ",s.base.label,"."]})}function ms(s){return s.renamed?"renamed":s.severity==="breaking"?"breaking":s.change}function us({obj:s,active:t,checked:a,selectable:l,onSelect:i,onToggle:x}){return e.jsxs("div",{className:"rv-obj"+(t?" active":""),onClick:i,children:[e.jsxs("div",{className:"rv-obj-head",children:[l&&e.jsx("input",{type:"checkbox",checked:a,onClick:c=>c.stopPropagation(),onChange:x,"aria-label":`include ${s.name_before??s.name_after}`}),e.jsx("span",{className:"rv-obj-name",children:s.name_before??s.name_after}),e.jsx("span",{className:"rv-obj-kind",children:s.object_kind_label}),e.jsxs("span",{className:"sev "+(s.renamed?"renamed":s.severity),children:[s.severity==="breaking"&&"⚠ ",ms(s)]})]}),e.jsxs("ul",{className:"rv-obj-fields",children:[s.fields.map((c,r)=>e.jsx("li",{children:c.label},c.field+r)),s.children.flatMap(c=>c.fields.map((r,m)=>e.jsxs("li",{children:[r.label,": ",c.name_before??c.name_after]},c.ulid+m)))]})]})}function ps({obj:s}){const t=s.children.flatMap(a=>a.fields.map(l=>({field:l,on:a.name_before??a.name_after??""})));return e.jsxs(e.Fragment,{children:[e.jsx("h2",{children:s.renamed?`${s.name_before} → ${s.name_after}`:s.name_before??s.name_after}),e.jsxs("div",{className:"rv-detail-kind",children:[s.object_kind_label,s.renamed&&" · renamed",s.path&&e.jsxs("span",{className:"rv-src",children:[" · ",s.path]})]}),s.fields.map((a,l)=>e.jsx(de,{f:a},a.field+l)),t.map(({field:a,on:l},i)=>e.jsx(de,{f:a,on:l},"c"+i)),s.severity==="breaking"&&e.jsxs("div",{className:"rv-note",children:[e.jsx("span",{className:"i",children:"ⓘ"}),e.jsxs("span",{children:["This is a structural change (route B). It will need an architect's review, and CI will run ",e.jsx("code",{children:"mdl drift --check"}),"."]})]})]})}function de({f:s,on:t}){var l,i,x;const a=t?`${s.label}: ${t}`:s.label;if((l=s.breaks)!=null&&l.length)return e.jsxs("div",{className:"rv-section",children:[e.jsx("h3",{children:a}),e.jsxs("div",{className:"rv-break",children:[e.jsxs("div",{className:"rv-break-head",children:[e.jsx("span",{children:"⚠"}),e.jsxs("span",{children:[t??String(s.before??"")," removed"]}),s.detail&&e.jsx("span",{className:"rv-break-meta",children:s.detail})]}),e.jsxs("div",{className:"rv-break-body",children:["This is realised by ",s.breaks.length," dbt model",s.breaks.length>1?"s":"",". Removing it will break ",s.breaks.length>1?"them":"it","."]}),e.jsx("ul",{className:"rv-usage",children:s.breaks.map(c=>e.jsxs("li",{children:[e.jsxs("span",{className:"m",children:["→ ",c.name]}),e.jsxs("span",{className:"p",children:[c.target," · ",c.materialization]})]},c.name))})]})]});if(s.kind==="definition_changed"){const c=String(s.before??""),r=String(s.after??"");return e.jsxs("div",{className:"rv-section",children:[e.jsx("h3",{children:a}),e.jsxs("div",{className:"rv-ba",children:[e.jsxs("div",{className:"rv-ba-col",children:[e.jsx("div",{className:"rv-ba-label",children:"before"}),e.jsx("div",{className:"rv-ba-text",children:me(c,r,"del")})]}),e.jsxs("div",{className:"rv-ba-col",children:[e.jsx("div",{className:"rv-ba-label",children:"after"}),e.jsx("div",{className:"rv-ba-text",children:me(r,c,"ins")})]})]})]})}if(Array.isArray(s.before)||Array.isArray(s.after)){const c=new Set(((i=s.before)==null?void 0:i.map(String))??[]),r=new Set(((x=s.after)==null?void 0:x.map(String))??[]),m=[...r].filter(b=>!c.has(b)),N=[...c].filter(b=>!r.has(b));return e.jsxs("div",{className:"rv-section",children:[e.jsx("h3",{children:a}),e.jsxs("div",{className:"rv-chips",children:[m.map(b=>e.jsxs("span",{className:"rv-chip-add",children:["+ ",b]},"a"+b)),N.map(b=>e.jsxs("span",{className:"rv-chip-del",children:["− ",b]},"r"+b))]})]})}return e.jsxs("div",{className:"rv-section",children:[e.jsx("h3",{children:a}),e.jsxs("div",{className:"rv-scalar",children:[e.jsx("span",{className:"from",children:he(s.before)}),e.jsx("span",{className:"arrow",children:"→"}),e.jsx("span",{className:"to",children:he(s.after)})]})]})}function he(s){return s==null||s===""?"—":typeof s=="boolean"?s?"yes":"no":typeof s=="object"?JSON.stringify(s):String(s)}function me(s,t,a){const l=s.split(/(\s+)/),i=t.split(/(\s+)/),x=h=>h.toLowerCase().replace(/[.,;:]/g,""),c=l.length,r=i.length,m=Array.from({length:c+1},()=>new Array(r+1).fill(0));for(let h=c-1;h>=0;h--)for(let g=r-1;g>=0;g--)m[h][g]=x(l[h])===x(i[g])?m[h+1][g+1]+1:Math.max(m[h+1][g],m[h][g+1]);const N=new Set;let b=0,f=0;for(;b=m[b][f+1]?b++:f++;return l.map((h,g)=>!h.trim()||N.has(g)?e.jsx("span",{children:h},g):a==="del"?e.jsx("del",{children:h},g):e.jsx("ins",{children:h},g))}const js=n.lazy(()=>ds(()=>import("./ModelDiagram-CEOzUQbJ.js"),__vite__mapDeps([0,1,2,3])).then(s=>({default:s.ModelDiagram})));function xs({stagedDiff:s,stagedRoute:t,user:a,onBack:l,onSubmit:i,excluded:x,onToggleObject:c,selectable:r}){const[m,N]=n.useState(null),[b,f]=n.useState(null),[h,g]=n.useState(null),[_,C]=n.useState(null),[T,S]=n.useState(null),[y,A]=n.useState("changes"),k=n.useCallback(()=>{je(a).then(o=>{C(o);const E=o.base_branch??"main";s?N(s):Le("HEAD").then($=>$.ok?N($):S($.error??"could not read the model")).catch($=>S(String($))),t?f(t):Fe("HEAD").then(f).catch(()=>{}),ze(E).then(g).catch(()=>{})}).catch(o=>S(String(o)))},[a,s,t]);if(n.useEffect(k,[k]),T)return e.jsxs("div",{className:"rv-shell",children:[e.jsx(ue,{onBack:l,diff:null}),e.jsx("p",{className:"sme-splash error",children:e.jsx("pre",{children:T})})]});if(!m)return e.jsx("div",{className:"sme-splash",children:"◮ reading your changes…"});const R=new Set(m.objects.map(o=>o.ulid).filter(o=>!x.has(o)));return e.jsxs("div",{className:"rv-shell",children:[e.jsx(ue,{onBack:l,diff:m,tab:y,onTab:A}),y==="changes"?e.jsx(hs,{diff:m,selectable:r,selected:R,onToggle:c}):e.jsx(n.Suspense,{fallback:e.jsx("div",{className:"sme-splash",children:"◮ drawing the model…"}),children:e.jsx(js,{severityByUlid:bs(m)})}),e.jsxs("div",{className:"rv-foot",children:[e.jsx(gs,{cl:b}),e.jsx(fs,{conf:h,ctx:_}),e.jsxs("div",{className:"rv-actions",children:[e.jsx("button",{className:"sme-secondary",onClick:l,children:"Keep editing"}),e.jsx("button",{className:"sme-primary",disabled:!m.objects.length||(_?!_.can_propose:!1),title:_&&!_.can_propose?"the working tree has uncommitted changes":"",onClick:()=>i(b),children:"Submit for review →"})]})]})]})}function bs(s){const t=new Map;for(const a of s.objects)t.set(a.ulid,a.severity);return t}function ue({onBack:s,diff:t,tab:a,onTab:l}){const i=t==null?void 0:t.counts;return e.jsxs("div",{className:"rv-head",children:[e.jsxs("div",{children:[e.jsx("button",{className:"rv-back",onClick:s,children:"‹ Back to terms"}),e.jsx("h1",{className:"rv-title",children:"Review your changes"}),i&&e.jsxs("div",{className:"rv-sub",children:[i.objects," object",i.objects===1?"":"s"," ·"," ",(i.breaking??0)+(i.additive??0)+(i.cosmetic??0)," changes",(t==null?void 0:t.has_breaking)&&e.jsxs("span",{className:"warn",children:[" · ⚠ ",i.breaking," breaking"]})," · comparing to ",e.jsx("code",{children:t==null?void 0:t.base.label})]})]}),l&&e.jsxs("div",{className:"rv-tabs",children:[e.jsxs("button",{className:"rv-tab"+(a==="changes"?" active":""),onClick:()=>l("changes"),children:["Changes ",(t==null?void 0:t.objects.length)??0]}),e.jsx("button",{className:"rv-tab"+(a==="diagram"?" active":""),onClick:()=>l("diagram"),children:"Diagram"})]})]})}function gs({cl:s}){if(!(s!=null&&s.primary))return null;const t=s.reviewers_actual??s.reviewers;return e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"rv-route",children:[e.jsxs("span",{className:"rv-route-badge",children:["Route ",s.primary," · ",s.primary_name]}),e.jsx("span",{className:"k",children:"Reviewers"}),e.jsx("span",{children:t.join(", ")}),s.reviewers_actual&&e.jsx("span",{className:"rv-src",children:"from .github/CODEOWNERS"})]}),e.jsxs("div",{className:"rv-gates",children:["CI gates"," ",s.gates.map(a=>e.jsx("code",{children:a},a))]})]})}function fs({conf:s,ctx:t}){return s!=null&&s.ok?s.clean?e.jsxs("div",{className:"rv-conflict ok",children:[e.jsx("span",{children:"✓"}),e.jsxs("span",{children:["Merges cleanly onto ",s.base]}),s.behind>0&&e.jsxs("span",{className:"detail",children:["· ",s.base," is ",s.behind," commit",s.behind===1?"":"s"," ahead"]}),(t==null?void 0:t.dirty)&&e.jsx("span",{className:"detail",children:"· uncommitted changes in the working tree"})]}):e.jsxs("div",{className:"rv-conflict bad",children:[e.jsx("span",{children:"⚠"}),e.jsxs("span",{children:[s.base," has changed and ",s.files.length," file",s.files.length===1?"":"s"," now conflict"]}),e.jsx("span",{className:"detail",children:s.files.map(a=>a.path).join(", ")})]}):null}function vs({term:s,canEdit:t,pendingCount:a,onEdit:l}){var i,x;return e.jsxs("div",{className:"sme-card",children:[e.jsxs("div",{className:"sme-card-head",children:[e.jsxs("div",{children:[e.jsx("h1",{children:s.name}),((i=s.subject_area)==null?void 0:i.name)&&e.jsx("span",{className:"sme-chip",children:s.subject_area.name}),s.kind==="term"&&e.jsx("span",{className:"sme-chip",children:"glossary term"})]}),t&&e.jsx("button",{className:"sme-primary",onClick:l,children:a>0?"Continue editing":"Suggest an edit"})]}),e.jsx("p",{className:"sme-definition",children:s.definition??e.jsx("em",{children:"No definition yet."})}),s.synonyms.length>0&&e.jsxs("section",{children:[e.jsx("h3",{children:"Also called"}),e.jsx("div",{children:s.synonyms.map(c=>e.jsx("span",{className:"sme-chip",children:c},c))})]}),s.stewardship&&(s.stewardship.owner||s.stewardship.steward)&&e.jsxs("section",{children:[e.jsx("h3",{children:"Who owns this"}),s.stewardship.owner&&e.jsxs("div",{className:"sme-kv",children:[e.jsx("span",{className:"k",children:"owner"}),e.jsx("span",{children:s.stewardship.owner})]}),s.stewardship.steward&&e.jsxs("div",{className:"sme-kv",children:[e.jsx("span",{className:"k",children:"steward"}),e.jsx("span",{children:s.stewardship.steward})]})]}),((x=s.ontology)==null?void 0:x.aligns_to)&&e.jsxs("section",{children:[e.jsx("h3",{children:"Standard it maps to"}),e.jsxs("div",{className:"sme-kv",children:[e.jsx("span",{className:"k",children:"aligned to"}),e.jsx("code",{children:s.ontology.aligns_to}),s.ontology.status==="proposed"&&e.jsx("span",{className:"sme-badge proposed",children:"awaiting architect"})]})]}),e.jsxs("section",{children:[e.jsx("h3",{children:"Where it's used"}),s.where_used.length===0?e.jsx("p",{className:"sme-muted",children:s.kind==="term"?"A glossary term — not yet realised by a data model.":"Not yet realised by any data model."}):e.jsx("ul",{className:"sme-usage",children:s.where_used.map(c=>e.jsxs("li",{children:[e.jsx("span",{className:"sme-usage-model",children:c.logical_entity}),c.unmanaged&&e.jsx("span",{className:"sme-badge",children:"engineer-owned"}),c.physical.length>0&&e.jsx("span",{className:"sme-usage-phys",children:c.physical.map(r=>r.name).join(", ")})]},c.logical_id))})]})]})}function Ns({term:s,pending:t,onStage:a,onDone:l,catalogOwned:i=[],catalog:x=null}){var E,$,D,B;const c=u=>i.includes(u),r=u=>t.find(P=>P.label===u),m=(u,P)=>{const F=r(u);return F?F.after:P},[N,b]=n.useState(m("Definition",s.definition??"")),[f,h]=n.useState(s.synonyms),[g,_]=n.useState(""),[C,T]=n.useState(((E=s.stewardship)==null?void 0:E.owner)??""),[S,y]=n.useState((($=s.stewardship)==null?void 0:$.steward)??""),A=()=>{N.trim()!==(s.definition??"").trim()&&a({op:"set_definition",payload:{id:s.id,definition:N.trim()},label:"Definition",before:s.definition??"(none)",after:N.trim()||"(none)"})},k=(u,P)=>{var F,I;a({op:"set_stewardship",payload:{id:s.id,owner:u||null,steward:P||null},label:"Stewardship",before:`owner: ${((F=s.stewardship)==null?void 0:F.owner)??"—"} · steward: ${((I=s.stewardship)==null?void 0:I.steward)??"—"}`,after:`owner: ${u||"—"} · steward: ${P||"—"}`})},R=u=>{h(u),a({op:"update_synonyms",payload:{id:s.id,synonyms:u},label:"Also called",before:s.synonyms.join(", ")||"(none)",after:u.join(", ")||"(none)"})},o=s.kind==="term";return e.jsxs("div",{className:"sme-editor",children:[e.jsxs("div",{className:"sme-card-head",children:[e.jsxs("h1",{children:["Suggest an edit — ",s.name]}),e.jsx("button",{className:"sme-secondary",onClick:l,children:"Back"})]}),e.jsxs("p",{className:"sme-hint",children:["You can refine what this term ",e.jsx("em",{children:"means"}),". Structure (keys, relationships, how it's built) stays with the data team."]}),(c("definition")||c("synonyms")||c("stewardship"))&&x&&e.jsxs("div",{className:"sme-catalog-banner",children:["Definitions, synonyms, and ownership are mastered in ",e.jsx("b",{children:x.name}),". Edit them there — this glossary mirrors them read-only.",x.url&&e.jsxs(e.Fragment,{children:[" ",e.jsxs("a",{href:x.url,target:"_blank",rel:"noreferrer",children:["Open in ",x.name," →"]})]}),e.jsx("div",{className:"sme-catalog-note",children:"You can still propose a standard mapping below."})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Definition"}),c("definition")?e.jsx("p",{className:"sme-readonly-value",children:s.definition??"—"}):e.jsx("textarea",{rows:4,value:N,onChange:u=>b(u.target.value),onBlur:A,placeholder:"A plain-language business definition…"})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Also called (synonyms)"}),c("synonyms")?e.jsx("div",{className:"sme-syn-row",children:s.synonyms.length?s.synonyms.map(u=>e.jsx("span",{className:"sme-chip",children:u},u)):e.jsx("span",{className:"sme-muted",children:"none"})}):e.jsxs("div",{className:"sme-syn-row",children:[f.map(u=>e.jsxs("span",{className:"sme-chip removable",onClick:()=>R(f.filter(P=>P!==u)),children:[u," ✕"]},u)),e.jsx("input",{value:g,placeholder:"add + Enter",onChange:u=>_(u.target.value),onKeyDown:u=>{u.key==="Enter"&&g.trim()&&(R([...f,g.trim()]),_(""))}})]})]}),!o&&(c("stewardship")?e.jsxs("div",{className:"sme-field-row",children:[e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Owner"}),e.jsx("p",{className:"sme-readonly-value",children:((D=s.stewardship)==null?void 0:D.owner)??"—"})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Steward"}),e.jsx("p",{className:"sme-readonly-value",children:((B=s.stewardship)==null?void 0:B.steward)??"—"})]})]}):e.jsxs("div",{className:"sme-field-row",children:[e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Owner"}),e.jsx("input",{value:C,onChange:u=>T(u.target.value),onBlur:()=>k(C,S)})]}),e.jsxs("label",{className:"sme-field",children:[e.jsx("span",{children:"Steward"}),e.jsx("input",{value:S,onChange:u=>y(u.target.value),onBlur:()=>k(C,S)})]})]})),e.jsx(ys,{term:s,onStage:a}),e.jsx("p",{className:"sme-note",children:t.length>0?`${t.length} change(s) staged — use “Submit for review” at the top when you're done.`:"Changes you make are collected until you submit them for review."})]})}const ws=["skos:exactMatch","skos:closeMatch","skos:broadMatch","skos:narrowMatch"];function ys({term:s,onStage:t}){var f;const[a,l]=n.useState(!1),[i,x]=n.useState(s.name),[c,r]=n.useState([]),[m,N]=n.useState("skos:closeMatch"),b=h=>{x(h),qe(h).then(g=>r(g.results)).catch(()=>r([]))};return a?e.jsxs("div",{className:"sme-field sme-align",children:[e.jsx("span",{children:"Propose a standard mapping (a suggestion; the architect confirms)"}),e.jsx("input",{value:i,onChange:h=>b(h.target.value),placeholder:"Search standards…",autoFocus:!0}),e.jsx("div",{className:"sme-align-results",children:c.map(h=>e.jsxs("button",{className:"sme-align-card",onClick:()=>{var g;t({op:"set_alignment",payload:{id:s.id,aligns_to:h.prefixed,alignment:m,status:"proposed"},label:"Standard mapping",before:((g=s.ontology)==null?void 0:g.aligns_to)??"(none)",after:`${h.prefixed} (${m}, proposed)`}),l(!1)},children:[e.jsx("strong",{children:h.label})," ",e.jsx("code",{children:h.prefixed}),h.definition&&e.jsx("span",{className:"sme-align-def",children:h.definition})]},h.iri))}),e.jsx("select",{value:m,onChange:h=>N(h.target.value),children:ws.map(h=>e.jsx("option",{children:h},h))})]}):e.jsxs("div",{className:"sme-field",children:[e.jsx("span",{children:"Standard mapping"}),e.jsxs("div",{children:[(f=s.ontology)!=null&&f.aligns_to?e.jsx("code",{className:"sme-current-align",children:s.ontology.aligns_to}):e.jsx("span",{className:"sme-muted",children:"not mapped to a standard"}),e.jsx("button",{className:"sme-link",onClick:()=>l(!0),children:"Propose a mapping…"})]})]})}function _s(){const[s,t]=n.useState(null),[a,l]=n.useState(!0),[i,x]=n.useState(null),[c,r]=n.useState(""),[m,N]=n.useState(null),[b,f]=n.useState(""),[h,g]=n.useState(""),[_,C]=n.useState(null),[T,S]=n.useState(!1),[y,A]=n.useState([]),[k,R]=n.useState(!1),[o,E]=n.useState(window.location.hash==="#proposals"?"proposals":"browse"),[$,D]=n.useState(new Set),[B,u]=n.useState(()=>localStorage.getItem("mdl.sme.user")??""),[P,F]=n.useState(null),[I,z]=n.useState(null),[W,U]=n.useState(!1),[K,j]=n.useState(null),d=n.useCallback((p,v)=>{var ae;const O=J=>{var te;const V=I==null?void 0:I.entities.find(re=>{var ie;return re.id===J||((ie=re.conceptual)==null?void 0:ie.id)===J});return((te=V==null?void 0:V.conceptual)==null?void 0:te.name)??(V==null?void 0:V.name)??"object"},L=O(v.id??v.entity_id);switch(p){case"create_entity":return{label:`New entity: ${v.name}`,before:"—",after:String(v.name??"")};case"create_relationship":return{label:`New relationship: ${O(v.from_entity)} → ${O(v.to_entity)}`,before:"—",after:String(v.cardinality??"many_to_one")};case"delete_entity":return{label:`Deleted ${L}`,before:L,after:"—"};case"delete_relationship":return{label:"Relationship removed",before:"",after:"—"};case"update_relationship":return{label:"Relationship changed",before:"",after:[v.cardinality,v.optionality,v.identifying?"identifying":""].filter(Boolean).join(", ")};case"set_definition":return{label:`Definition: ${L}`,before:"",after:String(v.definition??"")};case"add_attribute":return{label:`Attribute added to ${L}`,before:"—",after:String(v.name??"")};case"delete_attribute":return{label:`Attribute removed from ${L}`,before:"",after:"—"};case"update_attribute":return{label:`Attribute changed on ${L}`,before:"",after:JSON.stringify(v)};case"rename_entity":return{label:`Renamed ${L}`,before:L,after:String(v.name??"")};case"set_subject_area":return{label:`Subject area: ${L}`,before:"",after:String(v.subject_area??"none")};case"set_stewardship":return{label:`Stewardship: ${L}`,before:"",after:String(v.steward??"")};case"set_subject_area_members":{const J=v.__label,V=((ae=v.members)==null?void 0:ae.length)??0;return{label:J??"Subject area members",before:"",after:`${V} object${V===1?"":"s"}`}}case"set_alignment":return{label:`Alignment: ${L}`,before:"",after:String(v.aligns_to??"")};default:return{label:`${p.replace(/_/g," ")}: ${L}`,before:"",after:""}}},[I]),w=Je({subjectArea:b,describe:d}),M=n.useCallback(async(p,v)=>{const O=(I==null?void 0:I.fingerprint)??"",L=await We(p,v,O);return await le(b||void 0).then(z),L},[I,b]),q=n.useMemo(()=>{const p={};for(const v of w.pending)v.op==="set_subject_area_members"&&(p[v.payload.id]=v.payload.members??[]);return p},[w.pending]),G=n.useMemo(()=>[...y,...w.pending],[y,w.pending]),[Ne,X]=n.useState(null),[we,Z]=n.useState(null);n.useEffect(()=>{if(!G.length){X(null),Z(null);return}const p=new AbortController,v=G.map(O=>({op:O.op,payload:O.payload}));return pe(v,b||void 0,p.signal).then(O=>X(O.diff??null)).catch(O=>{O.name!=="AbortError"&&X(null)}),Ge(v).then(Z).catch(()=>Z(null)),()=>p.abort()},[G,b]);const ye=n.useMemo(()=>Qe(G),[G]).size{E(p),window.location.hash=p==="browse"?"":`#${p}`},[]);n.useEffect(()=>{const p=()=>{const v=window.location.hash;E(v==="#proposals"?"proposals":v==="#review"?"review":v==="#model"?"model":v==="#areas"?"areas":"browse")};return p(),window.addEventListener("hashchange",p),()=>window.removeEventListener("hashchange",p)},[]);const ee=n.useCallback(()=>{He({subject_area:b,q:h}).then(t).catch(p=>N(String(p)))},[b,h]);n.useEffect(ee,[ee]),n.useEffect(()=>{le(b||void 0).then(p=>{l(p.read_only),r(p.project.name),z(p),U(!!p.direct),F(p.identity??null),p.identity&&p.identity.source!=="anonymous"&&p.identity.name&&u(p.identity.name)}).catch(()=>{}),Ve().then(x).catch(()=>{})},[b]);const se=n.useMemo(()=>(s==null?void 0:s.terms.find(p=>p.id===_))??null,[s,_]),_e=n.useCallback(p=>{const v=p.key??fe(p.op,p.payload);A(O=>[...O.filter(L=>L.key!==v),{...p,key:v}])},[]),Se=p=>A(v=>v.filter((O,L)=>L!==p)),ke=()=>{A([]),R(!1),S(!1),ee()};if(m)return e.jsxs("div",{className:"sme-splash error",children:[e.jsx("h1",{children:"◮ Glossary"}),e.jsx("pre",{children:m}),e.jsxs("p",{children:["Is ",e.jsx("code",{children:"mdl glossary"})," pointed at a model?"]})]});if(!s)return e.jsx("div",{className:"sme-splash",children:"◮ loading glossary…"});const Y=!a,ne=(i==null?void 0:i.catalog_owned_fields)??[],Ce=ne.length>0;return e.jsxs("div",{className:"sme",children:[e.jsxs("header",{className:"sme-top",children:[e.jsxs("div",{className:"sme-brand",children:[e.jsx("span",{className:"sme-logo",children:"◮"}),e.jsx("span",{children:"Glossary"}),e.jsx("span",{className:"sme-project",children:c}),a&&e.jsx("span",{className:"sme-chip",children:"read-only"}),Ce&&e.jsxs("span",{className:"sme-chip catalog",title:`Definitions are mastered in ${i==null?void 0:i.catalog_name}`,children:["mirrors ",i==null?void 0:i.catalog_name]})]}),e.jsx("input",{className:"sme-search",type:"search",placeholder:"Search terms & definitions…",value:h,onChange:p=>g(p.target.value)}),e.jsxs("nav",{className:"sme-tabs",children:[e.jsx("button",{className:"sme-tab"+(o==="browse"?" active":""),onClick:()=>H("browse"),children:"Terms"}),e.jsx("button",{className:"sme-tab"+(o==="model"?" active":""),onClick:()=>H("model"),children:"Model"}),e.jsx("button",{className:"sme-tab"+(o==="areas"?" active":""),onClick:()=>H("areas"),children:"Subject areas"}),e.jsx("button",{className:"sme-tab"+(o==="proposals"?" active":""),onClick:()=>H("proposals"),children:"My proposals"})]}),G.length>0&&e.jsxs("button",{className:"sme-tray",onClick:()=>H("review"),children:[G.length," change",G.length>1?"s":""," · Review →"]})]}),o==="browse"&&e.jsx(Xe,{user:B}),o==="review"?e.jsx(xs,{stagedDiff:Ne,stagedRoute:we,user:B,selectable:ye,excluded:$,onToggleObject:p=>D(v=>{const O=new Set(v);return O.has(p)?O.delete(p):O.add(p),O}),onBack:()=>H("browse"),onSubmit:p=>{j(p),R(!0)}}):o==="model"?w.previewDoc??I?e.jsx(ns,{doc:w.previewDoc??I,subjectArea:b,onSubjectArea:f,exec:W?M:w.exec,canEdit:Y,direct:W,onImported:()=>H("review"),query:h,busy:w.busy}):e.jsx("div",{className:"sme-splash",children:"◮ loading the model…"}):o==="areas"?e.jsx(Ze,{exec:w.exec,canEdit:Y,stagedMembers:q}):o==="proposals"?e.jsx(as,{user:B,onView:()=>H("review")}):e.jsxs("div",{className:"sme-body",children:[e.jsxs("nav",{className:"sme-nav",children:[e.jsx("button",{className:"sme-sa"+(b===""?" active":""),onClick:()=>f(""),children:"All subject areas"}),s.subject_areas.map(p=>e.jsx("button",{className:"sme-sa"+(b===p.id?" active":""),onClick:()=>f(p.id),title:p.definition??void 0,children:p.name},p.id))]}),e.jsxs("ul",{className:"sme-list",children:[s.terms.map(p=>{var v;return e.jsxs("li",{className:"sme-list-item"+(p.id===_?" active":""),onClick:()=>{C(p.id),S(!1)},children:[e.jsx("span",{className:"sme-term-name",children:p.name}),p.kind==="term"&&e.jsx("span",{className:"sme-kind",children:"term"}),((v=p.ontology)==null?void 0:v.status)==="proposed"&&e.jsx("span",{className:"sme-badge proposed",title:"alignment awaiting architect",children:"proposed"}),e.jsx("span",{className:"sme-term-def",children:p.definition??"no definition yet"})]},p.id)}),s.terms.length===0&&e.jsx("li",{className:"sme-empty",children:"no terms match"})]}),e.jsx("section",{className:"sme-detail",children:se?T&&Y?e.jsx(Ns,{term:se,pending:y,onStage:_e,onDone:()=>S(!1),catalogOwned:ne,catalog:i?{name:i.catalog_name,url:i.catalog_url}:null}):e.jsx(vs,{term:se,canEdit:Y,pendingCount:y.length,onEdit:()=>S(!0)}):e.jsx("div",{className:"sme-placeholder",children:"Select a term to see its definition."})})]}),k&&e.jsx(is,{changes:G,routeAdvice:K,identity:P,user:B,onUser:p=>{u(p),localStorage.setItem("mdl.sme.user",p)},onDrop:Se,onClose:()=>R(!1),onProposed:()=>{ke(),w.clear(),D(new Set),H("proposals")}})]})}Ae.createRoot(document.getElementById("root")).render(e.jsx(Ee.StrictMode,{children:e.jsx(_s,{})})); diff --git a/packages/server/src/mdl_server/static/assets/styles-DijBTcQg.js b/packages/server/src/mdl_server/static/assets/styles-B2ouEC7_.js similarity index 99% rename from packages/server/src/mdl_server/static/assets/styles-DijBTcQg.js rename to packages/server/src/mdl_server/static/assets/styles-B2ouEC7_.js index 5a944a2..3361918 100644 --- a/packages/server/src/mdl_server/static/assets/styles-DijBTcQg.js +++ b/packages/server/src/mdl_server/static/assets/styles-B2ouEC7_.js @@ -20,4 +20,4 @@ var qa=Object.defineProperty;var Xa=(e,t,n)=>t in e?qa(e,t,{enumerable:!0,config `))).catch(()=>o(""))},[]);return w.useEffect(d,[d,t]),r!=null&&r.git?r.clean?h.jsx("div",{className:"panel-body ok",children:"✓ working tree clean"}):h.jsxs("div",{className:"panel-body",children:[h.jsx("ul",{className:"dirty-list",children:r.dirty.map(m=>h.jsxs("li",{children:[h.jsx("code",{className:"dirty-state",children:m.state})," ",m.path]},m.path))}),h.jsx("pre",{className:"diff-view",children:i.split(` `).map((m,f)=>h.jsx("span",{className:m.startsWith("+")&&!m.startsWith("+++")?"diff-add":m.startsWith("-")&&!m.startsWith("---")?"diff-del":m.startsWith("@@")?"diff-hunk":"",children:m+` -`},f))}),!e&&h.jsxs(h.Fragment,{children:[h.jsx("input",{className:"edit-input",placeholder:"commit message…",value:a,onChange:m=>l(m.target.value)}),h.jsxs("div",{className:"modal-footer",children:[h.jsx("button",{className:"mini-btn danger",disabled:c,onClick:()=>{confirm("Discard ALL uncommitted model changes?")&&(u(!0),wm().then(()=>{u(!1),d(),n()}))},children:"Discard all"}),h.jsx("button",{className:"primary-btn",disabled:c||!a.trim(),onClick:()=>{u(!0),xm(a).then(()=>{u(!1),l(""),d()})},children:"Commit"})]})]})]}):h.jsx("div",{className:"panel-body",children:"model directory is not in a git repo"})}function lx({readOnly:e}){const[t,n]=w.useState([]),r=w.useCallback(()=>{bm().then(i=>n(i.decisions)).catch(()=>n([]))},[]);if(w.useEffect(r,[r]),t.length===0)return h.jsx("div",{className:"panel-body",children:"no recorded decisions (run `mdl reverse` to propose)"});const s={accepted:"✓",rejected:"✗",proposed:"?"};return h.jsx("div",{className:"panel-body",children:t.map(i=>h.jsxs("div",{className:"decision "+i.verdict,children:[h.jsxs("div",{className:"decision-head",children:[h.jsx("span",{className:"decision-mark",children:s[i.verdict]}),h.jsx("span",{className:"chip",children:i.confidence}),h.jsx("span",{className:"chip",children:i.signal})]}),h.jsx("p",{className:"decision-subject",children:i.subject}),!e&&i.verdict==="proposed"&&h.jsxs("div",{className:"decision-actions",children:[h.jsx("button",{className:"mini-btn",onClick:()=>Os(i.signal_key,"accepted").then(r),children:"Accept"}),h.jsx("button",{className:"mini-btn danger",onClick:()=>Os(i.signal_key,"rejected").then(r),children:"Reject"})]})]},i.signal_key))})}function Rx({exec:e,canEdit:t,onImported:n,submitLabel:r="Import & review",buttonClass:s="erd-action ghost",applyBatch:i,openImport:o=!1}){const[a,l]=w.useState(o&&t?"import":null),[c,u]=w.useState([]),[d,m]=w.useState("postgres"),f=w.useRef(null);w.useEffect(()=>{vm().then(g=>u(g.formats)).catch(()=>u([]))},[]),w.useEffect(()=>{if(!a)return;const g=v=>{f.current&&!f.current.contains(v.target)&&l(null)};return document.addEventListener("mousedown",g),()=>document.removeEventListener("mousedown",g)},[a]);const y=c.find(g=>g.id==="sql");return h.jsxs("div",{className:"ie-wrap",ref:f,children:[h.jsxs("button",{className:s+(a==="export"?" on":""),onClick:()=>l(a==="export"?null:"export"),children:[h.jsx("span",{className:"erd-ico","aria-hidden":"true",children:"↧"}),"Export ",h.jsx("span",{className:"erd-caret",children:"▾"})]}),t&&h.jsxs("button",{className:s+(a==="import"?" on":""),onClick:()=>l(a==="import"?null:"import"),children:[h.jsx("span",{className:"erd-ico","aria-hidden":"true",children:"↥"}),"Import ",h.jsx("span",{className:"erd-caret",children:"▾"})]}),a==="export"&&h.jsxs("div",{className:"ie-menu",role:"menu",children:[(y==null?void 0:y.dialects)&&h.jsxs("label",{className:"ie-dialect",children:[h.jsx("span",{children:"SQL dialect"}),h.jsx("select",{value:d,onChange:g=>m(g.target.value),children:y.dialects.map(g=>h.jsx("option",{value:g,children:g},g))})]}),c.map(g=>h.jsx("a",{className:"ie-item",href:_m(g.id,g.id==="sql"?d:void 0),download:!0,onClick:()=>l(null),children:g.label},g.id))]}),a==="import"&&h.jsx(cx,{exec:e,submitLabel:r,buttonClass:s,applyBatch:i,onClose:()=>l(null),onImported:g=>{l(null),n==null||n(g)}})]})}const ai=[{id:"sql",label:"SQL DDL",hint:"CREATE TABLE … PRIMARY KEY / FOREIGN KEY"},{id:"mermaid",label:"Mermaid erDiagram",hint:"structural: entities, attributes, relationships"},{id:"json-schema",label:"JSON Schema",hint:"each object definition becomes an entity"}];function cx({exec:e,submitLabel:t,buttonClass:n,applyBatch:r,onClose:s,onImported:i}){var b;const[o,a]=w.useState("sql"),[l,c]=w.useState("postgres"),[u,d]=w.useState(""),[m,f]=w.useState(!1),[y,g]=w.useState(null),[v,x]=w.useState([]),_=k=>{k&&k.text().then(d).catch(()=>g("could not read that file"))},$=async()=>{f(!0),g(null),x([]);try{const k=await Em(o,u,o==="sql"?l:void 0);if(!k.changes.length){g(k.warnings[0]??"nothing to import from that document"),f(!1);return}if(r)await r(k.changes);else for(const C of k.changes)await e(C.op,C.payload);x(k.warnings),i(k.tables)}catch(k){g(k instanceof Error?k.message:String(k))}finally{f(!1)}};return h.jsxs("div",{className:"ie-menu ie-import",role:"dialog",children:[h.jsx("div",{className:"ie-tabs",children:ai.map(k=>h.jsx("button",{className:"ie-tab"+(o===k.id?" active":""),onClick:()=>a(k.id),children:k.label},k.id))}),h.jsx("p",{className:"ie-hint",children:(b=ai.find(k=>k.id===o))==null?void 0:b.hint}),o==="sql"&&h.jsxs("label",{className:"ie-dialect",children:[h.jsx("span",{children:"dialect"}),h.jsx("select",{value:l,onChange:k=>c(k.target.value),children:["postgres","snowflake","mysql","duckdb"].map(k=>h.jsx("option",{children:k},k))})]}),h.jsx("textarea",{className:"ie-textarea",placeholder:`Paste ${o} here, or choose a file…`,value:u,onChange:k=>d(k.target.value),rows:8}),h.jsx("input",{type:"file",className:"ie-file",accept:".sql,.mmd,.md,.json,.txt",onChange:k=>{var C;return _(((C=k.target.files)==null?void 0:C[0])??null)}}),o==="mermaid"&&h.jsx("p",{className:"ie-warn",children:"Mermaid is a diagram format: types and keys are approximate and relationships are inferred from the lines."}),v.map((k,C)=>h.jsx("p",{className:"ie-warn",children:k},C)),y&&h.jsx("p",{className:"ie-error",children:y}),h.jsxs("div",{className:"ie-foot",children:[h.jsx("button",{className:n,onClick:s,children:"Cancel"}),h.jsx("button",{className:n,disabled:m||!u.trim(),onClick:$,children:m?"Importing…":t})]})]})}export{Po as A,dm as B,im as C,km as E,Rx as I,ox as L,Ax as M,sx as O,ii as P,Mo as R,Ix as S,hx as a,Ex as b,vx as c,Sx as d,kx as e,fx as f,gm as g,jx as h,_x as i,gx as j,yx as k,xx as l,bx as m,wx as n,zo as o,Nx as p,mx as q,px as r,pm as s,ri as t,Io as u,ex as v,Qh as w}; +`},f))}),!e&&h.jsxs(h.Fragment,{children:[h.jsx("input",{className:"edit-input",placeholder:"commit message…",value:a,onChange:m=>l(m.target.value)}),h.jsxs("div",{className:"modal-footer",children:[h.jsx("button",{className:"mini-btn danger",disabled:c,onClick:()=>{confirm("Discard ALL uncommitted model changes?")&&(u(!0),wm().then(()=>{u(!1),d(),n()}))},children:"Discard all"}),h.jsx("button",{className:"primary-btn",disabled:c||!a.trim(),onClick:()=>{u(!0),xm(a).then(()=>{u(!1),l(""),d()})},children:"Commit"})]})]})]}):h.jsx("div",{className:"panel-body",children:"model directory is not in a git repo"})}function lx({readOnly:e}){const[t,n]=w.useState([]),r=w.useCallback(()=>{bm().then(i=>n(i.decisions)).catch(()=>n([]))},[]);if(w.useEffect(r,[r]),t.length===0)return h.jsx("div",{className:"panel-body",children:"no recorded decisions (run `mdl reverse` to propose)"});const s={accepted:"✓",rejected:"✗",proposed:"?"};return h.jsx("div",{className:"panel-body",children:t.map(i=>h.jsxs("div",{className:"decision "+i.verdict,children:[h.jsxs("div",{className:"decision-head",children:[h.jsx("span",{className:"decision-mark",children:s[i.verdict]}),h.jsx("span",{className:"chip",children:i.confidence}),h.jsx("span",{className:"chip",children:i.signal})]}),h.jsx("p",{className:"decision-subject",children:i.subject}),!e&&i.verdict==="proposed"&&h.jsxs("div",{className:"decision-actions",children:[h.jsx("button",{className:"mini-btn",onClick:()=>Os(i.signal_key,"accepted").then(r),children:"Accept"}),h.jsx("button",{className:"mini-btn danger",onClick:()=>Os(i.signal_key,"rejected").then(r),children:"Reject"})]})]},i.signal_key))})}function Rx({exec:e,canEdit:t,onImported:n,submitLabel:r="Import & review",buttonClass:s="erd-action ghost",applyBatch:i,openImport:o=!1}){const[a,l]=w.useState(o&&t?"import":null),[c,u]=w.useState([]),[d,m]=w.useState("postgres"),f=w.useRef(null);w.useEffect(()=>{vm().then(g=>u(g.formats)).catch(()=>u([]))},[]),w.useEffect(()=>{if(!a)return;const g=v=>{f.current&&!f.current.contains(v.target)&&l(null)};return document.addEventListener("mousedown",g),()=>document.removeEventListener("mousedown",g)},[a]);const y=c.find(g=>g.id==="sql");return h.jsxs("div",{className:"ie-wrap",ref:f,children:[h.jsxs("button",{className:s+(a==="export"?" on":""),onClick:()=>l(a==="export"?null:"export"),children:[h.jsx("span",{className:"erd-ico","aria-hidden":"true",children:"↧"}),"Export ",h.jsx("span",{className:"erd-caret",children:"▾"})]}),t&&h.jsxs("button",{className:s+(a==="import"?" on":""),onClick:()=>l(a==="import"?null:"import"),children:[h.jsx("span",{className:"erd-ico","aria-hidden":"true",children:"↥"}),"Import ",h.jsx("span",{className:"erd-caret",children:"▾"})]}),a==="export"&&h.jsxs("div",{className:"ie-menu",role:"menu",children:[(y==null?void 0:y.dialects)&&h.jsxs("label",{className:"ie-dialect",children:[h.jsx("span",{children:"SQL dialect"}),h.jsx("select",{value:d,onChange:g=>m(g.target.value),children:y.dialects.map(g=>h.jsx("option",{value:g,children:g},g))})]}),c.map(g=>h.jsx("a",{className:"ie-item",href:_m(g.id,g.id==="sql"?d:void 0),download:!0,onClick:()=>l(null),children:g.label},g.id))]}),a==="import"&&h.jsx(cx,{exec:e,submitLabel:r,buttonClass:s,applyBatch:i,onClose:()=>l(null),onImported:g=>{l(null),n==null||n(g)}})]})}const ai=[{id:"sql",label:"SQL DDL",hint:"CREATE TABLE … PRIMARY KEY / FOREIGN KEY"},{id:"mermaid",label:"Mermaid erDiagram",hint:"structural: entities, attributes, relationships"},{id:"json-schema",label:"JSON Schema",hint:"each object definition becomes an entity"},{id:"erwin",label:"erwin XML",hint:"entities, keys, relationships, subject areas, subtypes"}];function cx({exec:e,submitLabel:t,buttonClass:n,applyBatch:r,onClose:s,onImported:i}){var b;const[o,a]=w.useState("sql"),[l,c]=w.useState("postgres"),[u,d]=w.useState(""),[m,f]=w.useState(!1),[y,g]=w.useState(null),[v,x]=w.useState([]),_=k=>{k&&k.text().then(d).catch(()=>g("could not read that file"))},$=async()=>{f(!0),g(null),x([]);try{const k=await Em(o,u,o==="sql"?l:void 0);if(!k.changes.length){g(k.warnings[0]??"nothing to import from that document"),f(!1);return}if(r)await r(k.changes);else for(const C of k.changes)await e(C.op,C.payload);x(k.warnings),i(k.tables)}catch(k){g(k instanceof Error?k.message:String(k))}finally{f(!1)}};return h.jsxs("div",{className:"ie-menu ie-import",role:"dialog",children:[h.jsx("div",{className:"ie-tabs",children:ai.map(k=>h.jsx("button",{className:"ie-tab"+(o===k.id?" active":""),onClick:()=>a(k.id),children:k.label},k.id))}),h.jsx("p",{className:"ie-hint",children:(b=ai.find(k=>k.id===o))==null?void 0:b.hint}),o==="sql"&&h.jsxs("label",{className:"ie-dialect",children:[h.jsx("span",{children:"dialect"}),h.jsx("select",{value:l,onChange:k=>c(k.target.value),children:["postgres","snowflake","mysql","duckdb"].map(k=>h.jsx("option",{children:k},k))})]}),h.jsx("textarea",{className:"ie-textarea",placeholder:`Paste ${o} here, or choose a file…`,value:u,onChange:k=>d(k.target.value),rows:8}),h.jsx("input",{type:"file",className:"ie-file",accept:".sql,.mmd,.md,.json,.xml,.txt",onChange:k=>{var C;return _(((C=k.target.files)==null?void 0:C[0])??null)}}),o==="mermaid"&&h.jsx("p",{className:"ie-warn",children:"Mermaid is a diagram format: types and keys are approximate and relationships are inferred from the lines."}),v.map((k,C)=>h.jsx("p",{className:"ie-warn",children:k},C)),y&&h.jsx("p",{className:"ie-error",children:y}),h.jsxs("div",{className:"ie-foot",children:[h.jsx("button",{className:n,onClick:s,children:"Cancel"}),h.jsx("button",{className:n,disabled:m||!u.trim(),onClick:$,children:m?"Importing…":t})]})]})}export{Po as A,dm as B,im as C,km as E,Rx as I,ox as L,Ax as M,sx as O,ii as P,Mo as R,Ix as S,hx as a,Ex as b,vx as c,Sx as d,kx as e,fx as f,gm as g,jx as h,_x as i,gx as j,yx as k,xx as l,bx as m,wx as n,zo as o,Nx as p,mx as q,px as r,pm as s,ri as t,Io as u,ex as v,Qh as w}; diff --git a/packages/server/src/mdl_server/static/index.html b/packages/server/src/mdl_server/static/index.html index f52cf64..2e71c18 100644 --- a/packages/server/src/mdl_server/static/index.html +++ b/packages/server/src/mdl_server/static/index.html @@ -4,9 +4,9 @@ Modelith - + - + diff --git a/packages/server/src/mdl_server/static/sme.html b/packages/server/src/mdl_server/static/sme.html index 37b4fd2..5db0cf7 100644 --- a/packages/server/src/mdl_server/static/sme.html +++ b/packages/server/src/mdl_server/static/sme.html @@ -4,9 +4,9 @@ Modelith Glossary - + - + diff --git a/packages/server/tests/test_import_erwin_api.py b/packages/server/tests/test_import_erwin_api.py new file mode 100644 index 0000000..d5f554b --- /dev/null +++ b/packages/server/tests/test_import_erwin_api.py @@ -0,0 +1,33 @@ +"""/api/import dispatches the erwin format to the rich reader + model_to_commands.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient +from mdl_server.app import create_app + + +def _erwin_xml() -> str: + fixture = Path(__file__).resolve().parents[2] / "reverse" / "tests" / "test_erwin.py" + return fixture.read_text().split('_ERWIN = """')[1].split('"""')[0] + + +def test_api_import_erwin_returns_changes(tmp_path): + (tmp_path / "mdl-project.yaml").write_text("name: t\ndbt_target: duckdb\n") + client = TestClient(create_app(tmp_path)) + r = client.post("/api/import", json={"format": "erwin", "content": _erwin_xml()}) + assert r.status_code == 200, r.text + data = r.json() + assert data["ok"] is True + assert data["tables"] == 3 # 3 entities + ops = {c["op"] for c in data["changes"]} + assert "create_entity" in ops and "create_relationship" in ops and "create_key_group" in ops + assert any("view" in w.lower() for w in data["warnings"]) # a View was skipped + + +def test_api_import_empty_content_422(tmp_path): + (tmp_path / "mdl-project.yaml").write_text("name: t\ndbt_target: duckdb\n") + client = TestClient(create_app(tmp_path)) + r = client.post("/api/import", json={"format": "erwin", "content": ""}) + assert r.status_code == 422 From a427c2dd98d38e2f687a53f65cf912d71cd05012 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 26 Sep 2026 18:54:09 +1000 Subject: [PATCH 6/9] =?UTF-8?q?vscode:=20Import=20Erwin=20XML=20=E2=80=94?= =?UTF-8?q?=20palette,=20Reverse=20Review=20button,=20and=20.xml=20right-c?= =?UTF-8?q?lick?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three entry points for the erwin importer, no dedicated panel frame (import is a one-shot action, not monitored state — a button-only frame would be the blank-void anti-pattern): - `modelith.importErwin` command -> Cmd+Shift+P "Modelith: Import Erwin XML" (file picker, .xml filter). Resolves a target dir via the reverse no-clobber flow (update in place vs new folder), runs `mdl import erwin -o ` under progress (5-min timeout for 10MB files), then opens the canvas on the result and refreshes the panels. Surfaces the reader's notes. - Reverse Review title-bar button (beside Reverse / Reverse Live / Compare) — erwin import is a "bring an external model in" action, its natural home. - Explorer right-click on any .xml -> "Import Erwin XML", passing the clicked URI so it skips the picker. All 6 regression gates pass (42 commands registered); typecheck clean. --- vscode/package.json | 19 +++++++++++++-- vscode/src/extension.ts | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/vscode/package.json b/vscode/package.json index 8da88a8..6aa9086 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -285,6 +285,11 @@ "command": "modelith.import", "title": "Modelith: Import to Model" }, + { + "command": "modelith.importErwin", + "title": "Modelith: Import Erwin XML", + "icon": "$(cloud-download)" + }, { "command": "modelith.stopServer", "title": "Modelith: Stop Canvas Server" @@ -464,6 +469,11 @@ "when": "explorerResourceIsFolder || resourceFilename == dbt_project.yml || resourceFilename == profiles.yml || resourceExtname == .sql", "group": "modelith@0" }, + { + "command": "modelith.importErwin", + "when": "resourceExtname == .xml", + "group": "modelith@1" + }, { "command": "modelith.liftModel", "when": "resourceExtname == .sql", @@ -534,15 +544,20 @@ "group": "navigation@2" }, { - "command": "modelith.compareReversed", + "command": "modelith.importErwin", "when": "view == modelithReverse", "group": "navigation@3" }, { - "command": "modelith.reverseRefresh", + "command": "modelith.compareReversed", "when": "view == modelithReverse", "group": "navigation@4" }, + { + "command": "modelith.reverseRefresh", + "when": "view == modelithReverse", + "group": "navigation@5" + }, { "command": "modelith.docsGenerate", "when": "view == modelithDocs", diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index 43023f1..c7e2141 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -619,6 +619,59 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }), ); + // Import an erwin XML export into a fresh Modelith model. An erwin export is a whole + // model (like a reverse), so this WRITES a model dir via the CLI (10MB-safe, server- + // free) and opens it — not a canvas paste. Reachable from the palette, the Reverse + // Review title bar, and an .xml right-click (which passes the clicked URI). + cmd("modelith.importErwin", (arg: unknown) => + withModelDir(async (dir) => { + let fileUri = arg instanceof vscode.Uri ? arg : undefined; + if (!fileUri) { + const picked = await vscode.window.showOpenDialog({ + title: "Import erwin XML", + canSelectMany: false, + filters: { "erwin XML": ["xml"] }, + openLabel: "Import", + }); + if (!picked || picked.length === 0) return; + fileUri = picked[0]; + } + const file = fileUri.fsPath; + // Default the target at the workspace's model dir; the picker confirms/redirects + // (reusing the reverse no-clobber "update in place vs new folder" flow). + const res = await resolveReverseTarget(await bestDefaultTarget(dir), undefined); + if (!res) return; + const bin = await findMdl(dir); + const args = ["import", "erwin", file, "-o", res.target]; + if (res.force) args.push("--force"); + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: "Modelith: importing erwin XML…" }, + async () => { + const r = await runMdl(bin, args, dir, out, 300000); + out.appendLine(r.stdout + r.stderr); + if (r.code !== 0) { + out.show(true); + void vscode.window.showErrorMessage("Modelith: erwin import failed — see output."); + return; + } + // the CLI diverts on an existing model; read the actual written dir from the output + const diverted = /reversing into (.+?) instead/.exec(r.stdout); + const writtenRel = diverted ? diverted[1].trim() : res.target; + const writtenDir = path.isAbsolute(writtenRel) + ? writtenRel + : path.join(dir, writtenRel); + const notes = [...r.stdout.matchAll(/note: (.+)/g)].map((m) => m[1]); + await Promise.all([reverse.refresh(writtenDir), statusModel.refresh(writtenDir)]); + await canvas.open(writtenDir); + const summary = /imported (\d+) entities/.exec(r.stdout)?.[0] ?? "erwin model imported"; + void vscode.window.showInformationMessage( + `Modelith: ${summary}.` + (notes.length ? ` ${notes.length} note(s) — see output.` : ""), + ); + }, + ); + }), + ); + cmd("modelith.stopServer", () => canvas.stop()); // Palette-discoverable one-click CLI install (also offered automatically when a From 3e71d52e3a46d49c3a7d1fb4c973f0822b2c31d1 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 26 Sep 2026 18:57:03 +1000 Subject: [PATCH 7/9] Version bump (CLI 0.6.6, extension 0.3.13) + README erwin import section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents `mdl import erwin` (streaming, rich mapping — keys, relationships + verb phrases, subtype categories, subject areas, domains, physical names; graceful skip of views/layout/transforms) and the three VS Code entry points. --- README.md | 22 ++++++++++++++++++++-- pyproject.toml | 2 +- uv.lock | 2 +- vscode/package.json | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0396439..8a392b5 100644 --- a/README.md +++ b/README.md @@ -449,8 +449,8 @@ and a conformance kit that validates a bespoke mapping in CI. ### Import and export, from the Model tab The Model tab's toolbar has **Export** and **Import** menus. Export downloads the model -in any interchange format; import parses SQL DDL, a Mermaid diagram, or a JSON Schema and -stages it as a proposal you review before it lands. +in any interchange format; import parses SQL DDL, a Mermaid diagram, a JSON Schema, or an +**erwin XML export** and stages it as a proposal you review before it lands. ![The Export menu open in the Model-tab toolbar: a SQL dialect selector and download items for SQL DDL, Mermaid erDiagram, DBML, CSV, an ODCS data contract, and Neo4j Cypher](docs/assets/export-menu.png) @@ -459,6 +459,24 @@ stages it as a proposal you review before it lands. An imported model is visible on the canvas immediately as a staged preview — nothing touches disk or git until you submit the proposal. +### Import from erwin + +Nobody adopts a modeling tool greenfield, so `mdl import erwin` reads a real erwin Data +Modeler XML export and turns it into a committable Modelith model — entities, primary and +foreign keys, relationships (with their verb phrases), subtype/supertype categories, +subject areas, and domains, keeping the physical names alongside the logical ones: + +```bash +mdl import erwin your-model.xml -o model # writes a fresh model +mdl import erwin your-model.xml -o model --apply # or merge into an existing one +``` + +It parses the export by streaming, so 7-10MB files import without a memory spike, and it +degrades gracefully: anything Modelith doesn't model (views, ER-diagram layout, physical +transforms) is skipped with a note rather than a failure. In VS Code it's on the palette +(**Modelith: Import Erwin XML**), a button in the Reverse Review panel, and a right-click +on any `.xml` file. + ## Reverse engineering a real warehouse `mdl reverse` lifts a warehouse into a logical model from any of three sources — a diff --git a/pyproject.toml b/pyproject.toml index aeb6c25..0635ee5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ # project, so the install name is `modelith-dbt` (install: uv tool install # modelith-dbt). The command stays `mdl`; the import modules stay mdl_*. name = "modelith-dbt" -version = "0.6.5" +version = "0.6.6" description = "Ontology-anchored, git-native data modeling tool for dbt-core teams" readme = "README.md" requires-python = ">=3.11" diff --git a/uv.lock b/uv.lock index 901c9b7..61e513b 100644 --- a/uv.lock +++ b/uv.lock @@ -939,7 +939,7 @@ requires-dist = [ [[package]] name = "modelith-dbt" -version = "0.6.5" +version = "0.6.6" source = { editable = "." } dependencies = [ { name = "dbt-artifacts-parser" }, diff --git a/vscode/package.json b/vscode/package.json index 6aa9086..ce358fb 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -2,7 +2,7 @@ "name": "modelith-vscode", "displayName": "Modelith", "description": "Ontology-anchored, git-native data modeling for dbt — validate, generate, drift, and the Modelith canvas inside VS Code.", - "version": "0.3.12", + "version": "0.3.13", "publisher": "BosonResearch", "license": "Apache-2.0", "icon": "icon.png", From d6cf8487045d05c3d51b4610f981bca10889acf1 Mon Sep 17 00:00:00 2001 From: Debasish Bose Date: Sat, 26 Sep 2026 19:07:17 +1000 Subject: [PATCH 8/9] canvas: surface erwin verb phrases + physical names in the Inspector and on edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The importer populated verb_phrase/inverse_verb_phrase and physical_name, but they were invisible in the UI — imported data the user couldn't see or edit. Now fully surfaced: - Projection emits verb_phrase/inverse_verb_phrase per relationship and physical_name per entity and attribute. - Inspector: a "physical" name field under the entity title (editable), a physical column name beside an attribute when it differs, and the relationship rows show the two verb phrases ("is party to" / "is with") inline and editable. - ER edge label shows the relationship's verb phrase when selected (the erwin reading " "), falling back to the name. - Editing rides the existing command engine: update_relationship gains verb_phrase/ inverse_verb_phrase, update_attribute + rename_entity gain physical_name (empty clears). Verified in the browser against an erwin-imported model: the Inspector shows PHYSICAL COUNTERPARTY and the "is party to / is with" verb phrases; edits persist. 4 tests (projection emits, commands edit all four fields). Canvas rebuilt; extension 0.3.14. --- canvas/src/Inspector.tsx | 57 +++++++++++++++-- canvas/src/RelationshipEdge.tsx | 3 +- canvas/src/styles.css | 13 +++- canvas/src/types.ts | 4 ++ packages/core/src/mdl_core/commands.py | 31 ++++++++-- packages/server/src/mdl_server/projection.py | 5 ++ ...m-CEOzUQbJ.js => ModelDiagram-DfdI4kly.js} | 2 +- .../{main-DS5zhMab.js => main-CZFuiW_s.js} | 2 +- .../{sme-WKO1CSM1.js => sme-Ji3JtE2B.js} | 4 +- .../static/assets/styles-B2ouEC7_.js | 23 ------- ...tyles-CJ3Gp-TF.css => styles-BJW39REh.css} | 2 +- .../static/assets/styles-CIm9mWtD.js | 23 +++++++ .../server/src/mdl_server/static/index.html | 6 +- .../server/src/mdl_server/static/sme.html | 6 +- .../server/tests/test_erwin_fields_surface.py | 62 +++++++++++++++++++ vscode/package.json | 2 +- 16 files changed, 198 insertions(+), 47 deletions(-) rename packages/server/src/mdl_server/static/assets/{ModelDiagram-CEOzUQbJ.js => ModelDiagram-DfdI4kly.js} (97%) rename packages/server/src/mdl_server/static/assets/{main-DS5zhMab.js => main-CZFuiW_s.js} (99%) rename packages/server/src/mdl_server/static/assets/{sme-WKO1CSM1.js => sme-Ji3JtE2B.js} (99%) delete mode 100644 packages/server/src/mdl_server/static/assets/styles-B2ouEC7_.js rename packages/server/src/mdl_server/static/assets/{styles-CJ3Gp-TF.css => styles-BJW39REh.css} (73%) create mode 100644 packages/server/src/mdl_server/static/assets/styles-CIm9mWtD.js create mode 100644 packages/server/tests/test_erwin_fields_surface.py diff --git a/canvas/src/Inspector.tsx b/canvas/src/Inspector.tsx index 9c2d600..35757d9 100644 --- a/canvas/src/Inspector.tsx +++ b/canvas/src/Inspector.tsx @@ -56,6 +56,18 @@ export function Inspector({ readOnly={readOnly} onCommit={(v) => exec("rename_entity", { id: entity.id, name: v })} /> + {(entity.physical_name || !readOnly) && ( +
+ physical + exec("rename_entity", { id: entity.id, physical_name: v })} + /> +
+ )}
@@ -260,10 +272,33 @@ export function Inspector({ const other = r.from.entity === entity.id ? r.to.entity : r.from.entity; return (
- +
+ + {(r.verb_phrase || r.inverse_verb_phrase || !readOnly) && ( +
+ exec("update_relationship", { id: r.id, verb_phrase: v })} + /> + / + + exec("update_relationship", { id: r.id, inverse_verb_phrase: v }) + } + /> +
+ )} +
{!readOnly && (