@peter.prib/renderengine 0.2.11 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +22 -6
  2. package/REFERENCE.md +117 -2
  3. package/dist/renderengine-workspace.umd.js +2973 -1203
  4. package/dist/renderengine.umd.js +1178 -158
  5. package/package.json +1 -1
  6. package/src/charting/ModelCheckDialog.js +10 -3
  7. package/src/charting/ModelComparisonDialog.js +10 -3
  8. package/src/charting/ModelFileFormat.js +4 -1
  9. package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
  10. package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
  11. package/src/charting/arrangement/layouts.js +9 -4
  12. package/src/charting/datamodel/DataModel.js +18 -71
  13. package/src/charting/datamodel/DataModelCompared.js +1 -31
  14. package/src/charting/diagramModel/ListCompared.js +35 -0
  15. package/src/charting/model/Benchmark.js +59 -0
  16. package/src/charting/model/EditableModel.js +104 -0
  17. package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
  18. package/src/charting/requirements/RequirementsChecker.js +76 -0
  19. package/src/charting/requirements/RequirementsCompared.js +33 -0
  20. package/src/charting/requirements/RequirementsFileFormat.js +32 -0
  21. package/src/charting/requirements/RequirementsImportFormats.js +48 -0
  22. package/src/charting/requirements/RequirementsModel.js +305 -0
  23. package/src/charting/requirements/Storyboard.js +94 -0
  24. package/src/charting/requirements/TrackerExport.js +273 -0
  25. package/src/charting/requirements/index.js +7 -0
  26. package/src/index.js +6 -0
  27. package/src/workspace/Workspace.js +329 -321
  28. package/src/workspace/index.js +2 -0
  29. package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
  30. package/src/workspace/parts/DataModelPart.js +668 -735
  31. package/src/workspace/parts/EditorPart.js +1 -1
  32. package/src/workspace/parts/ModelFile.js +3 -1
  33. package/src/workspace/parts/ModelHosted.js +176 -0
  34. package/src/workspace/parts/ModelsPart.js +146 -7
  35. package/src/workspace/parts/RequirementsPart.js +465 -0
  36. package/src/workspace/storage/ModelLibrary.js +8 -2
@@ -1626,319 +1626,327 @@
1626
1626
  */
1627
1627
  const SYSTEM_THEME = 'system';
1628
1628
 
1629
- /** Bump when the serialized shape changes; `deserialize` migrates on the way in. */
1630
- const WORKSPACE_SCHEMA_VERSION = 1;
1631
-
1632
- /**
1633
- * Workspace — the `<ws-workspace>` custom element and the shell's public API.
1634
- *
1635
- * Renders into **light DOM**: parts routinely carry their own stylesheets, and a
1636
- * consumer stylesheet cannot cross a shadow boundary. All shell markup is
1637
- * `ws-`-prefixed and themed through CSS custom properties instead.
1638
- *
1639
- * Typical wiring:
1640
- *
1641
- * const ws = document.querySelector('ws-workspace');
1642
- * ws.registerParts([ListPart, DetailPart, ChartPart]);
1643
- * ws.addPart({ id: 'nav', type: 'list', title: 'Tables', items: [...] });
1644
- * ws.registerPerspective('default', { type: 'split', dir: 'row', children: [...] });
1645
- * ws.activatePerspective('default');
1646
- */
1647
- class Workspace extends HTMLElement {
1648
- constructor() {
1649
- super();
1650
- this.bus = new ContextBus();
1651
- this.registry = new PartRegistry();
1652
- /** @type {Map<string, object>} name → layout JSON */
1653
- this.perspectives = new Map();
1654
- this.activePerspective = null;
1655
- /** @type {import('./layout/LayoutNode.js').LayoutNode|null} */
1656
- this.tree = null;
1657
- /** @type {Map<string, object>} theme name → CSS custom-property map */
1658
- this.themes = new Map(Object.entries(BUILTIN_THEMES));
1659
- /** The *selection* — may be `system`, which resolves at apply time. */
1660
- this.theme = 'light';
1661
- /** Custom properties currently written onto the host, so they can be
1662
- * cleared before a different theme is applied. */
1663
- this._appliedVars = [];
1664
- this._built = false;
1665
- }
1666
-
1667
- /* ── Element lifecycle ──────────────────────────────────────────────── */
1668
-
1669
- connectedCallback() {
1670
- if (this._built) return;
1671
- this._built = true;
1672
- ensureStyles(this.ownerDocument ?? document);
1673
- this.classList.add('ws-workspace');
1674
-
1675
- // Chrome is built from element specs rather than an HTML string, and
1676
- // `ref` captures the nodes directly — no querySelector round-trip.
1677
- // `label`, not `title` — `title` is a global HTML attribute and would
1678
- // also render a browser tooltip over the whole workspace.
1679
- render(this, [
1680
- { cls: 'ws-toolbar', children: [
1681
- { tag: 'span', cls: 'ws-toolbar-title', text: this.getAttribute('label') ?? '' },
1682
- { tag: 'span', cls: 'ws-toolbar-spacer' },
1683
- { tag: 'span', cls: 'ws-theme-picker', ref: n => { this._themePickerEl = n; } },
1684
- { tag: 'span', cls: 'ws-perspectives', ref: n => { this._perspectivesEl = n; } },
1685
- ]},
1686
- { cls: 'ws-root', ref: n => { this._rootEl = n; } },
1687
- ]);
1688
-
1689
- this.applyTheme(this.getAttribute('theme') ?? 'light');
1690
- this._renderThemePicker();
1691
-
1692
- // A perspective registered before the element was connected renders now.
1693
- if (this.activePerspective) this._renderTree();
1694
- else if (this.perspectives.size) this.activatePerspective([...this.perspectives.keys()][0]);
1695
- }
1696
-
1697
- disconnectedCallback() {
1698
- this.tree?.dispose();
1699
- this.tree = null;
1700
- this._watchSystemTheme(false); // a live matchMedia listener would outlive the element
1701
- }
1702
-
1703
- /* ── Parts ──────────────────────────────────────────────────────────── */
1704
-
1705
- registerPart(PartClass) { this.registry.register(PartClass); return this; }
1706
- registerParts(classes) { this.registry.registerAll(classes); return this; }
1707
-
1708
- /** Create a pooled part instance from a spec. */
1709
- addPart(spec) { return this.registry.add(spec); }
1710
- addParts(specs) { return specs.map(s => this.addPart(s)); }
1711
- getPart(id) { return this.registry.get(id); }
1712
- removePart(id) { return this.registry.remove(id); }
1713
-
1714
- /* ── Perspectives ───────────────────────────────────────────────────── */
1715
-
1716
- registerPerspective(name, layoutJson) {
1717
- this.perspectives.set(name, layoutJson);
1718
- this._renderPerspectiveButtons();
1719
- return this;
1720
- }
1721
-
1722
- /**
1723
- * Switch to a named perspective.
1724
- *
1725
- * Parts are pooled by id and live for the workspace's lifetime, so a part
1726
- * the incoming perspective also references is re-parented rather than
1727
- * rebuilt — it keeps its scroll position, unsaved input and subscriptions.
1728
- * Parts the incoming perspective doesn't reference are detached, not
1729
- * destroyed, so switching back restores them intact.
1730
- *
1731
- * There is deliberately no legal-transition table: a framework cannot know
1732
- * a consumer's perspectives. Pass `guard` to veto a switch if you need one.
1733
- */
1734
- activatePerspective(name) {
1735
- const layout = this.perspectives.get(name);
1736
- if (!layout) throw new Error(`Unknown perspective "${name}"`);
1737
- const from = this.activePerspective;
1738
- if (from === name) return true;
1739
- if (this.guard && this.guard(from, name) === false) return false;
1740
-
1741
- this.tree?.dispose();
1742
- this.activePerspective = name;
1743
- this._renderTree();
1744
-
1745
- this.dispatchEvent(new CustomEvent('ws-perspective-change', {
1746
- detail: { from, to: name }, bubbles: true,
1747
- }));
1748
- return true;
1749
- }
1750
-
1751
- _renderTree() {
1752
- if (!this._rootEl) return;
1753
- const layout = this.perspectives.get(this.activePerspective);
1754
- this._rootEl.textContent = '';
1755
- this.tree = createLayout(layout);
1756
- this.tree.render(this._rootEl, this._ctx());
1757
- this.tree.activate?.();
1758
- // Parts the new tree doesn't reference are deliberately NOT disposed —
1759
- // they are merely detached (PartNode.dispose), keeping their DOM and
1760
- // state. Disposing them would delete them from the pool, so switching
1761
- // back to the perspective that used them would find them gone. Part
1762
- // lifetime ends only at removePart()/deserialize().
1763
- this._renderPerspectiveButtons();
1764
- }
1765
-
1766
- _ctx() {
1767
- return {
1768
- bus: this.bus,
1769
- registry: this.registry,
1770
- workspace: this,
1771
- onLayoutChange: () => this._onLayoutChange(),
1772
- ...(this.services ?? {}),
1773
- };
1774
- }
1775
-
1776
- _onLayoutChange() {
1777
- if (this.activePerspective) {
1778
- this.perspectives.set(this.activePerspective, this.tree.serialize());
1779
- }
1780
- this.dispatchEvent(new CustomEvent('ws-layout-change', {
1781
- detail: { perspective: this.activePerspective }, bubbles: true,
1782
- }));
1783
- }
1784
-
1785
- _renderPerspectiveButtons() {
1786
- if (!this._perspectivesEl) return;
1787
- render(this._perspectivesEl, [...this.perspectives.keys()].map(name => ({
1788
- tag: 'button',
1789
- cls: ['ws-perspective-btn', name === this.activePerspective && 'ws-active'],
1790
- text: name,
1791
- attrs: { type: 'button' },
1792
- on: { click: () => this.activatePerspective(name) },
1793
- })));
1794
- }
1795
-
1796
- /* ── Theming ────────────────────────────────────────────────────────── */
1797
-
1798
- /**
1799
- * Register a custom-property set under a name. It appears in the toolbar's
1800
- * theme picker immediately — the picker is built from the registry, so a
1801
- * consumer theme needs no CSS and no picker change.
1802
- */
1803
- registerTheme(name, vars) {
1804
- this.themes.set(name, vars);
1805
- this._renderThemePicker();
1806
- return this;
1807
- }
1808
-
1809
- /** Registered theme names, plus the reserved `system` option. */
1810
- themeNames() { return [SYSTEM_THEME, ...this.themes.keys()]; }
1811
-
1812
- /**
1813
- * Apply a theme by name, or `system` to follow the OS setting.
1814
- *
1815
- * The previous theme's properties are removed before the new ones are set:
1816
- * a custom theme may define only some of the vars, and leftovers from the
1817
- * outgoing theme would otherwise show through as a mismatched palette.
1818
- */
1819
- applyTheme(name) {
1820
- const resolved = name === SYSTEM_THEME ? this._systemTheme() : name;
1821
- const vars = this.themes.get(resolved);
1822
- if (!vars && name !== SYSTEM_THEME) return this;
1823
-
1824
- for (const key of this._appliedVars) this.style.removeProperty(key);
1825
- this._appliedVars = [];
1826
- for (const [k, v] of Object.entries(vars ?? {})) {
1827
- this.style.setProperty(k, v);
1828
- this._appliedVars.push(k);
1829
- }
1830
-
1831
- const previous = this.theme;
1832
- this.theme = name; // the selection ('system' stays 'system')
1833
- this.setAttribute('data-theme', resolved); // the resolved name, for consumer CSS hooks
1834
- this._watchSystemTheme(name === SYSTEM_THEME);
1835
- if (this._themeSelect) this._themeSelect.value = name;
1836
-
1837
- if (previous !== name) {
1838
- this.dispatchEvent(new CustomEvent('ws-theme-change', {
1839
- detail: { theme: name, resolved }, bubbles: true,
1840
- }));
1841
- }
1842
- return this;
1843
- }
1844
-
1845
- _systemTheme() {
1846
- return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1847
- }
1848
-
1849
- /** Keep `system` live: re-apply when the OS flips between light and dark. */
1850
- _watchSystemTheme(enabled) {
1851
- if (enabled && !this._systemThemeQuery) {
1852
- this._systemThemeQuery = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
1853
- this._onSystemThemeChange = () => { if (this.theme === SYSTEM_THEME) this.applyTheme(SYSTEM_THEME); };
1854
- this._systemThemeQuery?.addEventListener('change', this._onSystemThemeChange);
1855
- } else if (!enabled && this._systemThemeQuery) {
1856
- this._systemThemeQuery.removeEventListener('change', this._onSystemThemeChange);
1857
- this._systemThemeQuery = null;
1858
- }
1859
- }
1860
-
1861
- _renderThemePicker() {
1862
- if (!this._themePickerEl) return;
1863
- if (this.getAttribute('theme-picker') === 'hidden') {
1864
- this._themePickerEl.textContent = '';
1865
- return;
1866
- }
1867
- render(this._themePickerEl, [
1868
- { tag: 'label', text: 'Theme', attrs: { for: 'ws-theme-select' } },
1869
- {
1870
- tag: 'select',
1871
- cls: 'ws-theme-select',
1872
- attrs: { id: 'ws-theme-select', title: 'Colour theme' },
1873
- on: { change: e => this.applyTheme(e.target.value) },
1874
- ref: n => { this._themeSelect = n; n.value = this.theme; },
1875
- children: this.themeNames().map(name => ({
1876
- tag: 'option',
1877
- text: name === SYSTEM_THEME ? 'System' : name[0].toUpperCase() + name.slice(1),
1878
- attrs: { value: name, selected: name === this.theme },
1879
- })),
1880
- },
1881
- ]);
1882
- }
1883
-
1884
- /* ── Persistence ────────────────────────────────────────────────────── */
1885
-
1886
- serialize() {
1887
- if (this.tree && this.activePerspective) {
1888
- this.perspectives.set(this.activePerspective, this.tree.serialize());
1889
- }
1890
- return {
1891
- version: WORKSPACE_SCHEMA_VERSION,
1892
- theme: this.theme,
1893
- activePerspective: this.activePerspective,
1894
- perspectives: Object.fromEntries(this.perspectives),
1895
- parts: this.registry.serialize(),
1896
- };
1897
- }
1898
-
1899
- /**
1900
- * Restore a serialized workspace. Part *types* must already be registered —
1901
- * a saved document carries specs, not code.
1902
- *
1903
- * `version` is honoured from day one on purpose: layout schemas change, and
1904
- * sqlengine's dashboard already had to retrofit exactly this migration once.
1905
- */
1906
- deserialize(json, { migrate } = {}) {
1907
- const doc = migrate ? migrate(json) : json;
1908
- if (doc.version > WORKSPACE_SCHEMA_VERSION) {
1909
- throw new Error(`Workspace document version ${doc.version} is newer than this build (${WORKSPACE_SCHEMA_VERSION})`);
1910
- }
1911
- this.registry.disposeAll();
1912
- for (const spec of doc.parts ?? []) {
1913
- if (spec.adapter) continue; // AdapterPart's mount fn cannot round-trip — re-add it in code
1914
- this.addPart(spec);
1915
- }
1916
- // Only a theme this build actually knows — a document naming one the
1917
- // consumer no longer registers must not leave the shell unstyled.
1918
- if (doc.theme && (doc.theme === SYSTEM_THEME || this.themes.has(doc.theme))) {
1919
- this.applyTheme(doc.theme);
1920
- }
1921
- this.perspectives = new Map(Object.entries(doc.perspectives ?? {}));
1922
- this.activePerspective = null;
1923
- if (doc.activePerspective && this.perspectives.has(doc.activePerspective)) {
1924
- this.activatePerspective(doc.activePerspective);
1925
- }
1926
- return this;
1927
- }
1928
- }
1929
-
1930
- const WORKSPACE_TAG = 'ws-workspace';
1931
-
1932
- /**
1933
- * Register the element. Custom element names must contain a hyphen, so
1934
- * `workspace` is not a legal name — `define()` throws SyntaxError on it.
1935
- * Guarded so importing this module twice does not throw on re-definition.
1936
- */
1937
- function defineWorkspace(tag = WORKSPACE_TAG) {
1938
- if (typeof customElements === 'undefined') return false;
1939
- if (customElements.get(tag)) return false;
1940
- customElements.define(tag, Workspace);
1941
- return true;
1629
+ /** Bump when the serialized shape changes; `deserialize` migrates on the way in. */
1630
+ const WORKSPACE_SCHEMA_VERSION = 1;
1631
+
1632
+ /**
1633
+ * Workspace — the `<ws-workspace>` custom element and the shell's public API.
1634
+ *
1635
+ * Renders into **light DOM**: parts routinely carry their own stylesheets, and a
1636
+ * consumer stylesheet cannot cross a shadow boundary. All shell markup is
1637
+ * `ws-`-prefixed and themed through CSS custom properties instead.
1638
+ *
1639
+ * Typical wiring:
1640
+ *
1641
+ * const ws = document.querySelector('ws-workspace');
1642
+ * ws.registerParts([ListPart, DetailPart, ChartPart]);
1643
+ * ws.addPart({ id: 'nav', type: 'list', title: 'Tables', items: [...] });
1644
+ * ws.registerPerspective('default', { type: 'split', dir: 'row', children: [...] });
1645
+ * ws.activatePerspective('default');
1646
+ */
1647
+ class Workspace extends HTMLElement {
1648
+ constructor() {
1649
+ super();
1650
+ this.bus = new ContextBus();
1651
+ this.registry = new PartRegistry();
1652
+ /** @type {Map<string, object>} name → layout JSON */
1653
+ this.perspectives = new Map();
1654
+ this.activePerspective = null;
1655
+ /** @type {import('./layout/LayoutNode.js').LayoutNode|null} */
1656
+ this.tree = null;
1657
+ /** @type {Map<string, object>} theme name → CSS custom-property map */
1658
+ this.themes = new Map(Object.entries(BUILTIN_THEMES));
1659
+ /** The *selection* — may be `system`, which resolves at apply time. */
1660
+ this.theme = 'light';
1661
+ /** Custom properties currently written onto the host, so they can be
1662
+ * cleared before a different theme is applied. */
1663
+ this._appliedVars = [];
1664
+ this._built = false;
1665
+ }
1666
+
1667
+ /* ── Element lifecycle ──────────────────────────────────────────────── */
1668
+
1669
+ connectedCallback() {
1670
+ // Moved, not new (a host re-parents it: a tab dragged to another
1671
+ // group): its layout was disposed when it left (disconnectedCallback),
1672
+ // so it is drawn again — the parts are still in the registry, as they
1673
+ // were. Without this a workspace moved in the page came back empty.
1674
+ if (this._built) {
1675
+ if (!this.tree && this.activePerspective) this._renderTree();
1676
+ this._watchSystemTheme(this.theme === SYSTEM_THEME);
1677
+ return;
1678
+ }
1679
+ this._built = true;
1680
+ ensureStyles(this.ownerDocument ?? document);
1681
+ this.classList.add('ws-workspace');
1682
+
1683
+ // Chrome is built from element specs rather than an HTML string, and
1684
+ // `ref` captures the nodes directly — no querySelector round-trip.
1685
+ // `label`, not `title` — `title` is a global HTML attribute and would
1686
+ // also render a browser tooltip over the whole workspace.
1687
+ render(this, [
1688
+ { cls: 'ws-toolbar', children: [
1689
+ { tag: 'span', cls: 'ws-toolbar-title', text: this.getAttribute('label') ?? '' },
1690
+ { tag: 'span', cls: 'ws-toolbar-spacer' },
1691
+ { tag: 'span', cls: 'ws-theme-picker', ref: n => { this._themePickerEl = n; } },
1692
+ { tag: 'span', cls: 'ws-perspectives', ref: n => { this._perspectivesEl = n; } },
1693
+ ]},
1694
+ { cls: 'ws-root', ref: n => { this._rootEl = n; } },
1695
+ ]);
1696
+
1697
+ this.applyTheme(this.getAttribute('theme') ?? 'light');
1698
+ this._renderThemePicker();
1699
+
1700
+ // A perspective registered before the element was connected renders now.
1701
+ if (this.activePerspective) this._renderTree();
1702
+ else if (this.perspectives.size) this.activatePerspective([...this.perspectives.keys()][0]);
1703
+ }
1704
+
1705
+ disconnectedCallback() {
1706
+ this.tree?.dispose();
1707
+ this.tree = null;
1708
+ this._watchSystemTheme(false); // a live matchMedia listener would outlive the element
1709
+ }
1710
+
1711
+ /* ── Parts ──────────────────────────────────────────────────────────── */
1712
+
1713
+ registerPart(PartClass) { this.registry.register(PartClass); return this; }
1714
+ registerParts(classes) { this.registry.registerAll(classes); return this; }
1715
+
1716
+ /** Create a pooled part instance from a spec. */
1717
+ addPart(spec) { return this.registry.add(spec); }
1718
+ addParts(specs) { return specs.map(s => this.addPart(s)); }
1719
+ getPart(id) { return this.registry.get(id); }
1720
+ removePart(id) { return this.registry.remove(id); }
1721
+
1722
+ /* ── Perspectives ───────────────────────────────────────────────────── */
1723
+
1724
+ registerPerspective(name, layoutJson) {
1725
+ this.perspectives.set(name, layoutJson);
1726
+ this._renderPerspectiveButtons();
1727
+ return this;
1728
+ }
1729
+
1730
+ /**
1731
+ * Switch to a named perspective.
1732
+ *
1733
+ * Parts are pooled by id and live for the workspace's lifetime, so a part
1734
+ * the incoming perspective also references is re-parented rather than
1735
+ * rebuilt — it keeps its scroll position, unsaved input and subscriptions.
1736
+ * Parts the incoming perspective doesn't reference are detached, not
1737
+ * destroyed, so switching back restores them intact.
1738
+ *
1739
+ * There is deliberately no legal-transition table: a framework cannot know
1740
+ * a consumer's perspectives. Pass `guard` to veto a switch if you need one.
1741
+ */
1742
+ activatePerspective(name) {
1743
+ const layout = this.perspectives.get(name);
1744
+ if (!layout) throw new Error(`Unknown perspective "${name}"`);
1745
+ const from = this.activePerspective;
1746
+ if (from === name) return true;
1747
+ if (this.guard && this.guard(from, name) === false) return false;
1748
+
1749
+ this.tree?.dispose();
1750
+ this.activePerspective = name;
1751
+ this._renderTree();
1752
+
1753
+ this.dispatchEvent(new CustomEvent('ws-perspective-change', {
1754
+ detail: { from, to: name }, bubbles: true,
1755
+ }));
1756
+ return true;
1757
+ }
1758
+
1759
+ _renderTree() {
1760
+ if (!this._rootEl) return;
1761
+ const layout = this.perspectives.get(this.activePerspective);
1762
+ this._rootEl.textContent = '';
1763
+ this.tree = createLayout(layout);
1764
+ this.tree.render(this._rootEl, this._ctx());
1765
+ this.tree.activate?.();
1766
+ // Parts the new tree doesn't reference are deliberately NOT disposed —
1767
+ // they are merely detached (PartNode.dispose), keeping their DOM and
1768
+ // state. Disposing them would delete them from the pool, so switching
1769
+ // back to the perspective that used them would find them gone. Part
1770
+ // lifetime ends only at removePart()/deserialize().
1771
+ this._renderPerspectiveButtons();
1772
+ }
1773
+
1774
+ _ctx() {
1775
+ return {
1776
+ bus: this.bus,
1777
+ registry: this.registry,
1778
+ workspace: this,
1779
+ onLayoutChange: () => this._onLayoutChange(),
1780
+ ...(this.services ?? {}),
1781
+ };
1782
+ }
1783
+
1784
+ _onLayoutChange() {
1785
+ if (this.activePerspective) {
1786
+ this.perspectives.set(this.activePerspective, this.tree.serialize());
1787
+ }
1788
+ this.dispatchEvent(new CustomEvent('ws-layout-change', {
1789
+ detail: { perspective: this.activePerspective }, bubbles: true,
1790
+ }));
1791
+ }
1792
+
1793
+ _renderPerspectiveButtons() {
1794
+ if (!this._perspectivesEl) return;
1795
+ render(this._perspectivesEl, [...this.perspectives.keys()].map(name => ({
1796
+ tag: 'button',
1797
+ cls: ['ws-perspective-btn', name === this.activePerspective && 'ws-active'],
1798
+ text: name,
1799
+ attrs: { type: 'button' },
1800
+ on: { click: () => this.activatePerspective(name) },
1801
+ })));
1802
+ }
1803
+
1804
+ /* ── Theming ────────────────────────────────────────────────────────── */
1805
+
1806
+ /**
1807
+ * Register a custom-property set under a name. It appears in the toolbar's
1808
+ * theme picker immediately — the picker is built from the registry, so a
1809
+ * consumer theme needs no CSS and no picker change.
1810
+ */
1811
+ registerTheme(name, vars) {
1812
+ this.themes.set(name, vars);
1813
+ this._renderThemePicker();
1814
+ return this;
1815
+ }
1816
+
1817
+ /** Registered theme names, plus the reserved `system` option. */
1818
+ themeNames() { return [SYSTEM_THEME, ...this.themes.keys()]; }
1819
+
1820
+ /**
1821
+ * Apply a theme by name, or `system` to follow the OS setting.
1822
+ *
1823
+ * The previous theme's properties are removed before the new ones are set:
1824
+ * a custom theme may define only some of the vars, and leftovers from the
1825
+ * outgoing theme would otherwise show through as a mismatched palette.
1826
+ */
1827
+ applyTheme(name) {
1828
+ const resolved = name === SYSTEM_THEME ? this._systemTheme() : name;
1829
+ const vars = this.themes.get(resolved);
1830
+ if (!vars && name !== SYSTEM_THEME) return this;
1831
+
1832
+ for (const key of this._appliedVars) this.style.removeProperty(key);
1833
+ this._appliedVars = [];
1834
+ for (const [k, v] of Object.entries(vars ?? {})) {
1835
+ this.style.setProperty(k, v);
1836
+ this._appliedVars.push(k);
1837
+ }
1838
+
1839
+ const previous = this.theme;
1840
+ this.theme = name; // the selection ('system' stays 'system')
1841
+ this.setAttribute('data-theme', resolved); // the resolved name, for consumer CSS hooks
1842
+ this._watchSystemTheme(name === SYSTEM_THEME);
1843
+ if (this._themeSelect) this._themeSelect.value = name;
1844
+
1845
+ if (previous !== name) {
1846
+ this.dispatchEvent(new CustomEvent('ws-theme-change', {
1847
+ detail: { theme: name, resolved }, bubbles: true,
1848
+ }));
1849
+ }
1850
+ return this;
1851
+ }
1852
+
1853
+ _systemTheme() {
1854
+ return globalThis.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
1855
+ }
1856
+
1857
+ /** Keep `system` live: re-apply when the OS flips between light and dark. */
1858
+ _watchSystemTheme(enabled) {
1859
+ if (enabled && !this._systemThemeQuery) {
1860
+ this._systemThemeQuery = globalThis.matchMedia?.('(prefers-color-scheme: dark)');
1861
+ this._onSystemThemeChange = () => { if (this.theme === SYSTEM_THEME) this.applyTheme(SYSTEM_THEME); };
1862
+ this._systemThemeQuery?.addEventListener('change', this._onSystemThemeChange);
1863
+ } else if (!enabled && this._systemThemeQuery) {
1864
+ this._systemThemeQuery.removeEventListener('change', this._onSystemThemeChange);
1865
+ this._systemThemeQuery = null;
1866
+ }
1867
+ }
1868
+
1869
+ _renderThemePicker() {
1870
+ if (!this._themePickerEl) return;
1871
+ if (this.getAttribute('theme-picker') === 'hidden') {
1872
+ this._themePickerEl.textContent = '';
1873
+ return;
1874
+ }
1875
+ render(this._themePickerEl, [
1876
+ { tag: 'label', text: 'Theme', attrs: { for: 'ws-theme-select' } },
1877
+ {
1878
+ tag: 'select',
1879
+ cls: 'ws-theme-select',
1880
+ attrs: { id: 'ws-theme-select', title: 'Colour theme' },
1881
+ on: { change: e => this.applyTheme(e.target.value) },
1882
+ ref: n => { this._themeSelect = n; n.value = this.theme; },
1883
+ children: this.themeNames().map(name => ({
1884
+ tag: 'option',
1885
+ text: name === SYSTEM_THEME ? 'System' : name[0].toUpperCase() + name.slice(1),
1886
+ attrs: { value: name, selected: name === this.theme },
1887
+ })),
1888
+ },
1889
+ ]);
1890
+ }
1891
+
1892
+ /* ── Persistence ────────────────────────────────────────────────────── */
1893
+
1894
+ serialize() {
1895
+ if (this.tree && this.activePerspective) {
1896
+ this.perspectives.set(this.activePerspective, this.tree.serialize());
1897
+ }
1898
+ return {
1899
+ version: WORKSPACE_SCHEMA_VERSION,
1900
+ theme: this.theme,
1901
+ activePerspective: this.activePerspective,
1902
+ perspectives: Object.fromEntries(this.perspectives),
1903
+ parts: this.registry.serialize(),
1904
+ };
1905
+ }
1906
+
1907
+ /**
1908
+ * Restore a serialized workspace. Part *types* must already be registered —
1909
+ * a saved document carries specs, not code.
1910
+ *
1911
+ * `version` is honoured from day one on purpose: layout schemas change, and
1912
+ * sqlengine's dashboard already had to retrofit exactly this migration once.
1913
+ */
1914
+ deserialize(json, { migrate } = {}) {
1915
+ const doc = migrate ? migrate(json) : json;
1916
+ if (doc.version > WORKSPACE_SCHEMA_VERSION) {
1917
+ throw new Error(`Workspace document version ${doc.version} is newer than this build (${WORKSPACE_SCHEMA_VERSION})`);
1918
+ }
1919
+ this.registry.disposeAll();
1920
+ for (const spec of doc.parts ?? []) {
1921
+ if (spec.adapter) continue; // AdapterPart's mount fn cannot round-trip — re-add it in code
1922
+ this.addPart(spec);
1923
+ }
1924
+ // Only a theme this build actually knows — a document naming one the
1925
+ // consumer no longer registers must not leave the shell unstyled.
1926
+ if (doc.theme && (doc.theme === SYSTEM_THEME || this.themes.has(doc.theme))) {
1927
+ this.applyTheme(doc.theme);
1928
+ }
1929
+ this.perspectives = new Map(Object.entries(doc.perspectives ?? {}));
1930
+ this.activePerspective = null;
1931
+ if (doc.activePerspective && this.perspectives.has(doc.activePerspective)) {
1932
+ this.activatePerspective(doc.activePerspective);
1933
+ }
1934
+ return this;
1935
+ }
1936
+ }
1937
+
1938
+ const WORKSPACE_TAG = 'ws-workspace';
1939
+
1940
+ /**
1941
+ * Register the element. Custom element names must contain a hyphen, so
1942
+ * `workspace` is not a legal name — `define()` throws SyntaxError on it.
1943
+ * Guarded so importing this module twice does not throw on re-definition.
1944
+ */
1945
+ function defineWorkspace(tag = WORKSPACE_TAG) {
1946
+ if (typeof customElements === 'undefined') return false;
1947
+ if (customElements.get(tag)) return false;
1948
+ customElements.define(tag, Workspace);
1949
+ return true;
1942
1950
  }
1943
1951
 
1944
1952
  /**
@@ -3393,16 +3401,23 @@
3393
3401
  * @param {string} options.mineName - What the model compared is called
3394
3402
  * @param {string} options.theirsName - What the other is called
3395
3403
  * @param {() => void} [options.onMerge] - Merge from the other (offered when there is something to merge)
3404
+ * @param {string} [options.note] - Said above the differences: why the other is offered (a proposal's rationale)
3396
3405
  * @param {Document} [options.document]
3397
3406
  */
3398
- constructor({ comparison, mineName, theirsName, onMerge = null, document: doc = globalThis.document }) {
3407
+ constructor({ comparison, mineName, theirsName, onMerge = null, note = '', document: doc = globalThis.document }) {
3399
3408
  this.comparison = comparison;
3400
3409
  this.mineName = mineName;
3401
3410
  this.theirsName = theirsName;
3402
3411
  this.onMerge = onMerge;
3412
+ this.note = note;
3403
3413
  this.document = doc;
3404
3414
  }
3405
3415
 
3416
+ /** The dialog's description: the note, then `text`. */
3417
+ described(text) {
3418
+ return this.note ? `${this.note}\n\n${text}` : text;
3419
+ }
3420
+
3406
3421
  get title() {
3407
3422
  return `Compare "${this.mineName}" with "${this.theirsName}"`;
3408
3423
  }
@@ -3441,7 +3456,7 @@
3441
3456
  const canMerge = !!this.onMerge && this.comparison.mergeable().length > 0;
3442
3457
  return new FormDialog({
3443
3458
  title: this.title,
3444
- description: this.summaryText(),
3459
+ description: this.described(this.summaryText()),
3445
3460
  fields: this.fields(),
3446
3461
  submitLabel: canMerge ? 'Merge…' : 'Close',
3447
3462
  cancelLabel: 'Close',
@@ -3474,7 +3489,7 @@
3474
3489
  const nothing = !this.comparison.mergeable().length;
3475
3490
  return new FormDialog({
3476
3491
  title: this.title,
3477
- description: nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too.`,
3492
+ description: this.described(nothing ? `"${this.mineName}" has everything "${this.theirsName}" has.` : `${this.summaryText()}\n\nWhat the chosen need (a view's elements, a relationship's ends) comes too.`),
3478
3493
  fields: this.fields(),
3479
3494
  submitLabel: nothing ? 'Close' : 'Merge',
3480
3495
  idPrefix: 'model-merge-',
@@ -3968,9 +3983,15 @@
3968
3983
  /** The key prefix of a kept model. */
3969
3984
  static PREFIX = 'model:';
3970
3985
 
3971
- /** @param {KeyValueStore} store */
3972
- constructor(store) {
3986
+ /**
3987
+ * @param {KeyValueStore} store
3988
+ * @param {Object} [options]
3989
+ * @param {string} [options.label] - Where it keeps models, as its menu names it
3990
+ */
3991
+ constructor(store, { label = 'This browser' } = {}) {
3973
3992
  this.store = store;
3993
+ /** Where it keeps models, as the File menu names it ('This browser'). */
3994
+ this.label = label;
3974
3995
  }
3975
3996
 
3976
3997
  /** A new id for a model to keep. */
@@ -4167,6 +4188,66 @@
4167
4188
  }
4168
4189
  }
4169
4190
 
4191
+ /**
4192
+ * Benchmark — what a model is measured by, so a change to it (a proposal, a
4193
+ * merge) says whether it made the model better. Each measure:
4194
+ * `{ key, label, value, better: 'lower'|'higher', format?: 'count'|'share'|'score' }`
4195
+ * (a share is 0..1; a value of null is not measured yet).
4196
+ *
4197
+ * Every model with a checker is measured by its findings — warnings counted
4198
+ * apart (`Benchmark.findings`); a model with more to say gives its own
4199
+ * `benchmark()` (a data model its Scorecard, a requirements model how many
4200
+ * stories say when they are done and trace to something), starting from those.
4201
+ */
4202
+ class Benchmark {
4203
+ /** A model's measures: its own benchmark(), else its checker's findings, else none. */
4204
+ static of(model) {
4205
+ if (typeof model?.benchmark === 'function') return model.benchmark();
4206
+ return Benchmark.findings(model);
4207
+ }
4208
+
4209
+ /** The check's warnings and all its findings, each fewer the better. */
4210
+ static findings(model) {
4211
+ if (typeof model?.checker !== 'function') return [];
4212
+ const found = model.checker().run();
4213
+ return [
4214
+ { key: 'warnings', label: 'Check warnings', value: found.filter(f => f.severity === 'warning').length, better: 'lower', format: 'count' },
4215
+ { key: 'findings', label: 'Check findings', value: found.length, better: 'lower', format: 'count' },
4216
+ ];
4217
+ }
4218
+
4219
+ /**
4220
+ * Two models' measures side by side, matched by key: `{key, label, format,
4221
+ * before, after, change}` — change 'better', 'worse', 'same', or null when
4222
+ * either is not measured.
4223
+ */
4224
+ static compare(before, after) {
4225
+ const then = new Map(before.map(m => [m.key, m]));
4226
+ return after.filter(m => then.has(m.key)).map(m => {
4227
+ const was = then.get(m.key).value, now = m.value;
4228
+ const change = was == null || now == null ? null
4229
+ : was === now ? 'same'
4230
+ : (now < was) === (m.better === 'lower') ? 'better' : 'worse';
4231
+ return { key: m.key, label: m.label, format: m.format ?? 'count', before: was, after: now, change };
4232
+ });
4233
+ }
4234
+
4235
+ /** A measure's value as it reads ('3', '50%', '72 of 100', '—'). */
4236
+ static format(value, format = 'count') {
4237
+ if (value == null) return '—';
4238
+ if (format === 'share') return `${Math.round(value * 100)}%`;
4239
+ if (format === 'score') return `${value} of 100`;
4240
+ return String(value);
4241
+ }
4242
+
4243
+ /** A comparison in a line: "Check findings 7 → 3 (better); …" — what did not change left out unless nothing did. */
4244
+ static text(rows) {
4245
+ const moved = rows.filter(r => r.change !== 'same');
4246
+ const shown = moved.length ? moved : rows;
4247
+ return shown.map(r => `${r.label} ${Benchmark.format(r.before, r.format)} → ${Benchmark.format(r.after, r.format)}${r.change && r.change !== 'same' ? ` (${r.change})` : ''}`).join('; ');
4248
+ }
4249
+ }
4250
+
4170
4251
  /**
4171
4252
  * ModelsPart — several models open at once (as Archi's model tree holds
4172
4253
  * several): one editor part per model (any EditorPart type, `editorType`),
@@ -4199,6 +4280,23 @@
4199
4280
  * closed open again next time. File ▸ This browser opens a kept model, stops
4200
4281
  * keeping one, backs them all up (BackupFolder: a folder chosen once, such as
4201
4282
  * one OneDrive or Google Drive syncs, else downloads) and chooses that folder.
4283
+ *
4284
+ * Where models are kept is the host's to say: a `modelLibrary` among the
4285
+ * workspace's services (`workspace.services`, reaching this part as
4286
+ * `ctx.modelLibrary`) is used in place of the browser's — anything with
4287
+ * ModelLibrary's methods (keep, load, list, openOnes, setOpen, remove) and a
4288
+ * `label` for the menu ('The engine'). An entry is the model in its editor's
4289
+ * file format, so a host stores it as it likes: a database row, a file, a
4290
+ * graph built from it. The backup folder stays in the browser.
4291
+ *
4292
+ * Proposals: a model offered as a change to the one in front — by an agent,
4293
+ * a colleague, an import — with why (`propose`). Tools ▸ Proposals lists them;
4294
+ * each is reviewed as a merge (ModelMergeDialog, its rationale shown), so what
4295
+ * is taken is chosen and is one undo step. Taking or dismissing one removes it.
4296
+ *
4297
+ * A host adds tools of its own for the model in front — an agent to run on
4298
+ * it, say — as `modelTools(part, models)` among the workspace's services: its
4299
+ * items end the Tools menu.
4202
4300
  */
4203
4301
  class ModelsPart extends Part {
4204
4302
  static type = 'models';
@@ -4216,6 +4314,9 @@
4216
4314
  this.backups = null;
4217
4315
  /** Edits waiting to be kept, by part id. @private */
4218
4316
  this._pending = new Map();
4317
+ /** Changes offered, not yet taken or dismissed (propose). */
4318
+ this.proposals = [];
4319
+ this._proposalSeq = 0;
4219
4320
  }
4220
4321
 
4221
4322
  /** What a copy's name ends with (copyName). */
@@ -4262,8 +4363,8 @@
4262
4363
  * @param {Array<EditorPart>} starting - The models the spec opened
4263
4364
  */
4264
4365
  async startKeeping(starting = []) {
4265
- const store = await this.openStore();
4266
- this.library = new ModelLibrary(store);
4366
+ const store = await this.openStore(); // the backup folder's, whoever keeps the models
4367
+ this.library = this.ctx?.modelLibrary ?? new ModelLibrary(store);
4267
4368
  const win = this.container?.ownerDocument?.defaultView ?? globalThis;
4268
4369
  this.backups = new BackupFolder({
4269
4370
  store, window: win,
@@ -4353,7 +4454,7 @@
4353
4454
  async askOpenKept() {
4354
4455
  const open = new Set(this.models().map(p => p.keepId));
4355
4456
  const entries = this.ownKept(await this.library.list()).filter(e => !open.has(e.id));
4356
- if (!entries.length) { this.tell('Every model kept in this browser is open.'); return null; }
4457
+ if (!entries.length) { this.tell(`Every model kept (${this.keptWhere}) is open.`); return null; }
4357
4458
  return new FormDialog({
4358
4459
  title: 'Open a kept model',
4359
4460
  fields: [{
@@ -4392,7 +4493,7 @@
4392
4493
  async backUpAndTell() {
4393
4494
  for (const part of this.models()) await this.keep(part);
4394
4495
  const files = await this.backupFiles();
4395
- if (!files.length) { this.tell('Nothing is kept in this browser yet.'); return null; }
4496
+ if (!files.length) { this.tell(`Nothing is kept (${this.keptWhere}) yet.`); return null; }
4396
4497
  const { location, names } = await this.backups.backUp(files);
4397
4498
  this.tell(`Backed up ${names.length} model${names.length === 1 ? '' : 's'} to the ${location}:\n\n${names.join('\n')}`);
4398
4499
  return { location, names };
@@ -4405,6 +4506,83 @@
4405
4506
  return dir;
4406
4507
  }
4407
4508
 
4509
+ /** Where models are kept, as the File menu names it: the library's label. */
4510
+ get keptWhere() {
4511
+ return this.library?.label ?? 'This browser';
4512
+ }
4513
+
4514
+ // ===== Proposals =====
4515
+
4516
+ /**
4517
+ * Offer a change: the model proposed — `spec`, a model as this workbench's
4518
+ * editor takes it (as a base is given), or `entry`, a model in one of the
4519
+ * editor's file formats (`{format, data}`, as a kept one is) — with a
4520
+ * `title` and why (`rationale`), from `source` (who or what proposed it).
4521
+ * `onSettle(outcome, {bench})` hears whether it was 'taken' or
4522
+ * 'dismissed' and, taken, how what was taken moved the bench. Returns the proposal.
4523
+ * @param {{title: string, rationale?: string, source?: string, spec?: Object, entry?: {format: string, data: *}, onSettle?: Function}} proposal
4524
+ */
4525
+ propose({ title, rationale = '', source = '', spec = null, entry = null, onSettle = null }) {
4526
+ if (!spec && !entry) throw new TypeError('A proposal needs a model spec');
4527
+ const id = `${this.id}-proposal-${++this._proposalSeq}`;
4528
+ const part = this.registry.create({ ...ModelsPart.copySpec(spec ?? {}), id, type: this.editorType });
4529
+ if (entry) {
4530
+ const format = part.fileFormats().find(f => f.label === entry.format);
4531
+ if (!format) { part.dispose?.(); throw new Error(`No ${entry.format} format here to read the proposal in`); }
4532
+ part.replaceModel(format.import(entry.data));
4533
+ }
4534
+ const proposal = { id, title: title || part.model?.name || 'Proposal', rationale, source, part, onSettle };
4535
+ this.proposals.push(proposal);
4536
+ return proposal;
4537
+ }
4538
+
4539
+ /**
4540
+ * How the proposal would move the model's measures (Benchmark), were it
4541
+ * taken whole: `[{label, before, after, change, …}]`.
4542
+ */
4543
+ proposalBench(part, proposal) {
4544
+ return Benchmark.compare(Benchmark.of(part.model), Benchmark.of(proposal.part.model));
4545
+ }
4546
+
4547
+ /** Forget a proposal: not wanted (`outcome` 'dismissed'), or taken. Its proposer hears which. */
4548
+ dismissProposal(proposal, outcome = 'dismissed', note = {}) {
4549
+ if (!this.proposals.includes(proposal)) return;
4550
+ this.proposals = this.proposals.filter(p => p !== proposal);
4551
+ proposal.onSettle?.(outcome, note);
4552
+ proposal.part?.dispose?.();
4553
+ }
4554
+
4555
+ /**
4556
+ * Review a proposal against `part`'s model: its differences, chosen as a
4557
+ * merge (ModelMergeDialog), its rationale above them. Merging takes it.
4558
+ */
4559
+ reviewProposal(part, proposal) {
4560
+ const bench = this.proposalBench(part, proposal);
4561
+ return new ModelMergeDialog({
4562
+ ...this._comparing(part, proposal.part),
4563
+ theirsName: proposal.title,
4564
+ note: [proposal.source && `Proposed by ${proposal.source}.`, proposal.rationale, bench.length && `Taken whole: ${Benchmark.text(bench)}.`].filter(Boolean).join(' '),
4565
+ onMerge: (command) => {
4566
+ // What was taken (the parts chosen), measured: the model before the merge and after.
4567
+ const before = Benchmark.of(part.model);
4568
+ part.runModelEdit(command);
4569
+ this.dismissProposal(proposal, 'taken', { bench: Benchmark.text(Benchmark.compare(before, Benchmark.of(part.model))) });
4570
+ },
4571
+ }).open();
4572
+ }
4573
+
4574
+ /** Tools ▸ Proposals: each, to review or dismiss. */
4575
+ proposalItems(part) {
4576
+ if (!this.proposals.length) return [{ label: 'None waiting', disabled: true }];
4577
+ return this.proposals.map(p => ({
4578
+ label: p.source ? `${p.title} — ${p.source}` : p.title,
4579
+ items: [
4580
+ { label: 'Review and merge…', action: () => this.reviewProposal(part, p) },
4581
+ { label: 'Dismiss', action: () => this.dismissProposal(p) },
4582
+ ],
4583
+ }));
4584
+ }
4585
+
4408
4586
  /** Tell the user (the page's alert). @protected */
4409
4587
  tell(message) {
4410
4588
  const win = this.container?.ownerDocument?.defaultView ?? globalThis;
@@ -4459,6 +4637,19 @@
4459
4637
  this.registry.create({ ...ModelsPart.copySpec(spec), id: `${this.id}-base-${i + 1}`, type: this.editorType })));
4460
4638
  }
4461
4639
 
4640
+ /**
4641
+ * Replace the bases: a host whose reference models change (a live
4642
+ * database after its schema changed) gives them afresh. The parts made
4643
+ * for the old ones are disposed; nothing open is touched.
4644
+ * @param {Array<Object>} specs - Editor part specs, as `bases` in the spec
4645
+ */
4646
+ setBases(specs = []) {
4647
+ for (const base of this._bases ?? []) base.dispose?.();
4648
+ this._bases = null;
4649
+ this.spec.bases = specs;
4650
+ return this.bases();
4651
+ }
4652
+
4462
4653
  /** What a copy of a model is called: " (copy)" after its name, once (a copy of a copy is not "… (copy) (copy)"). */
4463
4654
  static copyName(name) {
4464
4655
  const plain = String(name || 'Untitled');
@@ -4485,9 +4676,33 @@
4485
4676
  return part;
4486
4677
  }
4487
4678
 
4488
- /** File ▸ New ▸'s items from bases: From base: <name>, one a base. */
4679
+ /**
4680
+ * Open a base to look at (by its place in `bases`): a tab of its own copy,
4681
+ * named as the base is with " (view)", that can be laid out and moved
4682
+ * about but is never kept — a live database seen as it is, without making a
4683
+ * design of it. Returns the part.
4684
+ */
4685
+ viewBase(index) {
4686
+ const spec = this.bases()[index]?.spec;
4687
+ if (!spec) return null;
4688
+ const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
4689
+ const part = this.newModel({ ...copy, name: `${copy.name ?? this.baseLabel(this.bases()[index], index)}${ModelsPart.VIEW_SUFFIX}` });
4690
+ part.keepOff = true;
4691
+ this.work.focus(part.id);
4692
+ return part;
4693
+ }
4694
+
4695
+ /** What a base viewed is called: " (view)" after its name. */
4696
+ static VIEW_SUFFIX = ' (view)';
4697
+
4698
+ /** File ▸ New ▸'s items from bases: From base: <name>, a design from one; View base: <name>, one to look at. */
4489
4699
  baseItems() {
4490
- return this.bases().map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) }));
4700
+ const bases = this.bases();
4701
+ return [
4702
+ ...bases.map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) })),
4703
+ ...(bases.length ? [{ separator: true }] : []),
4704
+ ...bases.map((base, i) => ({ label: `View base: ${this.baseLabel(base, i)}`, action: () => this.viewBase(i) })),
4705
+ ];
4491
4706
  }
4492
4707
 
4493
4708
  /**
@@ -4520,6 +4735,10 @@
4520
4735
  return [
4521
4736
  { label: 'Compare with', items: each((a, b) => this.compare(a, b)) },
4522
4737
  { label: 'Merge from', items: each((a, b) => this.merge(a, b)) },
4738
+ { separator: true },
4739
+ { label: `Proposals (${this.proposals.length})`, items: this.proposalItems(part) },
4740
+ // The host's own tools for the model in front (`services.modelTools(part, models)`: agents, say).
4741
+ ...(typeof this.ctx?.modelTools === 'function' ? [{ separator: true }, ...this.ctx.modelTools(part, this)] : []),
4523
4742
  ];
4524
4743
  }
4525
4744
 
@@ -4604,7 +4823,7 @@
4604
4823
  return [
4605
4824
  ...(saves ? [save, saveAs, { separator: true }, { label: 'Duplicate model', action: () => this.duplicate(part) }] : []),
4606
4825
  ...(compare ? [compare, merge] : []),
4607
- ...(keeping.length ? [{ label: 'This browser', items: keeping }] : []),
4826
+ ...(keeping.length ? [{ label: this.keptWhere, items: keeping }] : []),
4608
4827
  { separator: true },
4609
4828
  { label: 'Close model', action: () => this.closeModel(part.id) },
4610
4829
  { label: 'Close other models', disabled: !others.length || undefined, action: () => { for (const id of others) this.closeModel(id); } },
@@ -23539,14 +23758,19 @@
23539
23758
 
23540
23759
  /**
23541
23760
  * Fruchterman–Reingold: lines pull their ends together, every pair of boxes
23542
- * pushes apart, cooling over the iterations. Deterministic: it starts from
23543
- * where the boxes are (boxes on one spot are spread round it first).
23761
+ * pushes apart, cooling over the iterations, all within a frame — a square
23762
+ * round where the boxes were, room for them a grid's worth — so a box with no
23763
+ * lines is not pushed away for ever. Deterministic: it starts from where the
23764
+ * boxes are (boxes on one spot are spread round it first).
23544
23765
  */
23545
23766
  function forceDirected(boxes, edges, gap) {
23546
23767
  const size = Math.max(...boxes.map(b => Math.max(b.width, b.height))) + gap;
23547
23768
  const k = size * 1.2;
23548
23769
  const pos = boxes.map((b, i) => ({ x: b.left + b.width / 2 + (i % 3) * 0.5, y: b.top + b.height / 2 + Math.floor(i / 3) * 0.5 }));
23549
23770
  const index = new Map(boxes.map((b, i) => [b.id, i]));
23771
+ const centre = { x: pos.reduce((t, p) => t + p.x, 0) / pos.length, y: pos.reduce((t, p) => t + p.y, 0) / pos.length };
23772
+ const half = (k * (Math.ceil(Math.sqrt(boxes.length)) + 1)) / 2;
23773
+ const inFrame = (v, c) => Math.min(c + half, Math.max(c - half, v));
23550
23774
  let temperature = size * 2;
23551
23775
  for (let iteration = 0; iteration < 120; iteration++) {
23552
23776
  const shift = pos.map(() => ({ x: 0, y: 0 }));
@@ -23571,8 +23795,8 @@
23571
23795
  for (let i = 0; i < pos.length; i++) {
23572
23796
  const d = Math.max(1e-9, Math.hypot(shift[i].x, shift[i].y));
23573
23797
  const step = Math.min(d, temperature);
23574
- pos[i].x += (shift[i].x / d) * step;
23575
- pos[i].y += (shift[i].y / d) * step;
23798
+ pos[i].x = inFrame(pos[i].x + (shift[i].x / d) * step, centre.x);
23799
+ pos[i].y = inFrame(pos[i].y + (shift[i].y / d) * step, centre.y);
23576
23800
  }
23577
23801
  temperature *= 0.95;
23578
23802
  }
@@ -28422,6 +28646,8 @@
28422
28646
  this.instance = instance;
28423
28647
  this.model = model;
28424
28648
  this.nodeOptions = nodeOptions;
28649
+ /** A host's live status, `{text, color}` (the editor's elementStatus), or null: drawn as a pill. */
28650
+ this.status = null;
28425
28651
  }
28426
28652
 
28427
28653
  /** State lives on the instance and the model element, not here. @protected */
@@ -28537,6 +28763,27 @@
28537
28763
  * gets no glyph badge (buildNode leaves iconShape unset) but keeps an icon the element sets.
28538
28764
  */
28539
28765
  decorations(ctx) {
28766
+ return [...this._badge(ctx), ...this.statusPill(ctx)];
28767
+ }
28768
+
28769
+ /**
28770
+ * The host's status (`status`), as a small pill inside the box's bottom
28771
+ * right corner: its text, on its colour. Nothing without one.
28772
+ */
28773
+ statusPill({ w, h }) {
28774
+ const text = this.status?.text;
28775
+ if (!text) return [];
28776
+ const label = String(text);
28777
+ const ph = 12, pw = Math.min(w - 6, label.length * 5.5 + 10);
28778
+ const x = w / 2 - pw - 3, y = h / 2 - ph - 3;
28779
+ return [Glyph.group([
28780
+ Glyph.rect(x, y, pw, ph, this.status.color || '#455a64', null, { rx: 6, ry: 6 }),
28781
+ Glyph.text(label, x + pw / 2, y + ph / 2, { fill: '#ffffff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: '8px' }),
28782
+ ], { 'pointer-events': 'none', class: 'element-status' })];
28783
+ }
28784
+
28785
+ /** The type badge (decorations). @private */
28786
+ _badge(ctx) {
28540
28787
  const node = this.node;
28541
28788
  const { w, h } = ctx;
28542
28789
  const fixed = ArchimateElementTemplate.badgeSize(w, h);
@@ -32006,7 +32253,9 @@
32006
32253
  * ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
32007
32254
  * each a button (⚠ a warning, ℹ for a look) that shows what it names
32008
32255
  * (`onReveal`), with what can be done about it beside (`actionsFor`), or
32009
- * "nothing found". Any editor or part that checks a model shows it this way.
32256
+ * "nothing found". Any editor or part that checks a model shows it this way;
32257
+ * anything else listed to be opened (search results) too, with its own
32258
+ * `title` and `empty` text.
32010
32259
  */
32011
32260
  class ModelCheckDialog {
32012
32261
  /**
@@ -32015,9 +32264,13 @@
32015
32264
  * @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
32016
32265
  * @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
32017
32266
  * @param {Document} [options.document]
32267
+ * @param {string} [options.title] - In place of "Model check: n findings"
32268
+ * @param {string} [options.empty] - What an empty list says
32018
32269
  */
32019
- constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
32270
+ constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document, title = null, empty = 'Nothing found: the model passes every check.' }) {
32020
32271
  this.findings = findings;
32272
+ this._title = title;
32273
+ this.empty = empty;
32021
32274
  this.onReveal = onReveal;
32022
32275
  this.actionsFor = actionsFor;
32023
32276
  this.document = doc;
@@ -32025,6 +32278,7 @@
32025
32278
 
32026
32279
  /** The dialog's title: how many findings. */
32027
32280
  get title() {
32281
+ if (this._title) return this._title;
32028
32282
  const n = this.findings.length;
32029
32283
  return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
32030
32284
  }
@@ -32050,7 +32304,7 @@
32050
32304
  on: { click: () => { dialog?.close(); act.action(); } },
32051
32305
  })),
32052
32306
  ],
32053
- })) : [{ text: 'Nothing found: the model passes every check.' }],
32307
+ })) : [{ text: this.empty }],
32054
32308
  }, this.document);
32055
32309
  dialog = new FormDialog({
32056
32310
  title: this.title,
@@ -36733,8 +36987,12 @@
36733
36987
  * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
36734
36988
  * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
36735
36989
  * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
36990
+ * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
36991
+ * live status for an element (a component's lifecycle state), drawn on its boxes
36992
+ * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
36993
+ * element (its transitions), at the end of a box's right-click menu
36736
36994
  */
36737
- constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, ...options }) {
36995
+ constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
36738
36996
  super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
36739
36997
  this.view = view;
36740
36998
  this.model = model;
@@ -36747,6 +37005,9 @@
36747
37005
  this.settingsManager = settingsManager;
36748
37006
  /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
36749
37007
  this.templateMenu = templateMenu;
37008
+ /** A host's live status per element, drawn on its boxes, and its menu items per element. */
37009
+ this.elementStatus = elementStatus;
37010
+ this.elementMenuExtras = elementMenuExtras;
36750
37011
  this.addDropHandler({
36751
37012
  accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
36752
37013
  drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
@@ -36784,6 +37045,7 @@
36784
37045
  ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
36785
37046
  ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
36786
37047
  ...(items.length ? [{ label: 'Save as template', items }] : []),
37048
+ ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
36787
37049
  ];
36788
37050
  }
36789
37051
 
@@ -37008,7 +37270,11 @@
37008
37270
  templates.set(inst.id, existing?.instance === inst ? existing
37009
37271
  : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
37010
37272
  }
37011
- for (const t of templates.values()) t.nodeOptions = this.nodeOptions;
37273
+ for (const t of templates.values()) {
37274
+ t.nodeOptions = this.nodeOptions;
37275
+ // Asked afresh at every sync: a host's status is live, not part of the model.
37276
+ t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
37277
+ }
37012
37278
  // The view's groups (Archi groups: model folders with bounds on this view).
37013
37279
  for (const folder of view.placedFolders) {
37014
37280
  const existing = this.templates.get(folder.id);
@@ -37507,6 +37773,9 @@
37507
37773
  /** Whether files in this format can be imported (opened) as well as exported. A subclass that only exports says no. */
37508
37774
  get imports() { return true; }
37509
37775
 
37776
+ /** Whether a model can be exported in this format. A subclass that only reads (another tool's export) says no. */
37777
+ get exports() { return true; }
37778
+
37510
37779
  /** True when import takes the file's bytes (a zip archive), else its text. */
37511
37780
  get readsBytes() { return false; }
37512
37781
 
@@ -37566,7 +37835,7 @@
37566
37835
  const choose = chooseFile ?? ((accept) => pickFile(accept, doc()));
37567
37836
  const tell = report ?? ((message) => (doc().defaultView ?? globalThis).alert(message));
37568
37837
  const groups = formats.map(format => [
37569
- only !== 'import' && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
37838
+ only !== 'import' && format.exports !== false && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
37570
37839
  only !== 'export' && format.imports && enabled(format, 'import') && {
37571
37840
  label: only ? `${format.label}…` : `Import ${format.label}…`,
37572
37841
  action: () => importFile(format, { choose, onImport, report: tell }),
@@ -45628,50 +45897,151 @@
45628
45897
  }
45629
45898
  }
45630
45899
 
45631
- /**
45632
- * DataModelEditCommand — one edit of a DataModel (an entity, an attribute, a
45633
- * relationship, a delete) as one undo step: run, it makes the change and keeps
45634
- * the model as it was before and after (DataModel#toJSON); undone or redone, it
45635
- * puts the model back as it was (DataModel#restore). Any change to a model is
45636
- * undoable this way, whatever it touches.
45637
- */
45638
- class DataModelEditCommand extends Command {
45639
- /** A model edit is no edit on one view: undoing it shows no other. */
45640
- viewId = null;
45641
-
45642
- /**
45643
- * @param {DataModel} model
45644
- * @param {string} label - What the undo menu calls it ('Edit Customer')
45645
- * @param {(model: DataModel) => *} change - Makes the change
45646
- */
45647
- constructor(model, label, change) {
45648
- super(label);
45649
- this.model = model;
45650
- this.change = change;
45651
- this.before = null;
45652
- this.after = null;
45653
- }
45654
-
45655
- execute() {
45656
- if (this.after) {
45657
- this.model.restore(this.after);
45658
- } else {
45659
- this.before = DataModelEditCommand.snapshot(this.model);
45660
- this.result = this.change(this.model);
45661
- this.after = DataModelEditCommand.snapshot(this.model);
45662
- }
45663
- this.model.edited();
45664
- }
45665
-
45666
- undo() {
45667
- this.model.restore(this.before);
45668
- this.model.edited();
45669
- }
45670
-
45671
- /** The model as it is, a copy nothing later changes (its toJSON shares some arrays). */
45672
- static snapshot(model) {
45673
- return JSON.parse(JSON.stringify(model.toJSON()));
45674
- }
45900
+ /**
45901
+ * ModelEditCommand — one edit of an EditableModel (a data model's entity, a
45902
+ * requirements model's story, a delete) as one undo step: run, it makes the
45903
+ * change and keeps the model as it was before and after (its toJSON); undone
45904
+ * or redone, it puts the model back as it was (its restore). Any change to a
45905
+ * model is undoable this way, whatever it touches.
45906
+ */
45907
+ class ModelEditCommand extends Command {
45908
+ /** A model edit is no edit on one view: undoing it shows no other. */
45909
+ viewId = null;
45910
+
45911
+ /**
45912
+ * @param {EditableModel} model
45913
+ * @param {string} label - What the undo menu calls it ('Edit Customer')
45914
+ * @param {(model: EditableModel) => *} change - Makes the change
45915
+ */
45916
+ constructor(model, label, change) {
45917
+ super(label);
45918
+ this.model = model;
45919
+ this.change = change;
45920
+ this.before = null;
45921
+ this.after = null;
45922
+ }
45923
+
45924
+ execute() {
45925
+ if (this.after) {
45926
+ this.model.restore(this.after);
45927
+ } else {
45928
+ this.before = ModelEditCommand.snapshot(this.model);
45929
+ this.result = this.change(this.model);
45930
+ this.after = ModelEditCommand.snapshot(this.model);
45931
+ }
45932
+ this.model.edited();
45933
+ }
45934
+
45935
+ undo() {
45936
+ this.model.restore(this.before);
45937
+ this.model.edited();
45938
+ }
45939
+
45940
+ /** The model as it is, a copy nothing later changes (its toJSON may share arrays). */
45941
+ static snapshot(model) {
45942
+ return JSON.parse(JSON.stringify(model.toJSON()));
45943
+ }
45944
+ }
45945
+
45946
+ /**
45947
+ * EditableModel — base of a model edited as a whole rather than view by view
45948
+ * (DataModel, RequirementsModel; a ViewSetModel keeps its views' histories
45949
+ * its own way). What a part needs of any model it edits, keeps and merges:
45950
+ *
45951
+ * - a name;
45952
+ * - whether it changed since it was saved (`edited()`, `markSaved()`,
45953
+ * `isDirty()`, `onEdit(fn)`), so a part shows it, keeps it and asks before
45954
+ * it is lost;
45955
+ * - an undo history, every edit one step on it (`editCommand(label, change)`:
45956
+ * the model before and after, as its toJSON, put back by `restore`);
45957
+ * - how it differs from another of its kind (`compareWith`, over the
45958
+ * subclass's `comparedKinds()`), which merges the other's into it.
45959
+ *
45960
+ * A subclass gives `toJSON()`, its constructor reading that back, and
45961
+ * `static STATE`: the fields `restore` puts back.
45962
+ */
45963
+ class EditableModel {
45964
+ /** The fields a snapshot puts back (restore). A subclass lists its own, `name` among them. */
45965
+ static STATE = Object.freeze(['name']);
45966
+
45967
+ /** @param {{name?: string}} [spec] */
45968
+ constructor(spec = {}) {
45969
+ if (new.target === EditableModel) throw new TypeError('EditableModel is abstract — subclass it');
45970
+ /** What the model is called. */
45971
+ this.name = spec.name ?? '';
45972
+ this._version = 0;
45973
+ this._saved = 0;
45974
+ this._editListeners = [];
45975
+ }
45976
+
45977
+ /** The kinds it is compared on and merged by (ComparedKind), in the order a merge brings them. None here. */
45978
+ comparedKinds() {
45979
+ return [];
45980
+ }
45981
+
45982
+ /** How this model differs from another (ModelComparison), which can merge the other's into it. */
45983
+ compareWith(other) {
45984
+ return new ModelComparison(this, other);
45985
+ }
45986
+
45987
+ /** Its undo history: a merge is one step on it. */
45988
+ get history() {
45989
+ return (this._history ??= new CommandHistory());
45990
+ }
45991
+
45992
+ /** A change made as one undo step (ModelEditCommand), not yet run. */
45993
+ editCommand(label, change) {
45994
+ return new ModelEditCommand(this, label, change);
45995
+ }
45996
+
45997
+ /** Put the model back as a snapshot (its toJSON) says: the fields its class lists (STATE). */
45998
+ restore(json) {
45999
+ const back = new this.constructor(JSON.parse(JSON.stringify(json)));
46000
+ for (const key of this.constructor.STATE) this[key] = back[key];
46001
+ }
46002
+
46003
+ // ===== Changed since saved =====
46004
+
46005
+ /** Something changed: listeners hear of it. */
46006
+ edited() {
46007
+ this._version++;
46008
+ this._emitEdit();
46009
+ }
46010
+
46011
+ /** This state is the saved one (a file opened or saved). */
46012
+ markSaved() {
46013
+ this._saved = this._version;
46014
+ this._emitEdit();
46015
+ }
46016
+
46017
+ /** Whether it changed since markSaved. */
46018
+ isDirty() {
46019
+ return this._version !== this._saved;
46020
+ }
46021
+
46022
+ /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
46023
+ onEdit(fn) {
46024
+ this._editListeners.push(fn);
46025
+ return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
46026
+ }
46027
+
46028
+ /** @private */
46029
+ _emitEdit() {
46030
+ for (const fn of this._editListeners) fn(this);
46031
+ }
46032
+
46033
+ /** An id made from `name`, unlike any of `taken`'s ids ('customer', 'customer-2'). */
46034
+ _freshId(name, taken) {
46035
+ const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
46036
+ let id = base;
46037
+ for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
46038
+ return id;
46039
+ }
46040
+
46041
+ /** The model as plain JSON, read back by its constructor. @abstract */
46042
+ toJSON() {
46043
+ throw new Error(`${this.constructor.name} must implement toJSON()`);
46044
+ }
45675
46045
  }
45676
46046
 
45677
46047
  /** Words that stay small inside a title. */
@@ -46325,8 +46695,42 @@
46325
46695
  }
46326
46696
  }
46327
46697
 
46698
+ /** A plain copy of JSON-shaped data. */
46699
+ const copy$2 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
46700
+
46701
+ /** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
46702
+ class ListCompared extends ComparedKind {
46703
+ constructor() {
46704
+ super();
46705
+ if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
46706
+ }
46707
+
46708
+ /** The array the model holds them in. @abstract */
46709
+ list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
46710
+
46711
+ items(model) { return this.list(model); }
46712
+ get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
46713
+
46714
+ /** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
46715
+ bring(model, item) {
46716
+ const list = this.list(model);
46717
+ const twin = this.copyOf(item);
46718
+ const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
46719
+ if (at < 0) {
46720
+ list.push(twin);
46721
+ return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
46722
+ }
46723
+ const old = list[at];
46724
+ list[at] = twin;
46725
+ return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
46726
+ }
46727
+
46728
+ /** A copy to put in the other model. */
46729
+ copyOf(item) { return copy$2(item); }
46730
+ }
46731
+
46328
46732
  /** A plain copy of JSON-shaped data. */
46329
- const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
46733
+ const copy$1 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
46330
46734
 
46331
46735
  /**
46332
46736
  * The kinds two DataModels are compared on and merged by (ModelComparison,
@@ -46336,37 +46740,6 @@
46336
46740
  * two models alike but for where their boxes are, are alike.
46337
46741
  */
46338
46742
 
46339
- /** A list of a model's held in an array (`list(model)`), each by an id; bring puts a copy in place or adds it. */
46340
- class ListCompared extends ComparedKind {
46341
- constructor() {
46342
- super();
46343
- if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
46344
- }
46345
-
46346
- /** The array the model holds them in. @abstract */
46347
- list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
46348
-
46349
- items(model) { return this.list(model); }
46350
- get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
46351
-
46352
- /** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
46353
- bring(model, item) {
46354
- const list = this.list(model);
46355
- const twin = this.copyOf(item);
46356
- const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
46357
- if (at < 0) {
46358
- list.push(twin);
46359
- return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
46360
- }
46361
- const old = list[at];
46362
- list[at] = twin;
46363
- return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
46364
- }
46365
-
46366
- /** A copy to put in the other model. */
46367
- copyOf(item) { return copy(item); }
46368
- }
46369
-
46370
46743
  /** One level's entities: compared on all but where they are drawn. */
46371
46744
  class LevelEntitiesCompared extends ListCompared {
46372
46745
  constructor(level) {
@@ -46380,7 +46753,7 @@
46380
46753
 
46381
46754
  content(entity) {
46382
46755
  const { x, y, ...rest } = entity;
46383
- return copy(rest);
46756
+ return copy$1(rest);
46384
46757
  }
46385
46758
  }
46386
46759
 
@@ -46399,7 +46772,7 @@
46399
46772
  list(model) { return model.level(this.level).relationships; }
46400
46773
  idOf(r) { return r.id ?? `${r.source}->${r.target}${r.name ? `:${r.name}` : ''}`; }
46401
46774
  nameOf(r) { return `${r.source} ${r.name || '→'} ${r.target}`; }
46402
- content(r) { return copy(r); }
46775
+ content(r) { return copy$1(r); }
46403
46776
  requires(r) { return [[`${this.level}-entity`, r.source], [`${this.level}-entity`, r.target]]; }
46404
46777
  }
46405
46778
 
@@ -46426,8 +46799,8 @@
46426
46799
 
46427
46800
  /** Perspectives and contexts are classes; their JSON is what counts, and a copy is one made from it. */
46428
46801
  class ModelPartsCompared extends ListCompared {
46429
- content(item) { return copy(item.toJSON()); }
46430
- copyOf(item) { return new item.constructor(copy(item.toJSON())); }
46802
+ content(item) { return copy$1(item.toJSON()); }
46803
+ copyOf(item) { return new item.constructor(copy$1(item.toJSON())); }
46431
46804
  }
46432
46805
 
46433
46806
  class ContextsCompared extends ModelPartsCompared {
@@ -46469,11 +46842,13 @@
46469
46842
  * (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
46470
46843
  * three levels mapped one to one.
46471
46844
  *
46472
- * It knows whether it changed since it was saved (`edited()`, `markSaved()`,
46473
- * `isDirty()`, `onEdit(fn)`), as a ViewSetModel does, so a part can show it,
46474
- * keep it and ask before it is lost.
46845
+ * What any model a part edits has — whether it changed since saved, its undo
46846
+ * history, compare and merge — is EditableModel's.
46475
46847
  */
46476
- class DataModel {
46848
+ class DataModel extends EditableModel {
46849
+ /** What a snapshot puts back (EditableModel#restore). */
46850
+ static STATE = Object.freeze(['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']);
46851
+
46477
46852
  /** The levels, outermost first. */
46478
46853
  static LEVELS = LEVELS;
46479
46854
 
@@ -46557,11 +46932,7 @@
46557
46932
  * @param {string} [spec.name] - What the model is called
46558
46933
  */
46559
46934
  constructor(spec = {}) {
46560
- /** What the model is called. */
46561
- this.name = spec.name ?? '';
46562
- this._version = 0;
46563
- this._saved = 0;
46564
- this._editListeners = [];
46935
+ super(spec);
46565
46936
  const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
46566
46937
  this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
46567
46938
  this.mappings = LEVELS.slice(1).map((to, i) => {
@@ -46588,6 +46959,14 @@
46588
46959
  return new ModelScorecard(this);
46589
46960
  }
46590
46961
 
46962
+ /** What it is measured by (Benchmark): its check's findings, and its Scorecard total. */
46963
+ benchmark() {
46964
+ return [
46965
+ ...Benchmark.findings(this),
46966
+ { key: 'scorecard', label: 'Scorecard', value: this.scored().total().score, better: 'higher', format: 'score' },
46967
+ ];
46968
+ }
46969
+
46591
46970
  /** Keep a reviewer's scores and the weights (ModelScorecard.specFrom's), as one undo step. */
46592
46971
  scorecardCommand(spec) {
46593
46972
  return this.editCommand('Score the model', (model) => {
@@ -46635,46 +47014,6 @@
46635
47014
  ];
46636
47015
  }
46637
47016
 
46638
- /** How this model differs from another (ModelComparison), which can merge the other's into it. */
46639
- compareWith(other) {
46640
- return new ModelComparison(this, other);
46641
- }
46642
-
46643
- /** Its undo history: a merge is one step on it. */
46644
- get history() {
46645
- return (this._history ??= new CommandHistory());
46646
- }
46647
-
46648
- // ===== Changed since saved =====
46649
-
46650
- /** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
46651
- edited() {
46652
- this._version++;
46653
- this._emitEdit();
46654
- }
46655
-
46656
- /** This state is the saved one (a file opened or saved). */
46657
- markSaved() {
46658
- this._saved = this._version;
46659
- this._emitEdit();
46660
- }
46661
-
46662
- /** Whether it changed since markSaved. */
46663
- isDirty() {
46664
- return this._version !== this._saved;
46665
- }
46666
-
46667
- /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
46668
- onEdit(fn) {
46669
- this._editListeners.push(fn);
46670
- return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
46671
- }
46672
-
46673
- /** @private */
46674
- _emitEdit() {
46675
- for (const fn of this._editListeners) fn(this);
46676
- }
46677
-
46678
47017
  /** A level's schema (LevelSchema). */
46679
47018
  level(name) {
46680
47019
  const schema = this.levels[name];
@@ -46890,13 +47229,7 @@
46890
47229
 
46891
47230
  // ===== Perspectives and contexts of one's own =====
46892
47231
 
46893
- // ===== Editing what a level holds (each a DataModelEditCommand: one undo step) =====
46894
-
46895
- /** Put the model back as a snapshot says (DataModelEditCommand): its levels, mappings, layout, perspectives, contexts, data types. */
46896
- restore(json) {
46897
- const back = new DataModel(JSON.parse(JSON.stringify(json)));
46898
- for (const key of ['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']) this[key] = back[key];
46899
- }
47232
+ // ===== Editing what a level holds (each an editCommand: one undo step) =====
46900
47233
 
46901
47234
  /** Set how its data is classified (a ClassificationScheme spec), as one undo step. */
46902
47235
  classificationSchemeCommand(spec) {
@@ -46912,11 +47245,6 @@
46912
47245
  });
46913
47246
  }
46914
47247
 
46915
- /** A change made as one undo step. */
46916
- editCommand(label, change) {
46917
- return new DataModelEditCommand(this, label, change);
46918
- }
46919
-
46920
47248
  /**
46921
47249
  * Add an entity at a level (`id` null: one made from its name) or change
46922
47250
  * one (what the spec leaves out, where it is drawn among it, kept).
@@ -47268,13 +47596,6 @@
47268
47596
  }
47269
47597
 
47270
47598
  /** An id from a name, not yet among `taken`. @private */
47271
- _freshId(name, taken) {
47272
- const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
47273
- let id = base;
47274
- for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
47275
- return id;
47276
- }
47277
-
47278
47599
  toJSON() {
47279
47600
  return {
47280
47601
  name: this.name,
@@ -48374,6 +48695,853 @@
48374
48695
  }
48375
48696
  }
48376
48697
 
48698
+ /**
48699
+ * The kinds two RequirementsModels are compared on and merged by
48700
+ * (ModelComparison): epics, releases, then stories, in the order a merge
48701
+ * brings them — a story needs its epic and its release.
48702
+ */
48703
+
48704
+ class EpicsCompared extends ListCompared {
48705
+ get key() { return 'epic'; }
48706
+ get label() { return 'Epics'; }
48707
+ list(model) { return model.epics; }
48708
+ content(epic) { return { ...epic }; }
48709
+ }
48710
+
48711
+ class ReleasesCompared extends ListCompared {
48712
+ get key() { return 'release'; }
48713
+ get label() { return 'Releases'; }
48714
+ list(model) { return model.releases; }
48715
+ content(release) { return { ...release }; }
48716
+ }
48717
+
48718
+ /** Stories: compared on everything, where they sit in the list aside. */
48719
+ class StoriesCompared extends ListCompared {
48720
+ get key() { return 'story'; }
48721
+ get label() { return 'Stories'; }
48722
+ list(model) { return model.stories; }
48723
+ content(story) { return JSON.parse(JSON.stringify(story)); }
48724
+
48725
+ requires(story) {
48726
+ return [...(story.epic ? [['epic', story.epic]] : []), ...(story.release ? [['release', story.release]] : [])];
48727
+ }
48728
+ }
48729
+
48730
+ /**
48731
+ * RequirementsChecker — what a requirements model is missing, as a team
48732
+ * reviewing a backlog would say it: a story with no acceptance criteria
48733
+ * (nothing to say when it is done), one that says neither who wants it nor
48734
+ * what (no "As a … I want …"), one that traces to nothing (nothing realises
48735
+ * it), one under no epic, an epic with no stories, two stories of one name.
48736
+ *
48737
+ * With `resolve(trace)` — the host's way to find what a trace names in its
48738
+ * other models (true, false, or undefined when it cannot tell) — a trace to
48739
+ * something that is not there is found too.
48740
+ *
48741
+ * Findings name a story or an epic (`kind`), as ModelChecker's do.
48742
+ */
48743
+ class RequirementsChecker extends ModelChecker {
48744
+ /**
48745
+ * @param {RequirementsModel} model
48746
+ * @param {{resolve?: (trace: Object) => (boolean|undefined)}} [options]
48747
+ */
48748
+ constructor(model, { resolve = null } = {}) {
48749
+ super(model);
48750
+ this.resolve = resolve;
48751
+ }
48752
+
48753
+ checks() {
48754
+ return [this.noCriteria, this.noNarrative, this.untraced, this.brokenTraces, this.noEpic, this.emptyEpics, this.sameNames];
48755
+ }
48756
+
48757
+ /** @private */
48758
+ _story(story, code, severity, message) {
48759
+ return { code, severity, kind: 'story', ids: [story.id], message };
48760
+ }
48761
+
48762
+ noCriteria() {
48763
+ return this.model.stories.filter(s => !s.criteria.length)
48764
+ .map(s => this._story(s, 'story-no-criteria', 'warning', `"${s.name}" has no acceptance criteria: nothing says when it is done`));
48765
+ }
48766
+
48767
+ noNarrative() {
48768
+ return this.model.stories.filter(s => !s.want && !s.description)
48769
+ .map(s => this._story(s, 'story-no-narrative', 'info', `"${s.name}" says neither who wants it nor what (As a … I want … so that …)`));
48770
+ }
48771
+
48772
+ untraced() {
48773
+ return this.model.stories.filter(s => !s.traces.length)
48774
+ .map(s => this._story(s, 'story-untraced', 'info', `"${s.name}" traces to nothing that realises it`));
48775
+ }
48776
+
48777
+ brokenTraces() {
48778
+ if (!this.resolve) return [];
48779
+ return this.model.stories.flatMap(s => s.traces.filter(t => this.resolve(t) === false)
48780
+ .map(t => this._story(s, 'trace-missing', 'warning', `"${s.name}" traces to ${t.kind} "${t.name || t.ref}", which is not there`)));
48781
+ }
48782
+
48783
+ noEpic() {
48784
+ if (!this.model.epics.length) return [];
48785
+ return this.model.stories.filter(s => !this.model.epic(s.epic))
48786
+ .map(s => this._story(s, 'story-no-epic', 'info', `"${s.name}" is under no epic`));
48787
+ }
48788
+
48789
+ emptyEpics() {
48790
+ return this.model.epics.filter(e => !this.model.storiesOf(e.id).length)
48791
+ .map(e => ({ code: 'epic-empty', severity: 'info', kind: 'epic', ids: [e.id], message: `Epic "${e.name}" has no stories` }));
48792
+ }
48793
+
48794
+ sameNames() {
48795
+ const byName = new Map();
48796
+ for (const s of this.model.stories) {
48797
+ const key = s.name.trim().toLowerCase();
48798
+ if (key) byName.set(key, [...(byName.get(key) ?? []), s]);
48799
+ }
48800
+ return [...byName.values()].filter(list => list.length > 1)
48801
+ .map(list => ({ code: 'story-duplicate', severity: 'warning', kind: 'story', ids: list.map(s => s.id), message: `${list.length} stories are called "${list[0].name}"` }));
48802
+ }
48803
+ }
48804
+
48805
+ /** A plain copy of JSON-shaped data. */
48806
+ const copy = (value) => JSON.parse(JSON.stringify(value));
48807
+
48808
+ /**
48809
+ * What a story can trace to, and what its trace is called: ArchiMate's
48810
+ * motivation and implementation elements, any other element of an
48811
+ * architecture, and a data model's concepts, entities and tables — so a trace
48812
+ * runs epic → story → requirement → component → entity → table. A trace's
48813
+ * `kind` is any string; these are the ones offered.
48814
+ */
48815
+ const TRACE_KINDS = Object.freeze([
48816
+ ['requirement', 'Requirement (ArchiMate)'],
48817
+ ['work-package', 'Work package (ArchiMate)'],
48818
+ ['element', 'Architecture element'],
48819
+ ['concept', 'Data concept'],
48820
+ ['entity', 'Data entity'],
48821
+ ['table', 'Table'],
48822
+ ]);
48823
+
48824
+ /** Where a story is in its life, in order; a status is any string, these are offered. */
48825
+ const STATUSES = Object.freeze(['To do', 'In progress', 'Done']);
48826
+
48827
+ /**
48828
+ * RequirementsModel — what a system is to do, as an agile team writes it:
48829
+ * **epics** (large outcomes), the **stories** that deliver them ("As a … I
48830
+ * want … so that …"), each with its **acceptance criteria**, sliced into
48831
+ * **releases**; and **traces** from a story to what realises it in the other
48832
+ * models (TRACE_KINDS: an ArchiMate requirement or work package, an element,
48833
+ * a data concept, entity or table — `{kind, ref, name?, model?}`, `ref` that
48834
+ * model's id).
48835
+ *
48836
+ * Read as a **story map** (`storyMap()`): epics across in order, releases
48837
+ * down, each cell the stories of that epic in that release; stories with no
48838
+ * epic or no release have a column and a row of their own. `coverage()` and
48839
+ * the checker (RequirementsChecker) say what is missing — a story with no
48840
+ * acceptance criteria, nothing realising it — the numbers a bench measures.
48841
+ *
48842
+ * Spec (its toJSON): `{ name?, epics: [{id, name, description?}], releases:
48843
+ * [{id, name}], stories: [{id, name, epic?, release?, as?, want?, soThat?,
48844
+ * description?, criteria: [{id, text}], status?, priority?, points?, labels?,
48845
+ * source?: {system, key, url?}, traces: [{kind, ref, name?, model?}]}] }`.
48846
+ * Ids are made from names when not given. Every edit is a command (one undo
48847
+ * step); compare and merge, and changed-since-saved, are EditableModel's.
48848
+ */
48849
+ class RequirementsModel extends EditableModel {
48850
+ static STATE = Object.freeze(['name', 'epics', 'releases', 'stories']);
48851
+
48852
+ static TRACE_KINDS = TRACE_KINDS;
48853
+
48854
+ static STATUSES = STATUSES;
48855
+
48856
+ /** A story map's column for stories with no epic, and its row for those in no release. */
48857
+ static NO_EPIC = '';
48858
+ static NO_RELEASE = '';
48859
+
48860
+ constructor(spec = {}) {
48861
+ super(spec);
48862
+ this.epics = [];
48863
+ this.releases = [];
48864
+ this.stories = [];
48865
+ for (const e of spec.epics ?? []) this.epics.push(this._epic(e));
48866
+ for (const r of spec.releases ?? []) this.releases.push(this._release(r));
48867
+ for (const s of spec.stories ?? []) this.stories.push(this._story(s));
48868
+ }
48869
+
48870
+ // ===== Reading =====
48871
+
48872
+ epic(id) { return this.epics.find(e => e.id === id) ?? null; }
48873
+ release(id) { return this.releases.find(r => r.id === id) ?? null; }
48874
+ story(id) { return this.stories.find(s => s.id === id) ?? null; }
48875
+
48876
+ /** The stories of an epic (NO_EPIC: of none), in order; with `release`, only those in it (NO_RELEASE: in none). */
48877
+ storiesOf(epicId, release) {
48878
+ return this.stories.filter(s => (s.epic || '') === (epicId || '')
48879
+ && (release === undefined || (s.release || '') === (release || '')));
48880
+ }
48881
+
48882
+ /** The story's sentence: "As a … I want … so that …", from what it has. */
48883
+ static narrative(story) {
48884
+ return [story.as && `As ${/^(a|an|the)\b/i.test(story.as) ? '' : 'a '}${story.as}`, story.want && `I want ${story.want}`, story.soThat && `so that ${story.soThat}`]
48885
+ .filter(Boolean).join(', ');
48886
+ }
48887
+
48888
+ /**
48889
+ * The story map: `columns` the epics in order (then NO_EPIC, if any story
48890
+ * has none), `rows` the releases in order (then NO_RELEASE, if any story is
48891
+ * in none, or there are no releases), and `cell(epicId, releaseId)` the
48892
+ * stories there, in order.
48893
+ */
48894
+ storyMap() {
48895
+ const columns = [...this.epics.map(e => ({ id: e.id, name: e.name, epic: e }))];
48896
+ if (this.stories.some(s => !this.epic(s.epic))) columns.push({ id: RequirementsModel.NO_EPIC, name: 'No epic', epic: null });
48897
+ const rows = this.releases.map(r => ({ id: r.id, name: r.name, release: r }));
48898
+ if (!rows.length || this.stories.some(s => !this.release(s.release))) rows.push({ id: RequirementsModel.NO_RELEASE, name: this.releases.length ? 'Not planned' : 'Stories', release: null });
48899
+ const epicOf = (s) => (this.epic(s.epic) ? s.epic : RequirementsModel.NO_EPIC);
48900
+ const releaseOf = (s) => (this.release(s.release) ? s.release : RequirementsModel.NO_RELEASE);
48901
+ return {
48902
+ columns, rows,
48903
+ cell: (epicId, releaseId) => this.stories.filter(s => epicOf(s) === epicId && releaseOf(s) === releaseId),
48904
+ };
48905
+ }
48906
+
48907
+ /** The stories that trace to `ref` (with `kind`, only traces of that kind): what a change to it touches. */
48908
+ tracingTo(ref, kind) {
48909
+ return this.stories.filter(s => s.traces.some(t => t.ref === ref && (!kind || t.kind === kind)));
48910
+ }
48911
+
48912
+ /**
48913
+ * How complete it is, as numbers to measure by: stories, how many have
48914
+ * acceptance criteria, how many trace to something, epics with no stories,
48915
+ * and each as a share (0..1; 1 with nothing to count).
48916
+ */
48917
+ coverage() {
48918
+ const stories = this.stories.length;
48919
+ const withCriteria = this.stories.filter(s => s.criteria.length).length;
48920
+ const traced = this.stories.filter(s => s.traces.length).length;
48921
+ const emptyEpics = this.epics.filter(e => !this.storiesOf(e.id).length).length;
48922
+ const share = (n, of) => (of ? n / of : 1);
48923
+ return {
48924
+ stories, withCriteria, traced, epics: this.epics.length, emptyEpics,
48925
+ criteriaShare: share(withCriteria, stories), traceShare: share(traced, stories),
48926
+ };
48927
+ }
48928
+
48929
+ /** What it is measured by (Benchmark): its check's findings, and how many stories say when they are done and trace to something. */
48930
+ benchmark() {
48931
+ const c = this.coverage();
48932
+ return [
48933
+ ...Benchmark.findings(this),
48934
+ { key: 'criteria', label: 'Stories with acceptance criteria', value: c.criteriaShare, better: 'higher', format: 'share' },
48935
+ { key: 'traced', label: 'Stories traced', value: c.traceShare, better: 'higher', format: 'share' },
48936
+ ];
48937
+ }
48938
+
48939
+ /** Its checks (RequirementsChecker). */
48940
+ checker(options) {
48941
+ return new RequirementsChecker(this, options);
48942
+ }
48943
+
48944
+ comparedKinds() {
48945
+ return [new EpicsCompared(), new ReleasesCompared(), new StoriesCompared()];
48946
+ }
48947
+
48948
+ // ===== Editing (each an editCommand: one undo step) =====
48949
+
48950
+ /** Add an epic (`id` null) or change one. The command's result is the epic. */
48951
+ saveEpicCommand(id, spec) {
48952
+ return this.editCommand(id ? `Edit epic ${spec.name ?? id}` : `New epic ${spec.name ?? ''}`.trim(), (model) => {
48953
+ if (!id) { const epic = model._epic(spec); model.epics.push(epic); return epic; }
48954
+ const epic = model.epic(id);
48955
+ if (epic) Object.assign(epic, model._epic({ ...epic, ...spec, id }));
48956
+ return epic;
48957
+ });
48958
+ }
48959
+
48960
+ /** Remove an epic; its stories stay, with no epic. */
48961
+ removeEpicCommand(id) {
48962
+ return this.editCommand(`Remove epic ${this.epic(id)?.name ?? id}`, (model) => {
48963
+ model.epics = model.epics.filter(e => e.id !== id);
48964
+ for (const s of model.stories) if (s.epic === id) s.epic = '';
48965
+ });
48966
+ }
48967
+
48968
+ /** Add a release (`id` null) or rename one. */
48969
+ saveReleaseCommand(id, spec) {
48970
+ return this.editCommand(id ? `Edit release ${spec.name ?? id}` : `New release ${spec.name ?? ''}`.trim(), (model) => {
48971
+ if (!id) { const release = model._release(spec); model.releases.push(release); return release; }
48972
+ const release = model.release(id);
48973
+ if (release) Object.assign(release, model._release({ ...release, ...spec, id }));
48974
+ return release;
48975
+ });
48976
+ }
48977
+
48978
+ /** Remove a release; its stories stay, planned in none. */
48979
+ removeReleaseCommand(id) {
48980
+ return this.editCommand(`Remove release ${this.release(id)?.name ?? id}`, (model) => {
48981
+ model.releases = model.releases.filter(r => r.id !== id);
48982
+ for (const s of model.stories) if (s.release === id) s.release = '';
48983
+ });
48984
+ }
48985
+
48986
+ /** Add a story (`id` null) or change one — what the spec leaves out kept. The command's result is the story. */
48987
+ saveStoryCommand(id, spec) {
48988
+ return this.editCommand(id ? `Edit ${spec.name ?? this.story(id)?.name ?? id}` : `New story ${spec.name ?? ''}`.trim(), (model) => {
48989
+ if (!id) { const story = model._story(spec); model.stories.push(story); return story; }
48990
+ const at = model.stories.findIndex(s => s.id === id);
48991
+ if (at < 0) return null;
48992
+ model.stories[at] = model._story({ ...model.stories[at], ...spec, id });
48993
+ return model.stories[at];
48994
+ });
48995
+ }
48996
+
48997
+ removeStoryCommand(id) {
48998
+ return this.editCommand(`Remove ${this.story(id)?.name ?? id}`, (model) => {
48999
+ model.stories = model.stories.filter(s => s.id !== id);
49000
+ });
49001
+ }
49002
+
49003
+ /**
49004
+ * Move a story on the map: to an epic and a release (NO_EPIC, NO_RELEASE:
49005
+ * none), before another story there (`before`; none: last).
49006
+ */
49007
+ moveStoryCommand(id, { epic, release, before = null }) {
49008
+ return this.editCommand(`Move ${this.story(id)?.name ?? id}`, (model) => {
49009
+ const story = model.story(id);
49010
+ if (!story || before === id) return;
49011
+ if (epic !== undefined) story.epic = epic || '';
49012
+ if (release !== undefined) story.release = release || '';
49013
+ model.stories.splice(model.stories.indexOf(story), 1);
49014
+ const at = before ? model.stories.findIndex(s => s.id === before) : -1;
49015
+ model.stories.splice(at < 0 ? model.stories.length : at, 0, story);
49016
+ });
49017
+ }
49018
+
49019
+ /** Trace a story to something that realises it (`{kind, ref, name?, model?}`); once per kind and ref. */
49020
+ traceCommand(storyId, trace) {
49021
+ return this.editCommand(`Trace ${this.story(storyId)?.name ?? storyId} to ${trace.name || trace.ref}`, (model) => {
49022
+ const story = model.story(storyId);
49023
+ if (!story || story.traces.some(t => t.kind === trace.kind && t.ref === trace.ref)) return;
49024
+ story.traces.push(RequirementsModel._trace(trace));
49025
+ });
49026
+ }
49027
+
49028
+ /** Remove a story's trace to `ref` (of `kind`, if given). */
49029
+ untraceCommand(storyId, ref, kind) {
49030
+ return this.editCommand(`Remove the trace of ${this.story(storyId)?.name ?? storyId}`, (model) => {
49031
+ const story = model.story(storyId);
49032
+ if (story) story.traces = story.traces.filter(t => !(t.ref === ref && (!kind || t.kind === kind)));
49033
+ });
49034
+ }
49035
+
49036
+ /**
49037
+ * Bring epics, releases and stories in (an import): one undo step. One with
49038
+ * the id of one there replaces it; the rest are added.
49039
+ */
49040
+ bringCommand({ epics = [], releases = [], stories = [] }, label = 'Import') {
49041
+ return this.editCommand(label, (model) => {
49042
+ const put = (list, item) => {
49043
+ const at = list.findIndex(x => x.id === item.id);
49044
+ if (at < 0) list.push(item); else list[at] = item;
49045
+ };
49046
+ for (const e of epics) put(model.epics, model._epic(e));
49047
+ for (const r of releases) put(model.releases, model._release(r));
49048
+ for (const s of stories) put(model.stories, model._story(s));
49049
+ });
49050
+ }
49051
+
49052
+ // ===== Its parts, read from a spec =====
49053
+
49054
+ /** @private */
49055
+ _epic({ id, name = '', description = '', source } = {}) {
49056
+ return {
49057
+ id: id ?? this._freshId(name || 'epic', this.epics), name, description,
49058
+ ...(source && { source: copy(source) }),
49059
+ };
49060
+ }
49061
+
49062
+ /** @private */
49063
+ _release({ id, name = '' } = {}) {
49064
+ return { id: id ?? this._freshId(name || 'release', this.releases), name };
49065
+ }
49066
+
49067
+ /** @private */
49068
+ _story(spec = {}) {
49069
+ const { id, name = '', epic = '', release = '', as = '', want = '', soThat = '', description = '', status = '', priority = '', points = null, labels = [], source, criteria = [], traces = [] } = spec;
49070
+ const story = {
49071
+ id: id ?? this._freshId(name || 'story', this.stories), name, epic: epic ?? '', release: release ?? '',
49072
+ as, want, soThat, description, status, priority,
49073
+ points: Number.isFinite(Number(points)) && points !== null && points !== '' ? Number(points) : null,
49074
+ labels: [...labels],
49075
+ criteria: [],
49076
+ traces: traces.map(t => RequirementsModel._trace(t)),
49077
+ ...(source && { source: copy(source) }),
49078
+ };
49079
+ // A new criterion is numbered past every id the story's criteria have, so none is given twice.
49080
+ const given = new Set(criteria.map(c => (typeof c === 'object' && c?.id) || null).filter(Boolean));
49081
+ let n = 0;
49082
+ const fresh = () => { let id; do id = `${story.id}-ac${++n}`; while (given.has(id)); given.add(id); return id; };
49083
+ for (const c of criteria) {
49084
+ const text = typeof c === 'string' ? c : c?.text;
49085
+ if (!String(text ?? '').trim()) continue;
49086
+ story.criteria.push({ id: (typeof c === 'object' && c.id) || fresh(), text: String(text).trim() });
49087
+ }
49088
+ return story;
49089
+ }
49090
+
49091
+ /** @private */
49092
+ static _trace({ kind = 'element', ref, name = '', model = '' } = {}) {
49093
+ return { kind, ref: String(ref ?? ''), ...(name && { name }), ...(model && { model }) };
49094
+ }
49095
+
49096
+ toJSON() {
49097
+ return {
49098
+ name: this.name,
49099
+ epics: copy(this.epics),
49100
+ releases: copy(this.releases),
49101
+ stories: copy(this.stories),
49102
+ };
49103
+ }
49104
+ }
49105
+
49106
+ /**
49107
+ * RequirementsFileFormat — a requirements model as a file
49108
+ * (`.requirements.json`): its epics, releases and stories
49109
+ * (RequirementsModel#toJSON), read back by `new RequirementsModel(json)`.
49110
+ */
49111
+ class RequirementsFileFormat extends ModelFileFormat {
49112
+ get label() { return '.requirements.json'; }
49113
+ get accept() { return '.requirements.json,.json'; }
49114
+ get fileName() { return 'model.requirements.json'; }
49115
+ get featureName() { return 'Requirements'; }
49116
+
49117
+ /** JSON with a requirements model's shape: stories, or epics. */
49118
+ recognizes(text) {
49119
+ try {
49120
+ const json = JSON.parse(text);
49121
+ return !!json && typeof json === 'object' && (Array.isArray(json.stories) || Array.isArray(json.epics));
49122
+ } catch {
49123
+ return false;
49124
+ }
49125
+ }
49126
+
49127
+ export(model) {
49128
+ return { data: JSON.stringify(model.toJSON(), null, 2), mimeType: 'application/json' };
49129
+ }
49130
+
49131
+ import(text) {
49132
+ return new RequirementsModel(JSON.parse(text));
49133
+ }
49134
+ }
49135
+
49136
+ /**
49137
+ * Where each tracker's export keeps what a story needs: per tracker, the
49138
+ * column names (CSV) each field may be under, first found wins; which issue
49139
+ * types are epics; how its list fields are separated. One definition per
49140
+ * tracker — the CSV and the JSON readers both map onto these fields. A host
49141
+ * gives its own (a custom field's column) through `new TrackerExport({ columns })`.
49142
+ */
49143
+ const TRACKERS = Object.freeze({
49144
+ jira: {
49145
+ label: 'Jira',
49146
+ // Columns only its exports have, to tell it by.
49147
+ marks: ['Issue key', 'Issue Type'],
49148
+ columns: {
49149
+ key: ['Issue key', 'Key'], id: ['Issue id'], type: ['Issue Type'], title: ['Summary'],
49150
+ description: ['Description'], parent: ['Parent', 'Parent id', 'Parent key', 'Custom field (Epic Link)', 'Epic Link'],
49151
+ status: ['Status'], priority: ['Priority'],
49152
+ points: ['Custom field (Story Points)', 'Story Points', 'Custom field (Story point estimate)', 'Story point estimate'],
49153
+ release: ['Fix Version/s', 'Fix versions', 'Sprint'], labels: ['Labels'],
49154
+ criteria: ['Custom field (Acceptance Criteria)', 'Acceptance Criteria'], url: [],
49155
+ },
49156
+ epicTypes: ['Epic'], skipTypes: ['Sub-task', 'Subtask'], labelSeparator: /[\s,]+/,
49157
+ },
49158
+ azure: {
49159
+ label: 'Azure DevOps',
49160
+ marks: ['Work Item Type'],
49161
+ columns: {
49162
+ key: ['ID'], id: ['ID'], type: ['Work Item Type'], title: ['Title', 'Title 1', 'Title 2', 'Title 3'],
49163
+ description: ['Description'], parent: ['Parent'], status: ['State'], priority: ['Priority'],
49164
+ points: ['Story Points', 'Effort', 'Size'], release: ['Iteration Path'], labels: ['Tags'],
49165
+ criteria: ['Acceptance Criteria'], url: [],
49166
+ },
49167
+ epicTypes: ['Epic', 'Feature'], skipTypes: ['Task'], labelSeparator: /\s*;\s*/,
49168
+ },
49169
+ gitlab: {
49170
+ label: 'GitLab',
49171
+ marks: ['Issue ID', 'URL'],
49172
+ columns: {
49173
+ key: ['Issue ID', 'IID'], id: ['Issue ID', 'IID'], type: [], title: ['Title'], description: ['Description'],
49174
+ parent: ['Epic ID'], parentName: ['Epic Title'], status: ['State'], priority: [], points: ['Weight'],
49175
+ release: ['Milestone'], labels: ['Labels'], criteria: [], url: ['URL'],
49176
+ },
49177
+ epicTypes: [], skipTypes: [], labelSeparator: /\s*,\s*/,
49178
+ },
49179
+ });
49180
+
49181
+ /**
49182
+ * Where Jira's JSON keeps what has no field of its own: custom fields, by key.
49183
+ * Jira Cloud's "Story point estimate" is customfield_10016 unless a site
49184
+ * changed it; a host names its own (`columns: { points: ['customfield_10026'] }`).
49185
+ */
49186
+ const JIRA_FIELDS = Object.freeze({
49187
+ points: ['story_points', 'storyPoints', 'customfield_10016'],
49188
+ criteria: ['acceptanceCriteria'],
49189
+ });
49190
+
49191
+ /** Text with HTML in it (Azure DevOps keeps descriptions as HTML) as lines of plain text. */
49192
+ function plainText(value) {
49193
+ if (value == null) return '';
49194
+ if (typeof value === 'object') return documentText(value);
49195
+ return String(value)
49196
+ .replace(/<\s*(br|\/p|\/div|\/li|\/h\d)\s*\/?>/gi, '\n').replace(/<\s*li[^>]*>/gi, '- ')
49197
+ .replace(/<[^>]+>/g, '')
49198
+ .replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
49199
+ .replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
49200
+ }
49201
+
49202
+ /** Jira Cloud's description, a document of nodes, as text: each paragraph and list item a line. */
49203
+ function documentText(node) {
49204
+ if (!node || typeof node !== 'object') return '';
49205
+ if (node.type === 'text') return node.text ?? '';
49206
+ const inner = (node.content ?? []).map(documentText).join('');
49207
+ return ['paragraph', 'heading', 'listItem', 'codeBlock'].includes(node.type) ? `${node.type === 'listItem' ? '- ' : ''}${inner.trim()}\n` : inner;
49208
+ }
49209
+
49210
+ /** The lines of a list: "- x", "* x", "1. x", "• x", "[ ] x" — each item a line, its marker gone. */
49211
+ function listItems(text) {
49212
+ return String(text ?? '').split('\n')
49213
+ .map(line => line.replace(/^\s*(?:[-*•+]|\d+[.)]|\[[ xX]?\])\s+/, '').trim())
49214
+ .filter(Boolean);
49215
+ }
49216
+
49217
+ /**
49218
+ * Acceptance criteria in a story's text: the lines under an "Acceptance
49219
+ * criteria" heading, else its Given/When/Then scenarios (one each), else none.
49220
+ * @returns {{criteria: Array<string>, rest: string}} - and the text without them
49221
+ */
49222
+ function criteriaFrom(text) {
49223
+ const s = plainText(text);
49224
+ const heading = /^\s*(?:#+\s*|\*\*)?acceptance criteria\b[^\n]*\n/im.exec(s);
49225
+ if (heading) {
49226
+ const after = s.slice(heading.index + heading[0].length);
49227
+ const end = after.search(/\n\s*\n\s*(?:#+\s|\*\*|[A-Z][^\n:]{0,40}:\s*\n)/);
49228
+ const block = end < 0 ? after : after.slice(0, end);
49229
+ return { criteria: listItems(block), rest: (s.slice(0, heading.index) + (end < 0 ? '' : after.slice(end))).replace(/\n{3,}/g, '\n\n').trim() };
49230
+ }
49231
+ const scenarios = [];
49232
+ let current = null;
49233
+ for (const line of s.split('\n')) {
49234
+ const t = line.replace(/^\s*(?:[-*•]\s+)?/, '').trim();
49235
+ if (/^given\b/i.test(t)) { current = [t]; scenarios.push(current); } else if (current && /^(when|then|and|but)\b/i.test(t)) current.push(t); else current = null;
49236
+ }
49237
+ if (scenarios.length) return { criteria: scenarios.map(lines => lines.join(' ')), rest: s.split('\n').filter(l => !/^\s*(?:[-*•]\s+)?(given|when|then|and|but)\b/i.test(l)).join('\n').trim() };
49238
+ return { criteria: [], rest: s };
49239
+ }
49240
+
49241
+ /** A field that holds only acceptance criteria: as criteriaFrom reads them, else each of its lines one. */
49242
+ function criteriaField(text) {
49243
+ const found = criteriaFrom(text).criteria;
49244
+ return found.length ? found : listItems(plainText(text));
49245
+ }
49246
+
49247
+ /** "As a <who>, I want <what> so that <why>" in a text: its three parts (empty where it says nothing). */
49248
+ function narrativeFrom(text) {
49249
+ const m = /\bas an?\s+([^,\n]+?)\s*,?\s+i\s+(?:want|need|would like)\s+([^\n]+?)(?:\s*,?\s+so\s+that\s+([^\n]+?))?\s*(?:[.\n]|$)/i.exec(plainText(text));
49250
+ return m ? { as: m[1].trim(), want: m[2].trim(), soThat: (m[3] ?? '').trim() } : { as: '', want: '', soThat: '' };
49251
+ }
49252
+
49253
+ /** A text without its "As a … I want …" line: what is left to describe the story, once its sentence is read (narrativeFrom). */
49254
+ function withoutNarrative(text) {
49255
+ return String(text ?? '').replace(/^\s*\**\s*as an?\s[^\n]*\bi\s+(want|need|would like)\b[^\n]*\n?/im, '').trim();
49256
+ }
49257
+
49258
+ /**
49259
+ * TrackerExport — epics and stories from what an issue tracker exports: Jira,
49260
+ * Azure DevOps or GitLab (TRACKERS), as CSV or as their API's JSON. An issue
49261
+ * of an epic type becomes an epic (GitLab's epics are named by their issues'
49262
+ * Epic ID and title); the rest become stories under their parent epic, with
49263
+ * their status, priority, points, labels and release (fix version, iteration
49264
+ * or milestone). Acceptance criteria come from their own field, else from
49265
+ * the description (criteriaFrom); "As a … I want … so that …" from the
49266
+ * description or the title (narrativeFrom). Each keeps where it came from
49267
+ * (`source: {system, key, url?}`), and its id is the tracker's key, so
49268
+ * reading a newer export over a model replaces what it had.
49269
+ *
49270
+ * `read(text)` gives a RequirementsModel spec (`{epics, releases, stories}`).
49271
+ */
49272
+ class TrackerExport {
49273
+ /**
49274
+ * @param {Object} [options]
49275
+ * @param {string} [options.tracker] - 'jira', 'azure', 'gitlab' (default: told from the export)
49276
+ * @param {Object<string, Array<string>>} [options.columns] - Column names to look in first, by field (in Jira's JSON, field keys)
49277
+ */
49278
+ constructor({ tracker = null, columns = {} } = {}) {
49279
+ this.tracker = tracker;
49280
+ this.columns = columns;
49281
+ }
49282
+
49283
+ /** What a tracker is called ('Jira'). */
49284
+ static label(tracker) {
49285
+ return TRACKERS[tracker]?.label ?? 'Tracker';
49286
+ }
49287
+
49288
+ /** Which tracker an export's columns (or JSON) are from, or null. */
49289
+ static detect(text) {
49290
+ const json = TrackerExport._json(text);
49291
+ if (json) {
49292
+ if (Array.isArray(json.issues) && json.issues[0]?.fields) return 'jira';
49293
+ if (Array.isArray(json.value ?? json.workItems) && (json.value ?? json.workItems)[0]?.fields) return 'azure';
49294
+ if (Array.isArray(json) && json[0] && 'iid' in json[0]) return 'gitlab';
49295
+ return null;
49296
+ }
49297
+ const { columns } = parseCsvTable(text);
49298
+ const has = new Set(columns.map(c => c.toLowerCase()));
49299
+ const found = Object.entries(TRACKERS).find(([, t]) => t.marks.every(m => has.has(m.toLowerCase())));
49300
+ return found?.[0] ?? null;
49301
+ }
49302
+
49303
+ /** @private */
49304
+ static _json(text) {
49305
+ const s = String(text ?? '').trim();
49306
+ if (!s.startsWith('{') && !s.startsWith('[')) return null;
49307
+ try { return JSON.parse(s); } catch { return null; }
49308
+ }
49309
+
49310
+ /**
49311
+ * The export as a requirements model's spec.
49312
+ * @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
49313
+ */
49314
+ read(text) {
49315
+ const tracker = this.tracker ?? TrackerExport.detect(text) ?? 'jira';
49316
+ const def = TRACKERS[tracker];
49317
+ if (!def) throw new Error(`Unknown tracker: "${tracker}"`);
49318
+ const json = TrackerExport._json(text);
49319
+ const issues = json ? this._fromJson(tracker, json) : this._fromCsv(def, text);
49320
+ return { ...this._model(tracker, def, issues), tracker };
49321
+ }
49322
+
49323
+ /** CSV rows as issues: each field from the first of its columns there. @private */
49324
+ _fromCsv(def, text) {
49325
+ const { columns, rows } = parseCsvTable(text);
49326
+ const lower = new Map(columns.map(c => [c.toLowerCase(), c]));
49327
+ const columnOf = (field) => [...(this.columns[field] ?? []), ...(def.columns[field] ?? [])].map(c => lower.get(c.toLowerCase())).find(Boolean);
49328
+ const fields = Object.keys({ ...def.columns, ...this.columns, parentName: [] });
49329
+ const at = Object.fromEntries(fields.map(f => [f, columnOf(f)]));
49330
+ return rows.map(row => Object.fromEntries(fields.map(f => [f, at[f] ? row[at[f]] : ''])));
49331
+ }
49332
+
49333
+ /** An API's JSON as issues, in the CSV's fields. @private */
49334
+ _fromJson(tracker, json) {
49335
+ if (tracker === 'jira') {
49336
+ return (json.issues ?? []).map(({ key, id, fields: f = {} }) => {
49337
+ const custom = (names) => Object.entries(f).find(([k]) => names.some(n => k.toLowerCase().includes(n)))?.[1];
49338
+ // A field the host named (its custom field's key), else Jira Cloud's usual one.
49339
+ const field = (name) => [...(this.columns[name] ?? []), ...(JIRA_FIELDS[name] ?? [])].map(k => f[k]).find(v => v != null && v !== '');
49340
+ return {
49341
+ key, id: String(id ?? ''), type: f.issuetype?.name ?? '', title: f.summary ?? '', description: f.description ?? '',
49342
+ parent: f.parent?.key ?? f.parent?.id ?? '', status: f.status?.name ?? '', priority: f.priority?.name ?? '',
49343
+ points: field('points') ?? custom(['storypoint', 'story_point']) ?? '',
49344
+ release: (f.fixVersions ?? [])[0]?.name ?? '', labels: (f.labels ?? []).join(' '),
49345
+ criteria: field('criteria') ?? custom(['acceptance']) ?? '', url: '',
49346
+ };
49347
+ });
49348
+ }
49349
+ if (tracker === 'azure') {
49350
+ return (json.value ?? json.workItems ?? []).map(({ id, fields: f = {}, url }) => ({
49351
+ key: String(id), id: String(id), type: f['System.WorkItemType'] ?? '', title: f['System.Title'] ?? '',
49352
+ description: f['System.Description'] ?? '', parent: String(f['System.Parent'] ?? ''), status: f['System.State'] ?? '',
49353
+ priority: String(f['Microsoft.VSTS.Common.Priority'] ?? ''),
49354
+ points: f['Microsoft.VSTS.Scheduling.StoryPoints'] ?? f['Microsoft.VSTS.Scheduling.Effort'] ?? '',
49355
+ release: f['System.IterationPath'] ?? '', labels: f['System.Tags'] ?? '',
49356
+ criteria: f['Microsoft.VSTS.Common.AcceptanceCriteria'] ?? '', url: url ?? '',
49357
+ }));
49358
+ }
49359
+ return (Array.isArray(json) ? json : []).map(i => ({
49360
+ key: String(i.iid ?? i.id), id: String(i.iid ?? i.id), type: '', title: i.title ?? '', description: i.description ?? '',
49361
+ parent: String(i.epic?.id ?? i.epic_iid ?? ''), parentName: i.epic?.title ?? '', status: i.state ?? '', priority: '',
49362
+ points: i.weight ?? '', release: i.milestone?.title ?? '', labels: (i.labels ?? []).map(l => l.name ?? l).join(','),
49363
+ criteria: '', url: i.web_url ?? '',
49364
+ }));
49365
+ }
49366
+
49367
+ /** Issues as epics, releases and stories. @private */
49368
+ _model(tracker, def, issues) {
49369
+ const is = (list, type) => list.some(t => t.toLowerCase() === String(type ?? '').trim().toLowerCase());
49370
+ const keyOf = new Map(); // an issue's id or key → its key (Jira's CSV names a parent by its id)
49371
+ for (const i of issues) for (const k of [i.key, i.id]) if (k) keyOf.set(String(k), String(i.key || i.id));
49372
+ const source = (i, key) => ({ system: tracker, key, ...(i.url && { url: i.url }) });
49373
+
49374
+ const epics = new Map();
49375
+ for (const i of issues) {
49376
+ if (!is(def.epicTypes, i.type)) continue;
49377
+ const key = String(i.key || i.id);
49378
+ epics.set(key, { id: key, name: plainText(i.title), description: plainText(i.description), source: source(i, key) });
49379
+ }
49380
+ const releases = new Map();
49381
+ const stories = [];
49382
+ for (const i of issues) {
49383
+ if (is(def.epicTypes, i.type) || is(def.skipTypes, i.type) || !String(i.title ?? '').trim()) continue;
49384
+ const key = String(i.key || i.id);
49385
+ const parent = i.parent ? keyOf.get(String(i.parent)) ?? String(i.parent) : '';
49386
+ if (parent && !epics.has(parent) && i.parentName) epics.set(parent, { id: parent, name: plainText(i.parentName), description: '', source: { system: tracker, key: parent } });
49387
+ const release = plainText(i.release);
49388
+ if (release && !releases.has(release)) releases.set(release, { id: release, name: release.split(/[\\/]/).pop() });
49389
+ const own = criteriaField(i.criteria);
49390
+ const inText = own.length ? { criteria: [], rest: plainText(i.description) } : criteriaFrom(i.description);
49391
+ const said = narrativeFrom(i.description);
49392
+ const { as, want, soThat } = said.want ? said : narrativeFrom(i.title);
49393
+ stories.push({
49394
+ id: key, name: plainText(i.title), epic: parent, release,
49395
+ as, want, soThat, description: said.want ? withoutNarrative(inText.rest) : inText.rest,
49396
+ criteria: own.length ? own : inText.criteria,
49397
+ status: plainText(i.status), priority: plainText(i.priority), points: i.points === '' ? null : i.points,
49398
+ labels: String(i.labels ?? '').split(def.labelSeparator).map(l => l.trim()).filter(Boolean),
49399
+ source: source(i, key), traces: [],
49400
+ });
49401
+ }
49402
+ // A story whose parent is no epic here (a story's sub-item, an epic not exported) is under none.
49403
+ for (const s of stories) if (s.epic && !epics.has(s.epic)) s.epic = '';
49404
+ return { epics: [...epics.values()], releases: [...releases.values()], stories };
49405
+ }
49406
+ }
49407
+
49408
+ /**
49409
+ * Storyboard — epics and stories from a storyboard or requirements document
49410
+ * written in Markdown (or plain text with Markdown headings), as teams keep
49411
+ * them beside or before a tracker:
49412
+ *
49413
+ * - a heading that says "Epic" (`## Epic: Checkout`) is an epic, one that says
49414
+ * "Story" (`### Story: Pay by card`) a story; otherwise the document's one
49415
+ * title (`#`) is its name, the next level down are epics and the level
49416
+ * below that stories;
49417
+ * - under an epic, a list item that reads "As a … I want …" is a story too;
49418
+ * - under a story, "As a … I want … so that …" is its narrative, the list
49419
+ * under "Acceptance criteria" (or its Given/When/Then lines) its criteria,
49420
+ * and the rest its description — a picture (`![caption](file)`) kept there
49421
+ * by its caption.
49422
+ *
49423
+ * `read(text)` gives a RequirementsModel spec (`{name, epics, stories}`); ids
49424
+ * are made from names (prefix: `prefix`, so two storyboards read into one
49425
+ * model stay apart).
49426
+ */
49427
+ class Storyboard {
49428
+ /** @param {{prefix?: string}} [options] */
49429
+ constructor({ prefix = '' } = {}) {
49430
+ this.prefix = prefix;
49431
+ }
49432
+
49433
+ read(text) {
49434
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
49435
+ const headings = lines.map((line, at) => ({ at, m: /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line) })).filter(h => h.m)
49436
+ .map(({ at, m }) => ({ at, level: m[1].length, text: m[2].trim() }));
49437
+ const kindOf = (h) => (/^epic\b/i.test(h.text) ? 'epic' : /^(user\s+)?story\b/i.test(h.text) || /\bas an?\s+\S/i.test(h.text) ? 'story' : null);
49438
+ const top = Math.min(...headings.map(h => h.level));
49439
+ const tops = headings.filter(h => h.level === top);
49440
+ // One top heading, a title (#), names the document; its children are the epics.
49441
+ const named = top === 1 && tops.length === 1 && !kindOf(tops[0]) && headings.length > 1;
49442
+ const epicLevel = named ? top + 1 : top;
49443
+ const strip = (t) => t.replace(/^(epic|(user\s+)?story)\s*[:\-–—]?\s*/i, '').trim();
49444
+
49445
+ const epics = [], stories = [];
49446
+ const ids = new Set();
49447
+ const idFor = (name, kind) => {
49448
+ const base = `${this.prefix}${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || kind}`;
49449
+ let id = base;
49450
+ for (let n = 2; ids.has(id); n++) id = `${base}-${n}`;
49451
+ ids.add(id);
49452
+ return id;
49453
+ };
49454
+ const bodyOf = (h, i) => lines.slice(h.at + 1, headings[i + 1]?.at ?? lines.length).join('\n');
49455
+ let epic = null;
49456
+ headings.forEach((h, i) => {
49457
+ if (named && h === tops[0]) return;
49458
+ const kind = kindOf(h) ?? (h.level === epicLevel ? 'epic' : h.level === epicLevel + 1 ? 'story' : null);
49459
+ const body = bodyOf(h, i);
49460
+ if (kind === 'epic') {
49461
+ epic = { id: idFor(strip(h.text), 'epic'), name: strip(h.text), description: '' };
49462
+ epics.push(epic);
49463
+ const listed = this._listedStories(body);
49464
+ epic.description = listed.rest;
49465
+ for (const s of listed.stories) stories.push({ ...s, id: idFor(s.name, 'story'), epic: epic.id });
49466
+ } else if (kind === 'story') {
49467
+ stories.push({ ...this._story(strip(h.text), body), id: idFor(strip(h.text), 'story'), epic: epic?.id ?? '' });
49468
+ }
49469
+ });
49470
+ if (!headings.length || (!epics.length && !stories.length)) {
49471
+ for (const s of this._listedStories(lines.join('\n')).stories) stories.push({ ...s, id: idFor(s.name, 'story') });
49472
+ }
49473
+ return { name: named ? tops[0].text : '', epics, stories };
49474
+ }
49475
+
49476
+ /** A story from its heading and what is under it. @private */
49477
+ _story(name, body) {
49478
+ const pictures = [...body.matchAll(/!\[([^\]]*)\]\([^)]*\)/g)].map(m => m[1]).filter(Boolean);
49479
+ const text = body.replace(/!\[([^\]]*)\]\([^)]*\)/g, '').trim();
49480
+ const said = narrativeFrom(text);
49481
+ const { as, want, soThat } = said.want ? said : narrativeFrom(name);
49482
+ const { criteria, rest } = criteriaFrom(text);
49483
+ const description = [withoutNarrative(rest), ...pictures.map(c => `Picture: ${c}`)].filter(Boolean).join('\n');
49484
+ return { name, as, want, soThat, description, criteria };
49485
+ }
49486
+
49487
+ /** "As a … I want …" list items: stories; the rest of the text, kept. @private */
49488
+ _listedStories(body) {
49489
+ const stories = [];
49490
+ const rest = [];
49491
+ for (const line of body.split('\n')) {
49492
+ const item = /^\s*(?:[-*•+]|\d+[.)])\s+(.*)$/.exec(line);
49493
+ const said = item && narrativeFrom(item[1]);
49494
+ if (said?.want) stories.push({ name: said.want.replace(/^to\s+/i, '').replace(/^./, c => c.toUpperCase()), ...said, description: '', criteria: [] });
49495
+ else rest.push(line);
49496
+ }
49497
+ return { stories, rest: rest.join('\n').trim() };
49498
+ }
49499
+ }
49500
+
49501
+ /**
49502
+ * The files a requirements model is read from that another tool wrote — they
49503
+ * import only: a tracker's export (TrackerExport: Jira, Azure DevOps,
49504
+ * GitLab, CSV or JSON) and a storyboard (Storyboard: Markdown). Each reads
49505
+ * the file as a new model; `spec(text)` is what it holds, for a part that
49506
+ * brings it into the model open (RequirementsModel#bringCommand).
49507
+ */
49508
+ class RequirementsImportFormat extends ModelFileFormat {
49509
+ get exports() { return false; }
49510
+
49511
+ /** What the file holds, as a RequirementsModel spec. @abstract */
49512
+ spec(text) { throw new Error(`${this.constructor.name} must implement spec()`); }
49513
+
49514
+ import(text) {
49515
+ return new RequirementsModel(this.spec(text));
49516
+ }
49517
+ }
49518
+
49519
+ class TrackerExportFileFormat extends RequirementsImportFormat {
49520
+ get label() { return 'Tracker export (Jira, Azure DevOps, GitLab)'; }
49521
+ get accept() { return '.csv,.json'; }
49522
+ get featureName() { return 'TrackerExport'; }
49523
+
49524
+ /** A CSV or JSON export one of the trackers wrote. */
49525
+ recognizes(text) {
49526
+ return !!TrackerExport.detect(text);
49527
+ }
49528
+
49529
+ spec(text) {
49530
+ const { tracker, ...spec } = new TrackerExport().read(text);
49531
+ return { name: `${TrackerExport.label(tracker)} export`, ...spec };
49532
+ }
49533
+ }
49534
+
49535
+ class StoryboardFileFormat extends RequirementsImportFormat {
49536
+ get label() { return 'Storyboard (Markdown)'; }
49537
+ get accept() { return '.md,.markdown,.txt'; }
49538
+ get featureName() { return 'Storyboard'; }
49539
+
49540
+ spec(text) {
49541
+ return new Storyboard().read(text);
49542
+ }
49543
+ }
49544
+
48377
49545
  /**
48378
49546
  * propertiesDialog — the "Show/Edit Properties" dialog, as a shipped feature.
48379
49547
  *
@@ -51504,7 +52672,9 @@
51504
52672
  /** Open what `choose()` read: the part edits its model, from that file. Returns the model. */
51505
52673
  adopt({ model, name, format, handle = null }) {
51506
52674
  this.part.replaceModel(model);
51507
- Object.assign(this, { name, format, handle });
52675
+ // Read from another tool's export (a format that only reads): a new model, saved in the part's own format.
52676
+ if (format && format.exports === false) Object.assign(this, { name: null, format: null, handle: null });
52677
+ else Object.assign(this, { name, format, handle });
51508
52678
  this.showState();
51509
52679
  return model;
51510
52680
  }
@@ -52288,7 +53458,7 @@ th { color: #555; font-weight: 500; }
52288
53458
  ...EditorPart.menuGroup('New', some(...this.newItems(), models['New model'], ...(this.modelsHost?.baseItems?.() ?? []))),
52289
53459
  { separator: true },
52290
53460
  ...some(open, models['Open… in a new tab'], ...saving, models['Duplicate model'], models['Close model']),
52291
- ...EditorPart.menuGroup('This browser', this.modelsHost?.keepItems?.(this) ?? []),
53461
+ ...EditorPart.menuGroup(this.modelsHost?.keptWhere ?? 'This browser', this.modelsHost?.keepItems?.(this) ?? []),
52292
53462
  { separator: true },
52293
53463
  ...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
52294
53464
  ...EditorPart.menuGroup('Export', this.exportItems(), { prefix: true }),
@@ -52444,723 +53614,1280 @@ th { color: #555; font-weight: 500; }
52444
53614
  }
52445
53615
  }
52446
53616
 
52447
- /**
52448
- * DataModelPart — a data model (DataModel, drawn by dataModelDiagram) in a
52449
- * pane: its conceptual, logical and physical levels, each its own schema, and
52450
- * how they map; seen through a perspective (whose concepts, under whose names)
52451
- * read in a context (what they mean here, the rules that hold, where and
52452
- * when), in a notation (Chen, Crow's Foot, UML). Its toolbar switches each
52453
- * of them, Level offering the views that are no level (DataModel.OTHER_VIEWS:
52454
- * Mapping, the levels side by side joined where they map; Service landscape,
52455
- * the service domains in their business areas and domains, a double-click
52456
- * seeing the model through one), and its Model menu makes, changes and removes
52457
- * perspectives and contexts and reads service domains from a BIAN landscape. Boxes and notes stay where they were dragged. Export ▸ saves the
52458
- * picture (PNG, SVG). The drawing, its sizing and redrawing on resize are
52459
- * ChartPart's.
52460
- *
52461
- * It is saved as a file (DataModelFileFormat, `.datamodel.json`) as an
52462
- * editor's model is (ModelFile): File ▸ Open…, Save, Save As… (Ctrl+O, Ctrl+S),
52463
- * • while changed since, and asking before the page is left with changes
52464
- * unsaved. Hosted by a ModelsPart (`editorType: 'data-model'`) it is one of
52465
- * several models open, a tab each, and with that part's `keep` it is kept in
52466
- * this browser and backed up (File ▸ This browser), as the modeller's are.
52467
- *
52468
- * Spec: `{ id, type: 'data-model', title?, levels?, mappings?, mappingLayout?,
52469
- * perspectives?, contexts?, perspective?, context?, level?, notation?, options? }`
52470
- * — the model as DataModel takes it (or `entities`/`relationships` in the older
52471
- * one-entity form); `perspective`/`context` the ids chosen; `level` default
52472
- * 'logical' ('mapping' for the mapping view), `notation` default 'crowsfoot';
52473
- * `options` anything else dataModelDiagram takes (editable, resizable by default).
52474
- */
52475
- class DataModelPart extends ChartPart {
52476
- static type = 'data-model';
52477
-
52478
- constructor(spec = {}) {
52479
- const { perspective = null, context = null, level = 'logical', notation = 'crowsfoot', options = {} } = spec;
52480
- super({ ...spec, chartType: 'dataModel', options: { editable: true, resizable: true, ...options, level, notation } });
52481
- /** The file it is open from and saved to. */
52482
- this.file = new ModelFile(this);
52483
- /** Its id among the models kept in this browser, or null. */
52484
- this.keepId = spec.keepId ?? null;
52485
- this._adoptModel(new DataModel(spec));
52486
- /** The ids chosen, or null. */
52487
- this.perspectiveId = perspective;
52488
- this.contextId = context ?? this.model.perspective(perspective)?.context ?? null;
52489
- /** The level the drawing on screen is of (where a drag is kept). */
52490
- this._drawnLevel = level;
52491
- }
52492
-
52493
- // ===== A model saved as a file, kept, backed up =====
52494
-
52495
- /** Its file formats: the data model's own. */
52496
- fileFormats() {
52497
- return [DataModelFileFormat.instance ??= new DataModelFileFormat()];
52498
- }
52499
-
52500
- saveFormat() {
52501
- return this.fileFormats()[0];
52502
- }
52503
-
52504
- /** The ModelsPart it is a tab of, or null. */
52505
- get modelsHost() {
52506
- return this.ctx?.models ?? null;
52507
- }
52508
-
52509
- /** The page it is in. */
52510
- get document() {
52511
- return this.container?.ownerDocument ?? globalThis.document;
52512
- }
52513
-
52514
- /** What its tab and the page title show: the file's name (or the model's), and • while changed. */
52515
- get fileLabel() {
52516
- return this.file.label;
52517
- }
52518
-
52519
- /** The model as kept in this browser (ModelFile#keepEntry). */
52520
- keepEntry() {
52521
- return this.file.keepEntry();
52522
- }
52523
-
52524
- /** Edit another model (one opened, imported or kept), from scratch: nothing chosen, nothing changed. */
52525
- replaceModel(model) {
52526
- this._adoptModel(model);
52527
- this.perspectiveId = null;
52528
- this.contextId = null;
52529
- if (!this.mounted) return;
52530
- // Not rendered again (that would add a second toolbar and canvas): the toolbar
52531
- // shows the new model's choices, and it is drawn. What was on screen is the
52532
- // other model's, so where its boxes were is not kept into this one.
52533
- this._drawnLevel = null;
52534
- this._changed();
52535
- this._redraw();
52536
- }
52537
-
52538
- /** A model opened from a file in a host's new tab (as EditorPart does). */
52539
- onImported(model) {
52540
- return EditorPart.prototype.onImported.call(this, model);
52541
- }
52542
-
52543
- /** The file or its saved state changed: a ModelsPart's tab follows it. */
52544
- onFileState() {
52545
- this.modelsHost?.fileStateChanged?.(this);
52546
- }
52547
-
52548
- /** Edit `model`: every change of it shows (• and the title) and is kept. @private */
52549
- _adoptModel(model) {
52550
- this._unsubscribeEdits?.();
52551
- this.model = model;
52552
- model.markSaved();
52553
- this._unsubscribeEdits = model.onEdit(() => {
52554
- this.file.showState();
52555
- this.modelsHost?.modelEdited?.(this);
52556
- });
52557
- }
52558
-
52559
- mount(host, ctx) {
52560
- const first = !this.mounted;
52561
- super.mount(host, ctx);
52562
- if (first) {
52563
- this.file.listen();
52564
- this.file.showState();
52565
- }
52566
- }
52567
-
52568
- /**
52569
- * File ▸: Open…, Save, Save As…, then with a ModelsPart New model and Close
52570
- * model, and This browser ▸ (keeping and backups).
52571
- */
52572
- fileItems() {
52573
- const host = this.modelsHost;
52574
- return EditorPart.tidyMenu([
52575
- ...this.file.menuItems(),
52576
- ...(host ? [
52577
- { separator: true },
52578
- ...EditorPart.menuGroup('New', [{ label: 'New model', action: () => host.newModel() }, ...(host.baseItems?.() ?? [])]),
52579
- { label: 'Duplicate model', action: () => host.duplicate(this) },
52580
- { label: 'Close model', action: () => host.closeModel(this.id) },
52581
- ...EditorPart.menuGroup('This browser', host.keepItems?.(this) ?? []),
52582
- ] : []),
52583
- ]);
52584
- }
52585
-
52586
- /** Edit ▸: undo and redo (a merge is one step). */
52587
- editItems() {
52588
- const history = this.model.history;
52589
- return [
52590
- { label: history.canUndo ? `Undo ${history.undoLabel}` : 'Undo', disabled: !history.canUndo || undefined, action: () => { history.undo(); this._redraw(); } },
52591
- { label: history.canRedo ? `Redo ${history.redoLabel}` : 'Redo', disabled: !history.canRedo || undefined, action: () => { history.redo(); this._redraw(); } },
52592
- ];
52593
- }
52594
-
52595
- /** Run a model-wide edit (a merge) as one undo step, then redraw. */
52596
- runModelEdit(command) {
52597
- if (!command) return null;
52598
- this.model.history.execute(command);
52599
- this._changed();
52600
- this._redraw();
52601
- return command;
52602
- }
52603
-
52604
- /** The entities at the level shown (the concepts in the mapping view). */
52605
- get entities() {
52606
- return this.model.level(DataModel.otherView(this.level) ? 'conceptual' : this.level).entities;
52607
- }
52608
- get level() { return this.chartOptions.level; }
52609
- get notation() { return this.chartOptions.notation; }
52610
- /** The perspective chosen (DataModelPerspective), or null. */
52611
- get perspective() { return this.model.perspective(this.perspectiveId); }
52612
- /** The context chosen (DataModelContext), or null. */
52613
- get context() { return this.model.context(this.contextId); }
52614
-
52615
- /** Show another level ('conceptual', 'logical', 'physical'), or how they map ('mapping'). */
52616
- setLevel(level) {
52617
- this.chartOptions.level = level;
52618
- this._redraw();
52619
- }
52620
-
52621
- /** Draw the model in another notation ('chen', 'crowsfoot', 'uml'). */
52622
- setNotation(notation) {
52623
- this.chartOptions.notation = notation;
52624
- this._redraw();
52625
- }
52626
-
52627
- /** See the model through a perspective (an id; null: the whole schema), in the context it is read in, if it names one. */
52628
- setPerspective(id) {
52629
- this.perspectiveId = id || null;
52630
- const own = this.perspective?.context;
52631
- if (own) this.contextId = own;
52632
- this._redraw();
52633
- }
52634
-
52635
- /** Read the model in a context (an id; null: the schema's own descriptions). */
52636
- setContext(id) {
52637
- this.contextId = id || null;
52638
- this._redraw();
52639
- }
52640
-
52641
- /** What is drawn: the perspective's view of the level (or the mapping) in the context (DataModel#view), in the notation. */
52642
- drawOptions() {
52643
- return {
52644
- ...this.chartOptions,
52645
- ...this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: this.level }),
52646
- ...(this.level === DataModel.LANDSCAPE && { onNodeDoubleClick: (node) => this.openServiceDomain(ServiceLandscape.serviceDomainOf(node.id)) }),
52647
- ...(this.editsLevel && this.editingOptions()),
52648
- ...(this.level === DataModel.MAPPING && this.lineageOptions()),
52649
- };
52650
- }
52651
-
52652
- /**
52653
- * Show an entity's lineage: the mapping view, what it realises and what
52654
- * realises it (DataModel#lineage) as they are, the rest greyed.
52655
- */
52656
- showLineage(level, id) {
52657
- this.lineageFocus = { level, id };
52658
- this.setLevel(DataModel.MAPPING);
52659
- }
52660
-
52661
- /**
52662
- * In the mapping view: an entity's lineage picked out (`lineageFocus`), and
52663
- * its menus to show another's or show all again.
52664
- */
52665
- lineageOptions() {
52666
- const view = {};
52667
- const focus = this.lineageFocus;
52668
- if (focus) {
52669
- const { up, down } = this.model.lineage(focus.level, focus.id);
52670
- const on = new Set([`${focus.level}:${focus.id}`, ...[...up, ...down].map(({ level, entity }) => `${level}:${entity.id}`)]);
52671
- const drawn = this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: DataModel.MAPPING });
52672
- view.entities = drawn.entities.map(e => (on.has(e.id) ? e : { ...e, color: '#f3f4f6' }));
52673
- view.links = drawn.links.map(l => (on.has(l.source) && on.has(l.target) ? l : { ...l, color: '#e5e7eb' }));
52674
- }
52675
- const split = (nodeId) => { const i = String(nodeId).indexOf(':'); return [nodeId.slice(0, i), nodeId.slice(i + 1)]; };
52676
- return {
52677
- ...view,
52678
- contextMenuItems: [{ label: 'Show lineage', action: (node) => { const [level, id] = split(node.id); this.showLineage(level, id); } }],
52679
- canvasContextMenuItems: [{ label: 'Show all', action: () => { this.lineageFocus = null; this._redraw(); } }],
52680
- };
52681
- }
52682
-
52683
- /** Whether what is shown is a level of the schema (so its entities and relationships can be edited here). */
52684
- get editsLevel() {
52685
- return !DataModel.otherView(this.level);
52686
- }
52687
-
52688
- /**
52689
- * At a level of the schema: double-click an entity or a relationship to
52690
- * edit it; right-click one to edit or delete it, or the canvas for a new
52691
- * entity there or a new relationship. (These stand in for graphDiagram's
52692
- * own, which change only the drawing.)
52693
- */
52694
- editingOptions() {
52695
- const relationshipOf = (drawn) => this.model.relationshipIndex(this.level, drawn);
52696
- const isEntity = (node) => !!this.model.level(this.level).entity(node.id);
52697
- return {
52698
- onNodeDoubleClick: (node) => (isEntity(node) ? this.editEntity(node.id) : this.editRelationship(relationshipOf(node))),
52699
- onEdgeDoubleClick: (edge) => this.editRelationship(relationshipOf(edge)),
52700
- contextMenuItems: [
52701
- { label: 'Edit…', action: (node) => (isEntity(node) ? this.editEntity(node.id) : this.editRelationship(relationshipOf(node))) },
52702
- { label: 'Delete', action: (node) => (isEntity(node) ? this.removeEntity(node.id) : this.removeRelationship(relationshipOf(node))) },
52703
- { label: 'Show lineage', action: (node) => { if (isEntity(node)) this.showLineage(this.level, node.id); } },
52704
- ],
52705
- edgeContextMenuItems: [
52706
- { label: 'Edit relationship…', action: (edge) => this.editRelationship(relationshipOf(edge)) },
52707
- { label: 'Delete relationship', action: (edge) => this.removeRelationship(relationshipOf(edge)) },
52708
- ],
52709
- canvasContextMenuItems: [
52710
- { label: `New ${this.levelInfo.entityNoun.toLowerCase()} here…`, action: (subject, engine, point) => this.editEntity(null, point) },
52711
- { label: 'New relationship…', action: () => this.editRelationship(null) },
52712
- ],
52713
- };
52714
- }
52715
-
52716
- /** The level shown, as DataModelNotation has it (its nouns, its dialogs' fields). */
52717
- get levelInfo() {
52718
- return DataModelNotation.level(this.level);
52719
- }
52720
-
52721
- /**
52722
- * An entity's dialog at the level shown (none: a new one, placed at
52723
- * `point` or below the others): its level's fields (name, definition, and
52724
- * at the logical and physical levels its attributes as a table). Saved, it
52725
- * is one undo step.
52726
- */
52727
- editEntity(id, point = null) {
52728
- if (!this.editsLevel) return null;
52729
- const info = this.levelInfo;
52730
- const schema = this.model.level(this.level);
52731
- const entity = id ? schema.entity(id) : null;
52732
- if (id && !entity) return null;
52733
- const scheme = this.model.classificationScheme;
52734
- const context = { typeNames: this.model.dataTypes.map(t => t.name), sensitivities: scheme.levels.map(l => l.name), categories: scheme.categories };
52735
- const title = entity ? `${info.entityNoun}: ${entity.name}` : `New ${info.entityNoun.toLowerCase()}`;
52736
- return this._dialog(title, info.entityFields(entity, context), (values) => {
52737
- const spec = info.entitySpecFrom(values, entity);
52738
- if (!entity) Object.assign(spec, point ?? this._freeSpot(schema));
52739
- this.runModelEdit(this.model.saveEntityCommand(this.level, id, spec));
52740
- });
52741
- }
52742
-
52743
- /** Where a new entity goes with no point given: below what is there. @private */
52744
- _freeSpot(schema) {
52745
- const ys = schema.entities.map(e => e.y).filter(Number.isFinite);
52746
- return { x: 160, y: (ys.length ? Math.max(...ys) : 0) + 160 };
52747
- }
52748
-
52749
- removeEntity(id) {
52750
- if (!this.editsLevel || !this.model.level(this.level).entity(id)) return null;
52751
- return this.runModelEdit(this.model.removeEntityCommand(this.level, id));
52752
- }
52753
-
52754
- /** A relationship's dialog at the level shown (`index` null or -1: a new one); saved, one undo step. */
52755
- editRelationship(index) {
52756
- if (!this.editsLevel) return null;
52757
- const info = this.levelInfo;
52758
- const schema = this.model.level(this.level);
52759
- const relationship = index == null || index < 0 ? null : schema.relationships[index];
52760
- if (!schema.entities.length) return null;
52761
- const title = relationship ? `Relationship: ${relationship.name || `${relationship.source} – ${relationship.target}`}` : 'New relationship';
52762
- return this._dialog(title, info.relationshipFields(schema.entities, relationship), (values) => {
52763
- this.runModelEdit(this.model.saveRelationshipCommand(this.level, relationship ? index : null, info.relationshipSpecFrom(values, relationship)));
52764
- }, ({ source, target }) => (source && target ? null : 'Choose both ends'));
52765
- }
52766
-
52767
- removeRelationship(index) {
52768
- if (!this.editsLevel || index == null || index < 0) return null;
52769
- return this.runModelEdit(this.model.removeRelationshipCommand(this.level, index));
52770
- }
52771
-
52772
- /** See the model through a service domain (an id), at the logical level, where its business objects are. */
52773
- openServiceDomain(id) {
52774
- if (!this.model.perspective(id)) return;
52775
- this.chartOptions.level = 'logical';
52776
- this.setPerspective(id);
52777
- }
52778
-
52779
- render() {
52780
- Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
52781
- const toolbar = this.container.appendChild(el({}));
52782
- super.render();
52783
- Object.assign(this.canvasHost.style, { flex: '1 1 auto', minHeight: '0', height: 'auto' });
52784
- this.menuBar = new MenuBar({ container: toolbar, items: this.menuBarItems(), document: this.container.ownerDocument }).render();
52785
- }
52786
-
52787
- /**
52788
- * Model ▸ (perspectives and contexts of one's own), Perspective and Context
52789
- * (when the model has any; the context's scope beside them), Level (each
52790
- * level from DataModelNotation, then Mapping) and Notation, and Export ▸.
52791
- */
52792
- menuBarItems() {
52793
- const { perspectives, contexts } = this.model;
52794
- const host = this.modelsHost;
52795
- return [
52796
- new ToolbarMenu('File', () => this.fileItems()),
52797
- new ToolbarMenu('Edit', () => this.editItems()),
52798
- ...(host ? [new ToolbarMenu('Tools', () => host.toolItems(this))] : []),
52799
- new ToolbarMenu('Model', () => this.modelItems()),
52800
- ...(perspectives.length ? [new ToolbarSelect('Perspective',
52801
- () => [['', 'Whole schema'], ...this.model.perspectives.map(p => [p.id, p.constructor === DataModelPerspective ? p.name : `${p.name} (${p.constructor.kindLabel.toLowerCase()})`, p.description])],
52802
- () => this.perspectiveId ?? '', (v) => this.setPerspective(v))] : []),
52803
- ...(contexts.length ? [
52804
- new ToolbarSelect('Context',
52805
- () => [['', 'None'], ...this.model.contexts.map(c => [c.id, c.name, c.description])],
52806
- () => this.contextId ?? '', (v) => this.setContext(v)),
52807
- new ToolbarText(() => this.context?.scope ?? ''),
52808
- ] : []),
52809
- new ToolbarSelect('Level', () => [
52810
- ...DataModelNotation.levels().map(l => [l.name, l.label, l.description]),
52811
- ...DataModel.OTHER_VIEWS.filter(v => v.offered(this.model) || v.name === this.level).map(v => [v.name, v.label, v.description]),
52812
- ], () => this.level, (v) => this.setLevel(v)),
52813
- new ToolbarSelect('Notation', () => DataModelNotation.notations().map(n => [n.name, n.label]), () => this.notation, (v) => this.setNotation(v)),
52814
- new ToolbarMenu('Export', () => this.exportItems()),
52815
- ];
52816
- }
52817
-
52818
- /** Model ▸: make, change or remove a perspective or a context (the one chosen). */
52819
- modelItems() {
52820
- return [
52821
- { label: `New ${this.editsLevel ? this.levelInfo.entityNoun.toLowerCase() : 'entity'}…`, disabled: !this.editsLevel || undefined, action: () => this.editEntity(null) },
52822
- { label: 'New relationship…', disabled: !this.editsLevel || !this.model.level(this.level).entities.length || undefined, action: () => this.editRelationship(null) },
52823
- { separator: true },
52824
- { label: 'New perspective…', action: () => this.editPerspective(null) },
52825
- { label: 'New service domain…', action: () => this.editPerspective(null, ServiceDomain) },
52826
- { label: 'New classification lens…', action: () => this.editPerspective(null, ClassificationLens) },
52827
- { label: 'Read service domains from a BIAN landscape…', action: () => this.readLandscape() },
52828
- { label: 'Read a BIAN service domain (semantic API)…', action: () => this.readSemanticApi() },
52829
- { label: 'Edit perspective…', disabled: !this.perspective || undefined, action: () => this.editPerspective(this.perspective) },
52830
- { label: 'Delete perspective', disabled: !this.perspective || undefined, action: () => this.removePerspective(this.perspectiveId) },
52831
- { separator: true },
52832
- ...EditorPart.menuGroup('Data types', this.dataTypeItems()),
52833
- { separator: true },
52834
- { label: 'New context…', action: () => this.editContext(null) },
52835
- { label: 'Edit context…', disabled: !this.context || undefined, action: () => this.editContext(this.context) },
52836
- { label: 'Delete context', disabled: !this.context || undefined, action: () => this.removeContext(this.contextId) },
52837
- { separator: true },
52838
- { label: 'Check model…', action: () => this.checkModel() },
52839
- { label: 'Scorecard…', action: () => this.scoreModel() },
52840
- ...EditorPart.menuGroup('Naming standard', this.namingItems()),
52841
- { label: 'Classification scheme…', action: () => this.editClassificationScheme() },
52842
- ];
52843
- }
52844
-
52845
- /**
52846
- * Service domains from BIAN's published service landscape (an ArchiMate
52847
- * model: an Archi file or the Exchange Format; BianLandscapeReader), as one
52848
- * undo step (DataModel#serviceDomainsCommand); then the landscape is shown.
52849
- * @param {Object} [options] - As readInto's, and `reader` (a BianLandscapeReader)
52850
- * @returns {Promise<Command|null>}
52851
- */
52852
- readLandscape({ reader = new BianLandscapeReader(), ...options } = {}) {
52853
- let found = 0;
52854
- return this.readInto({
52855
- ...options, accept: '.archimate,.xml', what: 'the landscape',
52856
- command: (bytes, name) => {
52857
- const specs = reader.read(BianLandscapeReader.parse(bytes));
52858
- found = specs.length;
52859
- return this.model.serviceDomainsCommand(specs, `"${name}"`);
52860
- },
52861
- nothing: (name) => (found
52862
- ? `"${name}" brings nothing new: its ${found} service domain${found === 1 ? ' is' : 's are'} here as ${found === 1 ? 'it is' : 'they are'}.`
52863
- : `"${name}" has no service domains (Capabilities with none inside them).`),
52864
- then: () => { this.chartOptions.level = DataModel.LANDSCAPE; this.perspectiveId = null; },
52865
- });
52866
- }
52867
-
52868
- /**
52869
- * A service domain from one of BIAN's semantic APIs (an OpenAPI file, YAML
52870
- * or JSON; BianSemanticApiReader): its control record and behaviour
52871
- * qualifiers as logical entities, the schemas they use as data types, and
52872
- * the service domain, as one undo step (DataModel#bringCommand); then the
52873
- * model is seen through it at the logical level. An entity the model has
52874
- * by name stays as it is and is used.
52875
- * @param {Object} [options] - As readInto's, and `depth` (BianSemanticApiReader's)
52876
- * @returns {Promise<Command|null>}
52877
- */
52878
- readSemanticApi({ depth = 1, ...options } = {}) {
52879
- let read = null;
52880
- return this.readInto({
52881
- ...options, accept: '.yaml,.yml,.json', what: 'the semantic API',
52882
- command: (bytes, name) => {
52883
- const reader = new BianSemanticApiReader({ depth, entityNames: this.model.level('logical').entities.map(e => e.name) });
52884
- read = reader.read(BianSemanticApiReader.parse(new TextDecoder().decode(bytes)));
52885
- const [sd] = read.serviceDomains;
52886
- return this.model.bringCommand(read, `Read ${sd.name} from "${name}"`);
52887
- },
52888
- nothing: (name) => `"${name}" brings nothing the model does not have.`,
52889
- then: () => {
52890
- // The service domain is the one here of its id or name (bringCommand keeps it), else the new one.
52891
- const [sd] = read.serviceDomains;
52892
- const here = this.model.perspective(sd.id) ?? this.model.perspectives.find(p => p.name.toLowerCase() === sd.name.toLowerCase());
52893
- this.chartOptions.level = 'logical';
52894
- this.perspectiveId = here?.id ?? sd.id;
52895
- },
52896
- });
52897
- }
52898
-
52899
- /**
52900
- * Ask for a file, make a command from its bytes, and run it as one undo
52901
- * step; a file that cannot be read, or brings nothing, is reported.
52902
- * @param {Object} how
52903
- * @param {string} how.accept - The file chooser's filter
52904
- * @param {string} how.what - What is read, for the report ('the landscape')
52905
- * @param {(bytes: Uint8Array, name: string) => Command|null} how.command
52906
- * @param {(name: string) => string} how.nothing - The report when the command is null
52907
- * @param {() => void} [how.then] - What to show once it is run (before the redraw)
52908
- * @param {(accept: string) => Promise<File|null>} [how.chooseFile]
52909
- * @param {(message: string) => void} [how.report]
52910
- * @returns {Promise<Command|null>}
52911
- */
52912
- async readInto({ accept, what, command: make, nothing, then = () => {}, chooseFile = null, report = null }) {
52913
- const doc = this.container?.ownerDocument ?? globalThis.document;
52914
- const tell = report ?? ((message) => (doc.defaultView ?? globalThis).alert(message));
52915
- const file = await (chooseFile ?? ((filter) => pickFile(filter, doc)))(accept);
52916
- if (!file) return null;
52917
- let command;
52918
- try {
52919
- command = make(new Uint8Array(await file.arrayBuffer()), file.name);
52920
- } catch (err) {
52921
- tell(`Could not read ${what}: ${err.message}`);
52922
- return null;
52923
- }
52924
- if (!command) {
52925
- tell(nothing(file.name));
52926
- return null;
52927
- }
52928
- then();
52929
- this.runModelEdit(command);
52930
- return command;
52931
- }
52932
-
52933
- /**
52934
- * The Data Model Scorecard (ModelScorecard): each category as the model
52935
- * measures it, a reviewer's score and note, its weight; the total in the
52936
- * title. Saved, the reviews and weights are kept (one undo step).
52937
- */
52938
- scoreModel() {
52939
- const card = this.model.scored();
52940
- const results = card.results();
52941
- const { score, coverage } = card.total(results);
52942
- const title = score == null ? 'Scorecard: nothing scored yet' : `Scorecard: ${score} of 100 (${coverage}% of the weight scored)`;
52943
- return this._dialog(title, card.fields(results), (values) => {
52944
- this.runModelEdit(this.model.scorecardCommand(ModelScorecard.specFrom(values)));
52945
- }, () => null);
52946
- }
52947
-
52948
- /** The classification scheme's dialog (its sensitivity levels in order, its categories); saved, one undo step. */
52949
- editClassificationScheme() {
52950
- return this._dialog('Classification scheme', ClassificationScheme.fields(this.model.classificationScheme), (values) => {
52951
- this.runModelEdit(this.model.classificationSchemeCommand(ClassificationScheme.specFrom(values)));
52952
- }, (values) => (ClassificationScheme.specFrom(values).levels.length ? null : 'Give it at least one level'));
52953
- }
52954
-
52955
- /** Model ▸ Naming standard ▸: edit it, start from the conventional one, or remove it (Check model… checks names against it). */
52956
- namingItems() {
52957
- const has = !!this.model.namingStandard;
52958
- return [
52959
- { label: has ? 'Edit…' : 'Set…', action: () => this.editNamingStandard() },
52960
- ...(has ? [] : [{ label: 'Use the conventional one', action: () => this.runModelEdit(this.model.namingStandardCommand(NamingStandard.CONVENTIONAL)) }]),
52961
- ...(has ? [{ label: 'Remove', action: () => this.runModelEdit(this.model.namingStandardCommand(null)) }] : []),
52962
- ];
52963
- }
52964
-
52965
- /**
52966
- * The naming standard's dialog (NamingStandard.fields: a rule per level for
52967
- * entities and attributes); saved, one undo step. A standard that asks
52968
- * nothing is none.
52969
- */
52970
- editNamingStandard() {
52971
- return this._dialog('Naming standard', NamingStandard.fields(this.model.namingStandard), (values) => {
52972
- const spec = NamingStandard.specFrom(values);
52973
- const asks = Object.values(spec).some(kinds => Object.values(kinds).some(r => r.style || r.classWords.length || r.maxLength || Object.keys(r.abbreviations).length));
52974
- this.runModelEdit(this.model.namingStandardCommand(asks ? spec : null));
52975
- }, () => null);
52976
- }
52977
-
52978
- /** Model ▸ Data types ▸: a new one of each kind, then each to change or remove. */
52979
- dataTypeItems() {
52980
- const types = this.model.dataTypes;
52981
- return [
52982
- ...[EnumerationType, StructuredType, DataType].map(Kind => ({ label: `New ${Kind.kindLabel.toLowerCase()}…`, action: () => this.editDataType(null, Kind) })),
52983
- ...(types.length ? [
52984
- { separator: true },
52985
- { label: 'Show data types', action: () => this.setLevel(DataModel.DATA_TYPES) },
52986
- { label: 'Edit', items: types.map(t => ({ label: `${t.name} (${t.constructor.kindLabel.toLowerCase()})…`, action: () => this.editDataType(t) })) },
52987
- { label: 'Delete', items: types.map(t => ({ label: t.name, action: () => this.removeDataType(t.id) })) },
52988
- ] : []),
52989
- ];
52990
- }
52991
-
52992
- /** A dialog making a data type of a kind (none given) or changing one (its kind's fields); applied, the data types are shown. */
52993
- editDataType(type, Kind = type?.constructor ?? DataType) {
52994
- const title = type ? `${Kind.kindLabel}: ${type.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
52995
- return this._dialog(title, Kind.fields(this.model, type), (values) => {
52996
- const spec = Kind.specFrom(values, this.model, type);
52997
- if (type) this.model.updateDataType(type.id, spec); else this.model.addDataType(spec);
52998
- this.chartOptions.level = DataModel.DATA_TYPES;
52999
- this._changed();
53000
- this._redraw();
53001
- });
53002
- }
53003
-
53004
- removeDataType(id) {
53005
- this.model.removeDataType(id);
53006
- if (this.level === DataModel.DATA_TYPES && !this.model.dataTypes.length) this.chartOptions.level = 'logical';
53007
- this._changed();
53008
- this._redraw();
53009
- }
53010
-
53011
- /** Check the model (DataModelChecker) and list the findings; choosing one shows it (revealFinding). Returns the findings. */
53012
- checkModel() {
53013
- const findings = this.model.checker().run();
53014
- new ModelCheckDialog({ findings, onReveal: (finding) => this.revealFinding(finding), document: this.document }).open();
53015
- return findings;
53016
- }
53017
-
53018
- /** Show what a finding names: an entity's level, a service domain seen through at the logical level, the mapping or data types view. */
53019
- revealFinding(finding) {
53020
- if (finding.kind === 'perspective') {
53021
- this.chartOptions.level = 'logical';
53022
- this.setPerspective(finding.ids[0]);
53023
- } else if (finding.kind === 'mapping') {
53024
- this.setLevel(DataModel.MAPPING);
53025
- } else if (finding.kind === 'data-type') {
53026
- this.setLevel(DataModel.DATA_TYPES);
53027
- } else if (finding.level) {
53028
- this.setLevel(finding.level);
53029
- }
53030
- this.menuBar?.render?.();
53031
- }
53032
-
53033
- /** A dialog making a perspective (none given) or changing one (DataModelPerspective.fields); applied, it is the one seen through. */
53034
- editPerspective(perspective, Kind = perspective?.constructor ?? DataModelPerspective) {
53035
- const title = perspective ? `${Kind.kindLabel}: ${perspective.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
53036
- return this._dialog(title, Kind.fields(this.model, perspective), (values) => {
53037
- const spec = Kind.specFrom(values, this.model, perspective);
53038
- const saved = perspective ? this.model.updatePerspective(perspective.id, spec) : this.model.addPerspective(spec);
53039
- this._changed();
53040
- this.setPerspective(saved.id);
53041
- });
53042
- }
53043
-
53044
- /** A dialog making a context (none given) or changing one (DataModelContext.fields); applied, it is the one read in. */
53045
- editContext(context) {
53046
- return this._dialog(context ? `Context: ${context.name}` : 'New context', DataModelContext.fields(this.model, context), (values) => {
53047
- const spec = DataModelContext.specFrom(values, this.model, context);
53048
- const saved = context ? this.model.updateContext(context.id, spec) : this.model.addContext(spec);
53049
- this._changed();
53050
- this.setContext(saved.id);
53051
- });
53052
- }
53053
-
53054
- removePerspective(id) {
53055
- this.model.removePerspective(id);
53056
- this._changed();
53057
- this.setPerspective(null);
53058
- }
53059
-
53060
- removeContext(id) {
53061
- this.model.removeContext(id);
53062
- this._changed();
53063
- this.setContext(null);
53064
- }
53065
-
53066
- /** Open a form (a name is needed). @private */
53067
- _dialog(title, fields, apply, validate = ({ name }) => ((name ?? '').trim() ? null : 'Give it a name')) {
53068
- return new FormDialog({
53069
- title, fields, submitLabel: 'Save', idPrefix: 'data-model-',
53070
- document: this.container?.ownerDocument ?? globalThis.document,
53071
- validate, onApply: apply,
53072
- }).open();
53073
- }
53074
-
53075
- /** The perspectives or contexts changed: the toolbar shows them anew. @private */
53076
- _changed() {
53077
- if (this.menuBar) this.menuBar.items = this.menuBarItems();
53078
- this.menuBar?.render?.();
53079
- }
53080
-
53081
- /** Export ▸: the picture, as PNG or SVG, named after the model. */
53082
- exportItems() {
53083
- const name = this.model.name || this.title || 'data-model';
53084
- return [
53085
- { label: 'Image (PNG)', action: () => downloadPng(this.canvasHost, { name }) },
53086
- { label: 'Image (SVG)', action: () => downloadSvg(this.canvasHost, { name }) },
53087
- { separator: true },
53088
- { label: 'SQL DDL (physical level)', action: () => this.exportDdl() },
53089
- ];
53090
- }
53091
-
53092
- /** The physical level as SQL DDL (SqlDdlFileFormat), downloaded as the model's name. Returns the file. */
53093
- exportDdl({ download = (file, fileName) => downloadBlob(new (this.document.defaultView ?? globalThis).Blob([file.data], { type: file.mimeType }), fileName) } = {}) {
53094
- const file = new SqlDdlFileFormat().export(this.model);
53095
- download(file, `${String(this.model.name || this.title || 'model').replace(/[^A-Za-z0-9._-]+/g, '-')}.sql`);
53096
- return file;
53097
- }
53098
-
53099
- /** Redraw now (a choice made), when there is somewhere to draw. @private */
53100
- _redraw() {
53101
- if (this.canvasHost) this._draw();
53102
- // Its toolbar shows what is drawn, however it was chosen (a finding, a lineage, a service domain opened).
53103
- this.menuBar?.refresh?.();
53104
- }
53105
-
53106
- /**
53107
- * Keep where the boxes and notes were dragged (the drawing is made anew each
53108
- * time): on the level shown, or in the mapping view's own layout. Then draw.
53109
- */
53110
- _draw() {
53111
- if (this._keepPlaces()) this.model.edited(); // a box or note moved is a change, kept and saved
53112
- this._drawnLevel = this.level;
53113
- super._draw();
53114
- }
53115
-
53116
- /**
53117
- * Keep where the drawing on screen has its boxes and notes: on the level
53118
- * it shows, or in the mapping view's layout. Returns whether any moved. @private
53119
- */
53120
- _keepPlaces() {
53121
- const pos = this.engine?._graph?.pos;
53122
- if (!pos || !this._drawnLevel) return false;
53123
- let moved = false;
53124
- const place = (p, before, put) => {
53125
- if (!p || (before && before.x === p.x && before.y === p.y)) return;
53126
- put({ x: p.x, y: p.y });
53127
- moved = true;
53128
- };
53129
- const other = DataModel.otherView(this._drawnLevel);
53130
- if (other && !other.keepsPlaces) return false; // laid out by its structure, anew each time
53131
- if (other) {
53132
- for (const [id, p] of pos) {
53133
- if (id.includes(':')) place(p, this.model.mappingLayout[id], (at) => { this.model.mappingLayout[id] = at; });
53134
- }
53135
- return moved;
53136
- }
53137
- for (const entity of this.model.level(this._drawnLevel).entities) {
53138
- place(pos.get(entity.id), entity, (at) => Object.assign(entity, at));
53139
- }
53140
- for (const rule of this.model.contexts.flatMap(c => c.constraints)) {
53141
- const p = pos.get(rule.id);
53142
- const before = this.model.notePlace(this._drawnLevel, rule);
53143
- place(p, Number.isFinite(before.x) ? before : null, (at) => this.model.placeNote(this._drawnLevel, rule, at));
53144
- }
53145
- return moved;
53146
- }
53147
-
53148
- dispose() {
53149
- this._unsubscribeEdits?.();
53150
- this.file.restoreTitle();
53151
- this.menuBar?.dispose?.();
53152
- super.dispose();
53153
- }
53154
-
53155
- /** The spec again: the model (with where its boxes are), what is chosen, and the other options. */
53156
- serialize() {
53157
- const { options, chartType, ...base } = super.serialize();
53158
- const { level, notation, ...rest } = options;
53159
- return {
53160
- ...base, ...this.model.toJSON(),
53161
- perspective: this.perspectiveId, context: this.contextId, level, notation, options: rest,
53162
- };
53163
- }
53617
+ /**
53618
+ * ModelHosted — what a part that edits one model as a whole (an
53619
+ * EditableModel: DataModelPart, RequirementsPart) needs to be one of a
53620
+ * ModelsPart's models, as an EditorPart is: its file (ModelFile: Open…, Save,
53621
+ * Save As…, • while changed, asking before the page is left), kept by the
53622
+ * host (`keepId`, `keepEntry`), its File menu (New, Duplicate, Close model,
53623
+ * where models are kept, Import of what other tools wrote), Undo and Redo,
53624
+ * running a model-wide edit (a merge, a proposal taken) as one undo step,
53625
+ * and reading a file into the model as one (readInto).
53626
+ *
53627
+ * A mixin, for parts on different bases (a ChartPart, a plain Part):
53628
+ * `class DataModelPart extends ModelHosted(ChartPart)`. The part gives
53629
+ * `fileFormats()` (its own first: what it saves as), and `afterModelEdit()`
53630
+ * to show an edit (redraw).
53631
+ */
53632
+ const ModelHosted = (Base) => class extends Base {
53633
+ constructor(spec = {}) {
53634
+ super(spec);
53635
+ /** The file it is open from and saved to. */
53636
+ this.file = new ModelFile(this);
53637
+ /** Its id among the models kept by its host, or null. */
53638
+ this.keepId = spec.keepId ?? null;
53639
+ }
53640
+
53641
+ /** Its file formats: none here. @abstract */
53642
+ fileFormats() {
53643
+ return [];
53644
+ }
53645
+
53646
+ /** What a new file is saved as: its first format. */
53647
+ saveFormat() {
53648
+ return this.fileFormats()[0];
53649
+ }
53650
+
53651
+ /** The ModelsPart it is a tab of, or null. */
53652
+ get modelsHost() {
53653
+ return this.ctx?.models ?? null;
53654
+ }
53655
+
53656
+ /** The page it is in. */
53657
+ get document() {
53658
+ return this.container?.ownerDocument ?? globalThis.document;
53659
+ }
53660
+
53661
+ /** What its tab and the page title show: the file's name (or the model's), and • while changed. */
53662
+ get fileLabel() {
53663
+ return this.file.label;
53664
+ }
53665
+
53666
+ /** The model as kept by its host (ModelFile#keepEntry). */
53667
+ keepEntry() {
53668
+ return this.file.keepEntry();
53669
+ }
53670
+
53671
+ /** A model opened from a file: in a host's new tab, else in place of this one (as EditorPart does). */
53672
+ onImported(model) {
53673
+ return EditorPart.prototype.onImported.call(this, model);
53674
+ }
53675
+
53676
+ /** The file or its saved state changed: a ModelsPart's tab follows it. */
53677
+ onFileState() {
53678
+ this.modelsHost?.fileStateChanged?.(this);
53679
+ }
53680
+
53681
+ /** Edit `model`: as saved, and every change of it shows (• and the title) and is kept. @protected */
53682
+ _adoptModel(model) {
53683
+ this._unsubscribeEdits?.();
53684
+ this.model = model;
53685
+ model.markSaved();
53686
+ this._unsubscribeEdits = model.onEdit(() => {
53687
+ this.file.showState();
53688
+ this.modelsHost?.modelEdited?.(this);
53689
+ });
53690
+ }
53691
+
53692
+ mount(host, ctx) {
53693
+ const first = !this.mounted;
53694
+ super.mount(host, ctx);
53695
+ if (first) {
53696
+ this.file.listen();
53697
+ this.file.showState();
53698
+ }
53699
+ }
53700
+
53701
+ /** Show an edit of the model (a redraw): a subclass's. */
53702
+ afterModelEdit() {}
53703
+
53704
+ /** Run a model-wide edit (a merge) as one undo step, then show it. */
53705
+ runModelEdit(command) {
53706
+ if (!command) return null;
53707
+ this.model.history.execute(command);
53708
+ this.afterModelEdit();
53709
+ return command;
53710
+ }
53711
+
53712
+ /**
53713
+ * File ▸: Open…, Save, Save As…, Import of what other tools wrote (its
53714
+ * formats that only read), then with a ModelsPart New model, Duplicate,
53715
+ * Close model, and where models are kept ▸.
53716
+ */
53717
+ fileItems() {
53718
+ const host = this.modelsHost;
53719
+ return EditorPart.tidyMenu([
53720
+ ...this.file.menuItems(),
53721
+ ...EditorPart.menuGroup('Import', this.importItems(), { prefix: true }),
53722
+ ...(host ? [
53723
+ { separator: true },
53724
+ ...EditorPart.menuGroup('New', [{ label: 'New model', action: () => host.newModel() }, ...(host.baseItems?.() ?? [])]),
53725
+ { label: 'Duplicate model', action: () => host.duplicate(this) },
53726
+ { label: 'Close model', action: () => host.closeModel(this.id) },
53727
+ ...EditorPart.menuGroup(host.keptWhere ?? 'This browser', host.keepItems?.(this) ?? []),
53728
+ ] : []),
53729
+ ]);
53730
+ }
53731
+
53732
+ /** Import ▸: each format that only reads (another tool's export), opened as a new model. */
53733
+ importItems() {
53734
+ return modelFileMenuItems(this.fileFormats().filter(f => f.exports === false), {
53735
+ model: () => this.model, only: 'import', onImport: (model) => this.onImported(model), document: () => this.document,
53736
+ });
53737
+ }
53738
+
53739
+ /**
53740
+ * Ask for a file, make a command from its bytes, and run it as one undo
53741
+ * step; a file that cannot be read, or brings nothing, is reported.
53742
+ * @param {Object} how
53743
+ * @param {string} how.accept - The file chooser's filter
53744
+ * @param {string} how.what - What is read, for the report ('the landscape')
53745
+ * @param {(bytes: Uint8Array, name: string) => Command|null} how.command
53746
+ * @param {(name: string) => string} how.nothing - The report when the command is null
53747
+ * @param {() => void} [how.then] - What to show once it is run (before the redraw)
53748
+ * @param {(accept: string) => Promise<File|null>} [how.chooseFile]
53749
+ * @param {(message: string) => void} [how.report]
53750
+ * @returns {Promise<Command|null>}
53751
+ */
53752
+ async readInto({ accept, what, command: make, nothing, then = () => {}, chooseFile = null, report = null }) {
53753
+ const doc = this.container?.ownerDocument ?? globalThis.document;
53754
+ const tell = report ?? ((message) => (doc.defaultView ?? globalThis).alert(message));
53755
+ const file = await (chooseFile ?? ((filter) => pickFile(filter, doc)))(accept);
53756
+ if (!file) return null;
53757
+ let command;
53758
+ try {
53759
+ command = make(new Uint8Array(await file.arrayBuffer()), file.name);
53760
+ } catch (err) {
53761
+ tell(`Could not read ${what}: ${err.message}`);
53762
+ return null;
53763
+ }
53764
+ if (!command) {
53765
+ tell(nothing(file.name));
53766
+ return null;
53767
+ }
53768
+ then();
53769
+ this.runModelEdit(command);
53770
+ return command;
53771
+ }
53772
+
53773
+ /** Undo and Redo, each named by what it undoes (a merge is one step). */
53774
+ undoItems() {
53775
+ const history = this.model.history;
53776
+ return [
53777
+ { label: history.canUndo ? `Undo ${history.undoLabel}` : 'Undo', disabled: !history.canUndo || undefined, action: () => { history.undo(); this.afterModelEdit(); } },
53778
+ { label: history.canRedo ? `Redo ${history.redoLabel}` : 'Redo', disabled: !history.canRedo || undefined, action: () => { history.redo(); this.afterModelEdit(); } },
53779
+ ];
53780
+ }
53781
+
53782
+ dispose() {
53783
+ this._unsubscribeEdits?.();
53784
+ this.file.restoreTitle();
53785
+ super.dispose();
53786
+ }
53787
+ };
53788
+
53789
+ /**
53790
+ * DataModelPart — a data model (DataModel, drawn by dataModelDiagram) in a
53791
+ * pane: its conceptual, logical and physical levels, each its own schema, and
53792
+ * how they map; seen through a perspective (whose concepts, under whose names)
53793
+ * read in a context (what they mean here, the rules that hold, where and
53794
+ * when), in a notation (Chen, Crow's Foot, UML). Its toolbar switches each
53795
+ * of them, Level offering the views that are no level (DataModel.OTHER_VIEWS:
53796
+ * Mapping, the levels side by side joined where they map; Service landscape,
53797
+ * the service domains in their business areas and domains, a double-click
53798
+ * seeing the model through one), and its Model menu makes, changes and removes
53799
+ * perspectives and contexts and reads service domains from a BIAN landscape. Boxes and notes stay where they were dragged. Export ▸ saves the
53800
+ * picture (PNG, SVG). The drawing, its sizing and redrawing on resize are
53801
+ * ChartPart's.
53802
+ *
53803
+ * It is saved as a file (DataModelFileFormat, `.datamodel.json`) as an
53804
+ * editor's model is (ModelHosted: ModelFile): File ▸ Open…, Save, Save As… (Ctrl+O, Ctrl+S),
53805
+ * • while changed since, and asking before the page is left with changes
53806
+ * unsaved. Hosted by a ModelsPart (`editorType: 'data-model'`) it is one of
53807
+ * several models open, a tab each, and with that part's `keep` it is kept in
53808
+ * this browser and backed up (File ▸ This browser), as the modeller's are.
53809
+ *
53810
+ * Spec: `{ id, type: 'data-model', title?, levels?, mappings?, mappingLayout?,
53811
+ * perspectives?, contexts?, perspective?, context?, level?, notation?, options? }`
53812
+ * — the model as DataModel takes it (or `entities`/`relationships` in the older
53813
+ * one-entity form); `perspective`/`context` the ids chosen; `level` default
53814
+ * 'logical' ('mapping' for the mapping view), `notation` default 'crowsfoot';
53815
+ * `options` anything else dataModelDiagram takes (editable, resizable by default).
53816
+ */
53817
+ class DataModelPart extends ModelHosted(ChartPart) {
53818
+ static type = 'data-model';
53819
+
53820
+ constructor(spec = {}) {
53821
+ const { perspective = null, context = null, level = 'logical', notation = 'crowsfoot', options = {} } = spec;
53822
+ super({ ...spec, chartType: 'dataModel', options: { editable: true, resizable: true, ...options, level, notation } });
53823
+ this._adoptModel(new DataModel(spec));
53824
+ /** The ids chosen, or null. */
53825
+ this.perspectiveId = perspective;
53826
+ this.contextId = context ?? this.model.perspective(perspective)?.context ?? null;
53827
+ /** The level the drawing on screen is of (where a drag is kept). */
53828
+ this._drawnLevel = level;
53829
+ }
53830
+
53831
+ // ===== A model saved as a file, kept, backed up =====
53832
+
53833
+ /** Its file formats: the data model's own. */
53834
+ fileFormats() {
53835
+ return [DataModelFileFormat.instance ??= new DataModelFileFormat()];
53836
+ }
53837
+
53838
+ /** Edit another model (one opened, imported or kept), from scratch: nothing chosen, nothing changed. */
53839
+ replaceModel(model) {
53840
+ this._adoptModel(model);
53841
+ this.perspectiveId = null;
53842
+ this.contextId = null;
53843
+ if (!this.mounted) return;
53844
+ // Not rendered again (that would add a second toolbar and canvas): the toolbar
53845
+ // shows the new model's choices, and it is drawn. What was on screen is the
53846
+ // other model's, so where its boxes were is not kept into this one.
53847
+ this._drawnLevel = null;
53848
+ this._drawnPlaces = null;
53849
+ this._changed();
53850
+ this._redraw();
53851
+ }
53852
+
53853
+ /** Edit ▸: undo and redo (a merge is one step), and laying the level shown out again. */
53854
+ editItems() {
53855
+ return [
53856
+ ...this.undoItems(),
53857
+ ...(this.editsLevel ? [
53858
+ { separator: true },
53859
+ { label: 'Lay out', items: LAYOUTS.map(([kind, label]) => ({ label, action: () => this.runModelEdit(this.layoutCommand(kind)) })) },
53860
+ ] : []),
53861
+ ];
53862
+ }
53863
+
53864
+ /**
53865
+ * Lay the boxes of the level shown out again — what is drawn there (the
53866
+ * perspective's), by one of the arrangement's layouts (LAYOUTS: grid,
53867
+ * circle, trees, force-directed, by kind), its lines guiding the trees and
53868
+ * the force. Where each box goes is kept in the model as a drag would be.
53869
+ * One undo step; null on a view that is no level of the schema.
53870
+ * @param {string} kind - A LAYOUTS key
53871
+ */
53872
+ layoutCommand(kind) {
53873
+ if (!this.editsLevel) return null;
53874
+ const level = this.level;
53875
+ const view = this.model.view({ perspective: this.perspectiveId, context: this.contextId, level });
53876
+ const notation = DataModelNotation.notation(this.notation);
53877
+ const boxes = view.entities.map(e => {
53878
+ const node = DataModelDiagram.buildNode({ ...e, x: Number.isFinite(e.x) ? e.x : 0, y: Number.isFinite(e.y) ? e.y : 0 }, { level, notation });
53879
+ const b = DataModelDiagram.boxOf(node);
53880
+ return { id: e.id, left: b.x0, top: b.y0, width: b.x1 - b.x0, height: b.y1 - b.y0, kind: e.role ?? e.kind ?? '' };
53881
+ });
53882
+ const placed = layout(kind, boxes, {
53883
+ edges: (view.relationships ?? []).map(r => ({ source: r.source, target: r.target })),
53884
+ categoryOf: (box) => box.kind,
53885
+ });
53886
+ const sizes = new Map(boxes.map(b => [b.id, b]));
53887
+ const label = LAYOUTS.find(([k]) => k === kind)?.[1] ?? kind;
53888
+ return this.model.editCommand(`Lay out: ${label}`, (model) => {
53889
+ for (const p of placed) {
53890
+ const entity = model.level(level).entity(p.id), b = sizes.get(p.id);
53891
+ if (entity) Object.assign(entity, { x: Math.round(p.left + b.width / 2), y: Math.round(p.top + b.height / 2) });
53892
+ }
53893
+ });
53894
+ }
53895
+
53896
+ /** An edit shown: the toolbar's choices anew, and drawn. */
53897
+ afterModelEdit() {
53898
+ this._changed();
53899
+ this._redraw();
53900
+ }
53901
+
53902
+ /** The entities at the level shown (the concepts in the mapping view). */
53903
+ get entities() {
53904
+ return this.model.level(DataModel.otherView(this.level) ? 'conceptual' : this.level).entities;
53905
+ }
53906
+ get level() { return this.chartOptions.level; }
53907
+ get notation() { return this.chartOptions.notation; }
53908
+ /** The perspective chosen (DataModelPerspective), or null. */
53909
+ get perspective() { return this.model.perspective(this.perspectiveId); }
53910
+ /** The context chosen (DataModelContext), or null. */
53911
+ get context() { return this.model.context(this.contextId); }
53912
+
53913
+ /** Show another level ('conceptual', 'logical', 'physical'), or how they map ('mapping'). */
53914
+ setLevel(level) {
53915
+ this.chartOptions.level = level;
53916
+ this._redraw();
53917
+ }
53918
+
53919
+ /** Draw the model in another notation ('chen', 'crowsfoot', 'uml'). */
53920
+ setNotation(notation) {
53921
+ this.chartOptions.notation = notation;
53922
+ this._redraw();
53923
+ }
53924
+
53925
+ /** See the model through a perspective (an id; null: the whole schema), in the context it is read in, if it names one. */
53926
+ setPerspective(id) {
53927
+ this.perspectiveId = id || null;
53928
+ const own = this.perspective?.context;
53929
+ if (own) this.contextId = own;
53930
+ this._redraw();
53931
+ }
53932
+
53933
+ /** Read the model in a context (an id; null: the schema's own descriptions). */
53934
+ setContext(id) {
53935
+ this.contextId = id || null;
53936
+ this._redraw();
53937
+ }
53938
+
53939
+ /** What is drawn: the perspective's view of the level (or the mapping) in the context (DataModel#view), in the notation. */
53940
+ drawOptions() {
53941
+ return {
53942
+ ...this.chartOptions,
53943
+ ...this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: this.level }),
53944
+ ...(this.level === DataModel.LANDSCAPE && { onNodeDoubleClick: (node) => this.openServiceDomain(ServiceLandscape.serviceDomainOf(node.id)) }),
53945
+ ...(this.editsLevel && this.editingOptions()),
53946
+ ...(this.level === DataModel.MAPPING && this.lineageOptions()),
53947
+ };
53948
+ }
53949
+
53950
+ /**
53951
+ * Show an entity's lineage: the mapping view, what it realises and what
53952
+ * realises it (DataModel#lineage) as they are, the rest greyed.
53953
+ */
53954
+ showLineage(level, id) {
53955
+ this.lineageFocus = { level, id };
53956
+ this.setLevel(DataModel.MAPPING);
53957
+ }
53958
+
53959
+ /**
53960
+ * In the mapping view: an entity's lineage picked out (`lineageFocus`), and
53961
+ * its menus to show another's or show all again.
53962
+ */
53963
+ lineageOptions() {
53964
+ const view = {};
53965
+ const focus = this.lineageFocus;
53966
+ if (focus) {
53967
+ const { up, down } = this.model.lineage(focus.level, focus.id);
53968
+ const on = new Set([`${focus.level}:${focus.id}`, ...[...up, ...down].map(({ level, entity }) => `${level}:${entity.id}`)]);
53969
+ const drawn = this.model.view({ perspective: this.perspectiveId, context: this.contextId, level: DataModel.MAPPING });
53970
+ view.entities = drawn.entities.map(e => (on.has(e.id) ? e : { ...e, color: '#f3f4f6' }));
53971
+ view.links = drawn.links.map(l => (on.has(l.source) && on.has(l.target) ? l : { ...l, color: '#e5e7eb' }));
53972
+ }
53973
+ const split = (nodeId) => { const i = String(nodeId).indexOf(':'); return [nodeId.slice(0, i), nodeId.slice(i + 1)]; };
53974
+ return {
53975
+ ...view,
53976
+ contextMenuItems: [{ label: 'Show lineage', action: (node) => { const [level, id] = split(node.id); this.showLineage(level, id); } }],
53977
+ canvasContextMenuItems: [{ label: 'Show all', action: () => { this.lineageFocus = null; this._redraw(); } }],
53978
+ };
53979
+ }
53980
+
53981
+ /** Whether what is shown is a level of the schema (so its entities and relationships can be edited here). */
53982
+ get editsLevel() {
53983
+ return !DataModel.otherView(this.level);
53984
+ }
53985
+
53986
+ /**
53987
+ * At a level of the schema: double-click an entity or a relationship to
53988
+ * edit it; right-click one to edit or delete it, or the canvas for a new
53989
+ * entity there or a new relationship. (These stand in for graphDiagram's
53990
+ * own, which change only the drawing.)
53991
+ */
53992
+ editingOptions() {
53993
+ const relationshipOf = (drawn) => this.model.relationshipIndex(this.level, drawn);
53994
+ const isEntity = (node) => !!this.model.level(this.level).entity(node.id);
53995
+ return {
53996
+ onNodeDoubleClick: (node) => (isEntity(node) ? this.editEntity(node.id) : this.editRelationship(relationshipOf(node))),
53997
+ onEdgeDoubleClick: (edge) => this.editRelationship(relationshipOf(edge)),
53998
+ contextMenuItems: [
53999
+ { label: 'Edit…', action: (node) => (isEntity(node) ? this.editEntity(node.id) : this.editRelationship(relationshipOf(node))) },
54000
+ { label: 'Delete', action: (node) => (isEntity(node) ? this.removeEntity(node.id) : this.removeRelationship(relationshipOf(node))) },
54001
+ { label: 'Show lineage', action: (node) => { if (isEntity(node)) this.showLineage(this.level, node.id); } },
54002
+ ],
54003
+ edgeContextMenuItems: [
54004
+ { label: 'Edit relationship…', action: (edge) => this.editRelationship(relationshipOf(edge)) },
54005
+ { label: 'Delete relationship', action: (edge) => this.removeRelationship(relationshipOf(edge)) },
54006
+ ],
54007
+ canvasContextMenuItems: [
54008
+ { label: `New ${this.levelInfo.entityNoun.toLowerCase()} here…`, action: (subject, engine, point) => this.editEntity(null, point) },
54009
+ { label: 'New relationship…', action: () => this.editRelationship(null) },
54010
+ ],
54011
+ };
54012
+ }
54013
+
54014
+ /** The level shown, as DataModelNotation has it (its nouns, its dialogs' fields). */
54015
+ get levelInfo() {
54016
+ return DataModelNotation.level(this.level);
54017
+ }
54018
+
54019
+ /**
54020
+ * An entity's dialog at the level shown (none: a new one, placed at
54021
+ * `point` or below the others): its level's fields (name, definition, and
54022
+ * at the logical and physical levels its attributes as a table). Saved, it
54023
+ * is one undo step.
54024
+ */
54025
+ editEntity(id, point = null) {
54026
+ if (!this.editsLevel) return null;
54027
+ const info = this.levelInfo;
54028
+ const schema = this.model.level(this.level);
54029
+ const entity = id ? schema.entity(id) : null;
54030
+ if (id && !entity) return null;
54031
+ const scheme = this.model.classificationScheme;
54032
+ const context = { typeNames: this.model.dataTypes.map(t => t.name), sensitivities: scheme.levels.map(l => l.name), categories: scheme.categories };
54033
+ const title = entity ? `${info.entityNoun}: ${entity.name}` : `New ${info.entityNoun.toLowerCase()}`;
54034
+ return this._dialog(title, info.entityFields(entity, context), (values) => {
54035
+ const spec = info.entitySpecFrom(values, entity);
54036
+ if (!entity) Object.assign(spec, point ?? this._freeSpot(schema));
54037
+ this.runModelEdit(this.model.saveEntityCommand(this.level, id, spec));
54038
+ });
54039
+ }
54040
+
54041
+ /** Where a new entity goes with no point given: below what is there. @private */
54042
+ _freeSpot(schema) {
54043
+ const ys = schema.entities.map(e => e.y).filter(Number.isFinite);
54044
+ return { x: 160, y: (ys.length ? Math.max(...ys) : 0) + 160 };
54045
+ }
54046
+
54047
+ removeEntity(id) {
54048
+ if (!this.editsLevel || !this.model.level(this.level).entity(id)) return null;
54049
+ return this.runModelEdit(this.model.removeEntityCommand(this.level, id));
54050
+ }
54051
+
54052
+ /** A relationship's dialog at the level shown (`index` null or -1: a new one); saved, one undo step. */
54053
+ editRelationship(index) {
54054
+ if (!this.editsLevel) return null;
54055
+ const info = this.levelInfo;
54056
+ const schema = this.model.level(this.level);
54057
+ const relationship = index == null || index < 0 ? null : schema.relationships[index];
54058
+ if (!schema.entities.length) return null;
54059
+ const title = relationship ? `Relationship: ${relationship.name || `${relationship.source} – ${relationship.target}`}` : 'New relationship';
54060
+ return this._dialog(title, info.relationshipFields(schema.entities, relationship), (values) => {
54061
+ this.runModelEdit(this.model.saveRelationshipCommand(this.level, relationship ? index : null, info.relationshipSpecFrom(values, relationship)));
54062
+ }, ({ source, target }) => (source && target ? null : 'Choose both ends'));
54063
+ }
54064
+
54065
+ removeRelationship(index) {
54066
+ if (!this.editsLevel || index == null || index < 0) return null;
54067
+ return this.runModelEdit(this.model.removeRelationshipCommand(this.level, index));
54068
+ }
54069
+
54070
+ /** See the model through a service domain (an id), at the logical level, where its business objects are. */
54071
+ openServiceDomain(id) {
54072
+ if (!this.model.perspective(id)) return;
54073
+ this.chartOptions.level = 'logical';
54074
+ this.setPerspective(id);
54075
+ }
54076
+
54077
+ render() {
54078
+ Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
54079
+ const toolbar = this.container.appendChild(el({}));
54080
+ super.render();
54081
+ Object.assign(this.canvasHost.style, { flex: '1 1 auto', minHeight: '0', height: 'auto' });
54082
+ this.menuBar = new MenuBar({ container: toolbar, items: this.menuBarItems(), document: this.container.ownerDocument }).render();
54083
+ }
54084
+
54085
+ /**
54086
+ * Model ▸ (perspectives and contexts of one's own), Perspective and Context
54087
+ * (when the model has any; the context's scope beside them), Level (each
54088
+ * level from DataModelNotation, then Mapping) and Notation, and Export ▸.
54089
+ */
54090
+ menuBarItems() {
54091
+ const { perspectives, contexts } = this.model;
54092
+ const host = this.modelsHost;
54093
+ return [
54094
+ new ToolbarMenu('File', () => this.fileItems()),
54095
+ new ToolbarMenu('Edit', () => this.editItems()),
54096
+ ...(host ? [new ToolbarMenu('Tools', () => host.toolItems(this))] : []),
54097
+ new ToolbarMenu('Model', () => this.modelItems()),
54098
+ ...(perspectives.length ? [new ToolbarSelect('Perspective',
54099
+ () => [['', 'Whole schema'], ...this.model.perspectives.map(p => [p.id, p.constructor === DataModelPerspective ? p.name : `${p.name} (${p.constructor.kindLabel.toLowerCase()})`, p.description])],
54100
+ () => this.perspectiveId ?? '', (v) => this.setPerspective(v))] : []),
54101
+ ...(contexts.length ? [
54102
+ new ToolbarSelect('Context',
54103
+ () => [['', 'None'], ...this.model.contexts.map(c => [c.id, c.name, c.description])],
54104
+ () => this.contextId ?? '', (v) => this.setContext(v)),
54105
+ new ToolbarText(() => this.context?.scope ?? ''),
54106
+ ] : []),
54107
+ new ToolbarSelect('Level', () => [
54108
+ ...DataModelNotation.levels().map(l => [l.name, l.label, l.description]),
54109
+ ...DataModel.OTHER_VIEWS.filter(v => v.offered(this.model) || v.name === this.level).map(v => [v.name, v.label, v.description]),
54110
+ ], () => this.level, (v) => this.setLevel(v)),
54111
+ new ToolbarSelect('Notation', () => DataModelNotation.notations().map(n => [n.name, n.label]), () => this.notation, (v) => this.setNotation(v)),
54112
+ new ToolbarMenu('Export', () => this.exportItems()),
54113
+ ];
54114
+ }
54115
+
54116
+ /** Model ▸: make, change or remove a perspective or a context (the one chosen). */
54117
+ modelItems() {
54118
+ return [
54119
+ { label: `New ${this.editsLevel ? this.levelInfo.entityNoun.toLowerCase() : 'entity'}…`, disabled: !this.editsLevel || undefined, action: () => this.editEntity(null) },
54120
+ { label: 'New relationship…', disabled: !this.editsLevel || !this.model.level(this.level).entities.length || undefined, action: () => this.editRelationship(null) },
54121
+ { separator: true },
54122
+ { label: 'New perspective…', action: () => this.editPerspective(null) },
54123
+ { label: 'New service domain…', action: () => this.editPerspective(null, ServiceDomain) },
54124
+ { label: 'New classification lens…', action: () => this.editPerspective(null, ClassificationLens) },
54125
+ { label: 'Read service domains from a BIAN landscape…', action: () => this.readLandscape() },
54126
+ { label: 'Read a BIAN service domain (semantic API)…', action: () => this.readSemanticApi() },
54127
+ { label: 'Edit perspective…', disabled: !this.perspective || undefined, action: () => this.editPerspective(this.perspective) },
54128
+ { label: 'Delete perspective', disabled: !this.perspective || undefined, action: () => this.removePerspective(this.perspectiveId) },
54129
+ { separator: true },
54130
+ ...EditorPart.menuGroup('Data types', this.dataTypeItems()),
54131
+ { separator: true },
54132
+ { label: 'New context…', action: () => this.editContext(null) },
54133
+ { label: 'Edit context…', disabled: !this.context || undefined, action: () => this.editContext(this.context) },
54134
+ { label: 'Delete context', disabled: !this.context || undefined, action: () => this.removeContext(this.contextId) },
54135
+ { separator: true },
54136
+ { label: 'Check model…', action: () => this.checkModel() },
54137
+ { label: 'Scorecard…', action: () => this.scoreModel() },
54138
+ ...EditorPart.menuGroup('Naming standard', this.namingItems()),
54139
+ { label: 'Classification scheme…', action: () => this.editClassificationScheme() },
54140
+ ];
54141
+ }
54142
+
54143
+ /**
54144
+ * Service domains from BIAN's published service landscape (an ArchiMate
54145
+ * model: an Archi file or the Exchange Format; BianLandscapeReader), as one
54146
+ * undo step (DataModel#serviceDomainsCommand); then the landscape is shown.
54147
+ * @param {Object} [options] - As readInto's, and `reader` (a BianLandscapeReader)
54148
+ * @returns {Promise<Command|null>}
54149
+ */
54150
+ readLandscape({ reader = new BianLandscapeReader(), ...options } = {}) {
54151
+ let found = 0;
54152
+ return this.readInto({
54153
+ ...options, accept: '.archimate,.xml', what: 'the landscape',
54154
+ command: (bytes, name) => {
54155
+ const specs = reader.read(BianLandscapeReader.parse(bytes));
54156
+ found = specs.length;
54157
+ return this.model.serviceDomainsCommand(specs, `"${name}"`);
54158
+ },
54159
+ nothing: (name) => (found
54160
+ ? `"${name}" brings nothing new: its ${found} service domain${found === 1 ? ' is' : 's are'} here as ${found === 1 ? 'it is' : 'they are'}.`
54161
+ : `"${name}" has no service domains (Capabilities with none inside them).`),
54162
+ then: () => { this.chartOptions.level = DataModel.LANDSCAPE; this.perspectiveId = null; },
54163
+ });
54164
+ }
54165
+
54166
+ /**
54167
+ * A service domain from one of BIAN's semantic APIs (an OpenAPI file, YAML
54168
+ * or JSON; BianSemanticApiReader): its control record and behaviour
54169
+ * qualifiers as logical entities, the schemas they use as data types, and
54170
+ * the service domain, as one undo step (DataModel#bringCommand); then the
54171
+ * model is seen through it at the logical level. An entity the model has
54172
+ * by name stays as it is and is used.
54173
+ * @param {Object} [options] - As readInto's, and `depth` (BianSemanticApiReader's)
54174
+ * @returns {Promise<Command|null>}
54175
+ */
54176
+ readSemanticApi({ depth = 1, ...options } = {}) {
54177
+ let read = null;
54178
+ return this.readInto({
54179
+ ...options, accept: '.yaml,.yml,.json', what: 'the semantic API',
54180
+ command: (bytes, name) => {
54181
+ const reader = new BianSemanticApiReader({ depth, entityNames: this.model.level('logical').entities.map(e => e.name) });
54182
+ read = reader.read(BianSemanticApiReader.parse(new TextDecoder().decode(bytes)));
54183
+ const [sd] = read.serviceDomains;
54184
+ return this.model.bringCommand(read, `Read ${sd.name} from "${name}"`);
54185
+ },
54186
+ nothing: (name) => `"${name}" brings nothing the model does not have.`,
54187
+ then: () => {
54188
+ // The service domain is the one here of its id or name (bringCommand keeps it), else the new one.
54189
+ const [sd] = read.serviceDomains;
54190
+ const here = this.model.perspective(sd.id) ?? this.model.perspectives.find(p => p.name.toLowerCase() === sd.name.toLowerCase());
54191
+ this.chartOptions.level = 'logical';
54192
+ this.perspectiveId = here?.id ?? sd.id;
54193
+ },
54194
+ });
54195
+ }
54196
+
54197
+ /**
54198
+ * The Data Model Scorecard (ModelScorecard): each category as the model
54199
+ * measures it, a reviewer's score and note, its weight; the total in the
54200
+ * title. Saved, the reviews and weights are kept (one undo step).
54201
+ */
54202
+ scoreModel() {
54203
+ const card = this.model.scored();
54204
+ const results = card.results();
54205
+ const { score, coverage } = card.total(results);
54206
+ const title = score == null ? 'Scorecard: nothing scored yet' : `Scorecard: ${score} of 100 (${coverage}% of the weight scored)`;
54207
+ return this._dialog(title, card.fields(results), (values) => {
54208
+ this.runModelEdit(this.model.scorecardCommand(ModelScorecard.specFrom(values)));
54209
+ }, () => null);
54210
+ }
54211
+
54212
+ /** The classification scheme's dialog (its sensitivity levels in order, its categories); saved, one undo step. */
54213
+ editClassificationScheme() {
54214
+ return this._dialog('Classification scheme', ClassificationScheme.fields(this.model.classificationScheme), (values) => {
54215
+ this.runModelEdit(this.model.classificationSchemeCommand(ClassificationScheme.specFrom(values)));
54216
+ }, (values) => (ClassificationScheme.specFrom(values).levels.length ? null : 'Give it at least one level'));
54217
+ }
54218
+
54219
+ /** Model ▸ Naming standard ▸: edit it, start from the conventional one, or remove it (Check model… checks names against it). */
54220
+ namingItems() {
54221
+ const has = !!this.model.namingStandard;
54222
+ return [
54223
+ { label: has ? 'Edit…' : 'Set…', action: () => this.editNamingStandard() },
54224
+ ...(has ? [] : [{ label: 'Use the conventional one', action: () => this.runModelEdit(this.model.namingStandardCommand(NamingStandard.CONVENTIONAL)) }]),
54225
+ ...(has ? [{ label: 'Remove', action: () => this.runModelEdit(this.model.namingStandardCommand(null)) }] : []),
54226
+ ];
54227
+ }
54228
+
54229
+ /**
54230
+ * The naming standard's dialog (NamingStandard.fields: a rule per level for
54231
+ * entities and attributes); saved, one undo step. A standard that asks
54232
+ * nothing is none.
54233
+ */
54234
+ editNamingStandard() {
54235
+ return this._dialog('Naming standard', NamingStandard.fields(this.model.namingStandard), (values) => {
54236
+ const spec = NamingStandard.specFrom(values);
54237
+ const asks = Object.values(spec).some(kinds => Object.values(kinds).some(r => r.style || r.classWords.length || r.maxLength || Object.keys(r.abbreviations).length));
54238
+ this.runModelEdit(this.model.namingStandardCommand(asks ? spec : null));
54239
+ }, () => null);
54240
+ }
54241
+
54242
+ /** Model ▸ Data types ▸: a new one of each kind, then each to change or remove. */
54243
+ dataTypeItems() {
54244
+ const types = this.model.dataTypes;
54245
+ return [
54246
+ ...[EnumerationType, StructuredType, DataType].map(Kind => ({ label: `New ${Kind.kindLabel.toLowerCase()}…`, action: () => this.editDataType(null, Kind) })),
54247
+ ...(types.length ? [
54248
+ { separator: true },
54249
+ { label: 'Show data types', action: () => this.setLevel(DataModel.DATA_TYPES) },
54250
+ { label: 'Edit', items: types.map(t => ({ label: `${t.name} (${t.constructor.kindLabel.toLowerCase()})…`, action: () => this.editDataType(t) })) },
54251
+ { label: 'Delete', items: types.map(t => ({ label: t.name, action: () => this.removeDataType(t.id) })) },
54252
+ ] : []),
54253
+ ];
54254
+ }
54255
+
54256
+ /** A dialog making a data type of a kind (none given) or changing one (its kind's fields); applied, the data types are shown. */
54257
+ editDataType(type, Kind = type?.constructor ?? DataType) {
54258
+ const title = type ? `${Kind.kindLabel}: ${type.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
54259
+ return this._dialog(title, Kind.fields(this.model, type), (values) => {
54260
+ const spec = Kind.specFrom(values, this.model, type);
54261
+ if (type) this.model.updateDataType(type.id, spec); else this.model.addDataType(spec);
54262
+ this.chartOptions.level = DataModel.DATA_TYPES;
54263
+ this._changed();
54264
+ this._redraw();
54265
+ });
54266
+ }
54267
+
54268
+ removeDataType(id) {
54269
+ this.model.removeDataType(id);
54270
+ if (this.level === DataModel.DATA_TYPES && !this.model.dataTypes.length) this.chartOptions.level = 'logical';
54271
+ this._changed();
54272
+ this._redraw();
54273
+ }
54274
+
54275
+ /** Check the model (DataModelChecker) and list the findings; choosing one shows it (revealFinding). Returns the findings. */
54276
+ checkModel() {
54277
+ const findings = this.model.checker().run();
54278
+ new ModelCheckDialog({ findings, onReveal: (finding) => this.revealFinding(finding), document: this.document }).open();
54279
+ return findings;
54280
+ }
54281
+
54282
+ /** Show what a finding names: an entity's level, a service domain seen through at the logical level, the mapping or data types view. */
54283
+ revealFinding(finding) {
54284
+ if (finding.kind === 'perspective') {
54285
+ this.chartOptions.level = 'logical';
54286
+ this.setPerspective(finding.ids[0]);
54287
+ } else if (finding.kind === 'mapping') {
54288
+ this.setLevel(DataModel.MAPPING);
54289
+ } else if (finding.kind === 'data-type') {
54290
+ this.setLevel(DataModel.DATA_TYPES);
54291
+ } else if (finding.level) {
54292
+ this.setLevel(finding.level);
54293
+ }
54294
+ this.menuBar?.render?.();
54295
+ }
54296
+
54297
+ /** A dialog making a perspective (none given) or changing one (DataModelPerspective.fields); applied, it is the one seen through. */
54298
+ editPerspective(perspective, Kind = perspective?.constructor ?? DataModelPerspective) {
54299
+ const title = perspective ? `${Kind.kindLabel}: ${perspective.name}` : `New ${Kind.kindLabel.toLowerCase()}`;
54300
+ return this._dialog(title, Kind.fields(this.model, perspective), (values) => {
54301
+ const spec = Kind.specFrom(values, this.model, perspective);
54302
+ const saved = perspective ? this.model.updatePerspective(perspective.id, spec) : this.model.addPerspective(spec);
54303
+ this._changed();
54304
+ this.setPerspective(saved.id);
54305
+ });
54306
+ }
54307
+
54308
+ /** A dialog making a context (none given) or changing one (DataModelContext.fields); applied, it is the one read in. */
54309
+ editContext(context) {
54310
+ return this._dialog(context ? `Context: ${context.name}` : 'New context', DataModelContext.fields(this.model, context), (values) => {
54311
+ const spec = DataModelContext.specFrom(values, this.model, context);
54312
+ const saved = context ? this.model.updateContext(context.id, spec) : this.model.addContext(spec);
54313
+ this._changed();
54314
+ this.setContext(saved.id);
54315
+ });
54316
+ }
54317
+
54318
+ removePerspective(id) {
54319
+ this.model.removePerspective(id);
54320
+ this._changed();
54321
+ this.setPerspective(null);
54322
+ }
54323
+
54324
+ removeContext(id) {
54325
+ this.model.removeContext(id);
54326
+ this._changed();
54327
+ this.setContext(null);
54328
+ }
54329
+
54330
+ /** Open a form (a name is needed). @private */
54331
+ _dialog(title, fields, apply, validate = ({ name }) => ((name ?? '').trim() ? null : 'Give it a name')) {
54332
+ return new FormDialog({
54333
+ title, fields, submitLabel: 'Save', idPrefix: 'data-model-',
54334
+ document: this.container?.ownerDocument ?? globalThis.document,
54335
+ validate, onApply: apply,
54336
+ }).open();
54337
+ }
54338
+
54339
+ /** The perspectives or contexts changed: the toolbar shows them anew. @private */
54340
+ _changed() {
54341
+ if (this.menuBar) this.menuBar.items = this.menuBarItems();
54342
+ this.menuBar?.render?.();
54343
+ }
54344
+
54345
+ /** Export ▸: the picture, as PNG or SVG, named after the model. */
54346
+ exportItems() {
54347
+ const name = this.model.name || this.title || 'data-model';
54348
+ return [
54349
+ { label: 'Image (PNG)', action: () => downloadPng(this.canvasHost, { name }) },
54350
+ { label: 'Image (SVG)', action: () => downloadSvg(this.canvasHost, { name }) },
54351
+ { separator: true },
54352
+ { label: 'SQL DDL (physical level)', action: () => this.exportDdl() },
54353
+ ];
54354
+ }
54355
+
54356
+ /** The physical level as SQL DDL (SqlDdlFileFormat), downloaded as the model's name. Returns the file. */
54357
+ exportDdl({ download = (file, fileName) => downloadBlob(new (this.document.defaultView ?? globalThis).Blob([file.data], { type: file.mimeType }), fileName) } = {}) {
54358
+ const file = new SqlDdlFileFormat().export(this.model);
54359
+ download(file, `${String(this.model.name || this.title || 'model').replace(/[^A-Za-z0-9._-]+/g, '-')}.sql`);
54360
+ return file;
54361
+ }
54362
+
54363
+ /** Redraw now (a choice made), when there is somewhere to draw. @private */
54364
+ _redraw() {
54365
+ if (this.canvasHost) this._draw();
54366
+ // Its toolbar shows what is drawn, however it was chosen (a finding, a lineage, a service domain opened).
54367
+ this.menuBar?.refresh?.();
54368
+ }
54369
+
54370
+ /**
54371
+ * Keep where the boxes and notes were dragged (the drawing is made anew each
54372
+ * time): on the level shown, or in the mapping view's own layout. Then draw.
54373
+ */
54374
+ _draw() {
54375
+ if (this._keepPlaces()) this.model.edited(); // a box or note moved is a change, kept and saved
54376
+ this._drawnLevel = this.level;
54377
+ super._draw();
54378
+ this._drawnPlaces = this._placesOnScreen();
54379
+ }
54380
+
54381
+ /** Where the drawing has each box and note now, a copy a drag does not change. @private */
54382
+ _placesOnScreen() {
54383
+ const pos = this.engine?._graph?.pos;
54384
+ return pos ? new Map([...pos].map(([id, p]) => [id, { x: p.x, y: p.y }])) : null;
54385
+ }
54386
+
54387
+ /**
54388
+ * Keep where the drawing on screen has its boxes and notes: on the level
54389
+ * it shows, or in the mapping view's layout. Returns whether any moved.
54390
+ * A box the model has a place for and that was not moved on screen since
54391
+ * it was drawn keeps the model's place — the model may have moved it
54392
+ * since (laid out, an undo), and the screen still shows where it was. @private
54393
+ */
54394
+ _keepPlaces() {
54395
+ const pos = this.engine?._graph?.pos;
54396
+ if (!pos || !this._drawnLevel) return false;
54397
+ let moved = false;
54398
+ const place = (p, before, put, id) => {
54399
+ if (!p || (before && before.x === p.x && before.y === p.y)) return;
54400
+ const drawn = this._drawnPlaces?.get(id);
54401
+ if (drawn && drawn.x === p.x && drawn.y === p.y && Number.isFinite(before?.x) && Number.isFinite(before?.y)) return;
54402
+ put({ x: p.x, y: p.y });
54403
+ moved = true;
54404
+ };
54405
+ const other = DataModel.otherView(this._drawnLevel);
54406
+ if (other && !other.keepsPlaces) return false; // laid out by its structure, anew each time
54407
+ if (other) {
54408
+ for (const [id, p] of pos) {
54409
+ if (id.includes(':')) place(p, this.model.mappingLayout[id], (at) => { this.model.mappingLayout[id] = at; }, id);
54410
+ }
54411
+ return moved;
54412
+ }
54413
+ for (const entity of this.model.level(this._drawnLevel).entities) {
54414
+ place(pos.get(entity.id), entity, (at) => Object.assign(entity, at), entity.id);
54415
+ }
54416
+ for (const rule of this.model.contexts.flatMap(c => c.constraints)) {
54417
+ const p = pos.get(rule.id);
54418
+ const before = this.model.notePlace(this._drawnLevel, rule);
54419
+ place(p, Number.isFinite(before.x) ? before : null, (at) => this.model.placeNote(this._drawnLevel, rule, at), rule.id);
54420
+ }
54421
+ return moved;
54422
+ }
54423
+
54424
+ dispose() {
54425
+ this.menuBar?.dispose?.();
54426
+ super.dispose();
54427
+ }
54428
+
54429
+ /** The spec again: the model (with where its boxes are), what is chosen, and the other options. */
54430
+ serialize() {
54431
+ const { options, chartType, ...base } = super.serialize();
54432
+ const { level, notation, ...rest } = options;
54433
+ return {
54434
+ ...base, ...this.model.toJSON(),
54435
+ perspective: this.perspectiveId, context: this.contextId, level, notation, options: rest,
54436
+ };
54437
+ }
54438
+ }
54439
+
54440
+ /** The ways the model is shown: [id, label]. */
54441
+ const VIEWS = Object.freeze([['map', 'Story map'], ['backlog', 'Backlog']]);
54442
+
54443
+ /** How a story card and the map's headers look, from the workspace theme. */
54444
+ const CARD = {
54445
+ background: 'var(--ws-panel-bg, #fff)', color: 'var(--ws-fg, #222)', border: '1px solid var(--ws-border, #ccc)',
54446
+ borderLeft: '4px solid #f0c040', borderRadius: '4px', padding: '6px 8px', margin: '0 0 6px', fontSize: '12px', cursor: 'pointer',
54447
+ };
54448
+ const HEAD = { padding: '6px 8px', fontWeight: '600', fontSize: '12px', textAlign: 'left', verticalAlign: 'top', cursor: 'pointer' };
54449
+ const CELL = { padding: '6px', verticalAlign: 'top', minWidth: '180px', borderTop: '1px solid var(--ws-border, #ddd)', borderLeft: '1px solid var(--ws-border, #ddd)' };
54450
+ const BADGE = { display: 'inline-block', marginRight: '6px', color: 'var(--ws-muted, #777)', fontSize: '11px' };
54451
+
54452
+ /**
54453
+ * RequirementsPart — a requirements model (RequirementsModel) in a pane, as an
54454
+ * agile team works with it: a **story map** (epics across, releases down,
54455
+ * each story a card — dragged to another epic or release, or among its
54456
+ * neighbours) or the **backlog** (every story in order, a row each). A card
54457
+ * or a row opened edits the story: its narrative ("As a … I want … so that
54458
+ * …"), acceptance criteria, status, priority, points, labels and traces to
54459
+ * what realises it in the other models. Model ▸ adds epics, releases and
54460
+ * stories, brings stories in from a tracker's export or a storyboard, and
54461
+ * checks the model (RequirementsChecker); the toolbar shows how complete it
54462
+ * is (coverage: criteria, traces).
54463
+ *
54464
+ * A model of a ModelsPart (`editorType: 'requirements'`) as the other
54465
+ * modellers' are (ModelHosted): saved as `.requirements.json`, opened from a
54466
+ * tracker's export or a storyboard too, kept, compared and merged, a proposal
54467
+ * reviewed.
54468
+ *
54469
+ * A host extends it by subclass: `traceTargets()` — what a story can be traced
54470
+ * to (the elements of its other models); `resolveTrace(trace)` — whether a
54471
+ * trace's target is there (for the check); `storyStatus(story)` — a live state
54472
+ * shown on the card (a work item's), `{label, color?, title?}` or null;
54473
+ * `searchByMeaning(query)` — the stories nearest a question (a vector
54474
+ * store's), which puts Model ▸ Find by meaning… in the menu.
54475
+ *
54476
+ * Spec: `{ id, type: 'requirements', title?, view?: 'map'|'backlog', ...the
54477
+ * model as RequirementsModel takes it }`.
54478
+ */
54479
+ class RequirementsPart extends ModelHosted(Part) {
54480
+ static type = 'requirements';
54481
+
54482
+ static VIEWS = VIEWS;
54483
+
54484
+ constructor(spec = {}) {
54485
+ super(spec);
54486
+ this._adoptModel(new RequirementsModel(spec));
54487
+ /** The view shown: 'map' or 'backlog'. */
54488
+ this.view = VIEWS.some(([id]) => id === spec.view) ? spec.view : 'map';
54489
+ this.menuBar = null;
54490
+ this.body = null;
54491
+ }
54492
+
54493
+ // ===== A model saved as a file, kept, backed up =====
54494
+
54495
+ /** Its own format first (what it saves as), then what other tools wrote (import only). */
54496
+ fileFormats() {
54497
+ return [
54498
+ RequirementsFileFormat.instance ??= new RequirementsFileFormat(),
54499
+ TrackerExportFileFormat.instance ??= new TrackerExportFileFormat(),
54500
+ StoryboardFileFormat.instance ??= new StoryboardFileFormat(),
54501
+ ];
54502
+ }
54503
+
54504
+ /** Edit another model (one opened, imported or kept). */
54505
+ replaceModel(model) {
54506
+ this._adoptModel(model);
54507
+ this.afterModelEdit();
54508
+ }
54509
+
54510
+ afterModelEdit() {
54511
+ if (this.menuBar) { this.menuBar.items = this.menuBarItems(); this.menuBar.render?.(); }
54512
+ this._draw();
54513
+ }
54514
+
54515
+ // ===== What a host adds =====
54516
+
54517
+ /** What a story can be traced to: `[{kind, ref, name, model?}]`. None here; a host gives its models' elements. */
54518
+ traceTargets() {
54519
+ return [];
54520
+ }
54521
+
54522
+ /** Whether a trace's target is there: true, false, or undefined (cannot tell). */
54523
+ resolveTrace(trace) {
54524
+ return undefined;
54525
+ }
54526
+
54527
+ /** A live state shown on a story's card: `{label, color?, title?}`, or null. */
54528
+ storyStatus(story) {
54529
+ return null;
54530
+ }
54531
+
54532
+ /**
54533
+ * The stories nearest a question by meaning, best first: a promise of
54534
+ * `[{id, score}]` (score 0..1). None here; a host with a vector store gives
54535
+ * them, and Model ▸ Find by meaning… is offered.
54536
+ */
54537
+ searchByMeaning(query) {
54538
+ return null;
54539
+ }
54540
+
54541
+ /** Whether stories can be found by meaning here (a host gives searchByMeaning). */
54542
+ get searchable() {
54543
+ return this.searchByMeaning !== RequirementsPart.prototype.searchByMeaning;
54544
+ }
54545
+
54546
+ // ===== Shown =====
54547
+
54548
+ render() {
54549
+ Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
54550
+ const toolbar = this.container.appendChild(el({}, this.document));
54551
+ this.body = this.container.appendChild(el({ style: { flex: '1 1 auto', minHeight: '0', overflow: 'auto', padding: '8px', background: 'var(--ws-bg, #fafafa)' } }, this.document));
54552
+ this.menuBar = new MenuBar({ container: toolbar, items: this.menuBarItems(), document: this.document }).render();
54553
+ this._draw();
54554
+ }
54555
+
54556
+ /** Draw the view shown. @private */
54557
+ _draw() {
54558
+ if (!this.body) return;
54559
+ this.body.replaceChildren(el(this.view === 'backlog' ? this.backlogSpec() : this.storyMapSpec(), this.document));
54560
+ }
54561
+
54562
+ /** Show the story map or the backlog. */
54563
+ setView(view) {
54564
+ if (!VIEWS.some(([id]) => id === view)) return;
54565
+ this.view = view;
54566
+ this._draw();
54567
+ this.menuBar?.refresh?.();
54568
+ }
54569
+
54570
+ menuBarItems() {
54571
+ const host = this.modelsHost;
54572
+ return [
54573
+ new ToolbarMenu('File', () => this.fileItems()),
54574
+ new ToolbarMenu('Edit', () => this.undoItems()),
54575
+ ...(host ? [new ToolbarMenu('Tools', () => host.toolItems(this))] : []),
54576
+ new ToolbarMenu('Model', () => this.modelItems()),
54577
+ new ToolbarSelect('View', () => VIEWS.map(([id, label]) => [id, label]), () => this.view, (v) => this.setView(v)),
54578
+ new ToolbarText(() => this.coverageText()),
54579
+ ];
54580
+ }
54581
+
54582
+ /** How complete it is, in a line: stories, with criteria, traced. */
54583
+ coverageText() {
54584
+ const c = this.model.coverage();
54585
+ if (!c.stories) return 'No stories yet';
54586
+ const pct = (share) => `${Math.round(share * 100)}%`;
54587
+ return `${c.stories} stor${c.stories === 1 ? 'y' : 'ies'} · ${pct(c.criteriaShare)} with acceptance criteria · ${pct(c.traceShare)} traced`;
54588
+ }
54589
+
54590
+ /** Model ▸: new epic, release and story; stories brought in; the check. */
54591
+ modelItems() {
54592
+ return [
54593
+ { label: 'New story…', action: () => this.editStory(null) },
54594
+ { label: 'New epic…', action: () => this.editEpic(null) },
54595
+ { label: 'New release…', action: () => this.editRelease(null) },
54596
+ { separator: true },
54597
+ ...EditorPart.menuGroup('Bring in stories', [
54598
+ { label: 'From a tracker\'s export (Jira, Azure DevOps, GitLab)…', action: () => this.bringFrom(this.fileFormats()[1]) },
54599
+ { label: 'From a storyboard (Markdown)…', action: () => this.bringFrom(this.fileFormats()[2]) },
54600
+ ]),
54601
+ { separator: true },
54602
+ ...(this.searchable ? [{ label: 'Find by meaning…', action: () => this.findByMeaning() }] : []),
54603
+ { label: 'Check model…', action: () => this.checkModel() },
54604
+ ];
54605
+ }
54606
+
54607
+ /** Ask what to look for, then list the stories nearest it by meaning; one chosen is opened. */
54608
+ findByMeaning() {
54609
+ return new FormDialog({
54610
+ title: 'Find stories by meaning', document: this.document, submitLabel: 'Find',
54611
+ description: 'Describe what you are looking for in your own words: stories that mean the same are found, whatever their words.',
54612
+ fields: [{ key: 'query', label: 'Looking for', value: '' }],
54613
+ validate: (v) => (String(v.query ?? '').trim() ? null : 'Say what to look for'),
54614
+ onApply: ({ query }) => this.showFound(query),
54615
+ }).open();
54616
+ }
54617
+
54618
+ /** The stories nearest `query` (searchByMeaning), listed to open; a failure said. */
54619
+ async showFound(query) {
54620
+ let results;
54621
+ try {
54622
+ results = (await this.searchByMeaning(query)) ?? [];
54623
+ } catch (err) {
54624
+ (this.document.defaultView ?? globalThis).alert?.(`Could not search: ${err.message}`);
54625
+ return null;
54626
+ }
54627
+ const found = results.filter(r => this.model.story(r.id)).map(r => ({
54628
+ code: 'found', severity: 'info', kind: 'story', ids: [r.id],
54629
+ message: `${Math.round((r.score ?? 0) * 100)}% — ${this.model.story(r.id).name}`,
54630
+ }));
54631
+ new ModelCheckDialog({
54632
+ findings: found, title: `Stories like "${query}"`, empty: 'No story found: index the model for search, or ask another way.',
54633
+ onReveal: (f) => this.revealFinding(f), document: this.document,
54634
+ }).open();
54635
+ return found;
54636
+ }
54637
+
54638
+ /**
54639
+ * Bring a file's epics, releases and stories into this model (an import
54640
+ * format's spec), as one undo step: one with the id of one here replaces it.
54641
+ */
54642
+ bringFrom(format, options = {}) {
54643
+ return this.readInto({
54644
+ ...options, accept: format.accept, what: 'the file',
54645
+ command: (bytes, name) => {
54646
+ const spec = format.spec(new TextDecoder().decode(bytes));
54647
+ if (!spec.stories.length && !spec.epics.length) return null;
54648
+ return this.model.bringCommand(spec, `Bring in "${name}"`);
54649
+ },
54650
+ nothing: (name) => `"${name}" has no epics or stories.`,
54651
+ });
54652
+ }
54653
+
54654
+ /** The findings (RequirementsChecker, traces resolved by the host); one opened edits its story or epic. */
54655
+ checkModel() {
54656
+ const findings = this.model.checker({ resolve: (trace) => this.resolveTrace(trace) }).run();
54657
+ new ModelCheckDialog({ findings, onReveal: (f) => this.revealFinding(f), document: this.document }).open();
54658
+ return findings;
54659
+ }
54660
+
54661
+ /** Open what a finding is about. */
54662
+ revealFinding(finding) {
54663
+ const [id] = finding.ids ?? [];
54664
+ if (finding.kind === 'epic') return this.editEpic(id);
54665
+ if (finding.kind === 'story') return this.editStory(id);
54666
+ return null;
54667
+ }
54668
+
54669
+ // ===== The views, as element specs =====
54670
+
54671
+ /** A story as a card: its name, its sentence, and badges (criteria, traces, points, status). */
54672
+ cardSpec(story) {
54673
+ const live = this.storyStatus(story);
54674
+ const badges = [
54675
+ story.criteria.length ? `✓ ${story.criteria.length}` : '✓ none',
54676
+ story.traces.length ? `→ ${story.traces.length}` : null,
54677
+ story.points != null ? `${story.points} pt` : null,
54678
+ story.status || null,
54679
+ ].filter(Boolean);
54680
+ return {
54681
+ cls: 'rq-card', dataset: { storyId: story.id }, attrs: { draggable: 'true', title: RequirementsModel.narrative(story) || story.description || story.name },
54682
+ style: { ...CARD, ...(story.criteria.length ? {} : { borderLeftColor: '#e08a3c' }) },
54683
+ on: {
54684
+ click: () => this.editStory(story.id),
54685
+ contextmenu: (e) => { e.preventDefault(); PopupMenu.show(e.clientX, e.clientY, this.storyMenuItems(story.id), this.document); },
54686
+ dragstart: (e) => { e.dataTransfer?.setData('text/plain', story.id); },
54687
+ dragover: (e) => e.preventDefault(),
54688
+ drop: (e) => { e.preventDefault(); e.stopPropagation(); this.dropStory(e.dataTransfer?.getData('text/plain'), { before: story.id, epic: story.epic, release: story.release }); },
54689
+ },
54690
+ children: [
54691
+ { text: story.name, style: { fontWeight: '600' } },
54692
+ ...(story.want ? [{ text: RequirementsModel.narrative(story), style: { color: 'var(--ws-muted, #666)', margin: '2px 0' } }] : []),
54693
+ {
54694
+ style: { marginTop: '4px' },
54695
+ children: [
54696
+ ...badges.map(text => ({ tag: 'span', text, style: BADGE })),
54697
+ ...(live ? [{ tag: 'span', text: live.label, attrs: { title: live.title ?? '' }, style: { ...BADGE, color: '#fff', background: live.color ?? '#557', borderRadius: '8px', padding: '0 6px' } }] : []),
54698
+ ],
54699
+ },
54700
+ ],
54701
+ };
54702
+ }
54703
+
54704
+ /** The story map: a table, epics across, releases down; a cell takes a card dropped on it, and + adds one there. */
54705
+ storyMapSpec() {
54706
+ const map = this.model.storyMap();
54707
+ const drop = (epic, release) => ({
54708
+ dragover: (e) => e.preventDefault(),
54709
+ drop: (e) => { e.preventDefault(); this.dropStory(e.dataTransfer?.getData('text/plain'), { epic, release }); },
54710
+ });
54711
+ return {
54712
+ tag: 'table', cls: 'rq-map', style: { borderCollapse: 'collapse', color: 'var(--ws-fg, #222)' },
54713
+ children: [
54714
+ {
54715
+ tag: 'thead', children: [{
54716
+ tag: 'tr', children: [
54717
+ { tag: 'th', style: HEAD },
54718
+ ...map.columns.map(c => ({
54719
+ tag: 'th', text: c.name, dataset: { epicId: c.id }, style: { ...HEAD, background: 'var(--ws-panel-bg, #fff)', borderBottom: '3px solid #6b8fd6' },
54720
+ attrs: { title: c.epic?.description ?? '' },
54721
+ on: c.epic ? { click: () => this.editEpic(c.id) } : {},
54722
+ })),
54723
+ ],
54724
+ }],
54725
+ },
54726
+ {
54727
+ tag: 'tbody', children: map.rows.map(r => ({
54728
+ tag: 'tr', dataset: { releaseId: r.id }, children: [
54729
+ { tag: 'th', text: r.name, style: { ...HEAD, color: 'var(--ws-muted, #666)' }, on: r.release ? { click: () => this.editRelease(r.id) } : {} },
54730
+ ...map.columns.map(c => ({
54731
+ tag: 'td', cls: 'rq-cell', dataset: { epicId: c.id, releaseId: r.id }, style: CELL, on: drop(c.id, r.id),
54732
+ children: [
54733
+ ...map.cell(c.id, r.id).map(s => this.cardSpec(s)),
54734
+ { tag: 'button', text: '+', attrs: { title: 'New story here' }, style: { border: 'none', background: 'none', color: 'var(--ws-muted, #888)', cursor: 'pointer' }, on: { click: () => this.editStory(null, { epic: c.id, release: r.id }) } },
54735
+ ],
54736
+ })),
54737
+ ],
54738
+ })),
54739
+ },
54740
+ ],
54741
+ };
54742
+ }
54743
+
54744
+ /** The backlog: every story in order, a row each. */
54745
+ backlogSpec() {
54746
+ const epicName = (id) => this.model.epic(id)?.name ?? '';
54747
+ const releaseName = (id) => this.model.release(id)?.name ?? '';
54748
+ const columns = ['Story', 'Epic', 'Release', 'Status', 'Priority', 'Points', 'Criteria', 'Traces'];
54749
+ return {
54750
+ tag: 'table', cls: 'rq-backlog', style: { borderCollapse: 'collapse', width: '100%', fontSize: '12px', color: 'var(--ws-fg, #222)' },
54751
+ children: [
54752
+ { tag: 'thead', children: [{ tag: 'tr', children: columns.map(c => ({ tag: 'th', text: c, style: { ...HEAD, borderBottom: '1px solid var(--ws-border, #ccc)' } })) }] },
54753
+ {
54754
+ tag: 'tbody', children: this.model.stories.map(s => ({
54755
+ tag: 'tr', dataset: { storyId: s.id }, style: { cursor: 'pointer' },
54756
+ on: { click: () => this.editStory(s.id), contextmenu: (e) => { e.preventDefault(); PopupMenu.show(e.clientX, e.clientY, this.storyMenuItems(s.id), this.document); } },
54757
+ children: [s.name, epicName(s.epic), releaseName(s.release), s.status, s.priority, s.points ?? '', s.criteria.length, s.traces.length]
54758
+ .map(text => ({ tag: 'td', text, style: { padding: '4px 8px', borderBottom: '1px solid var(--ws-border, #eee)' } })),
54759
+ })),
54760
+ },
54761
+ ],
54762
+ };
54763
+ }
54764
+
54765
+ /** A card dropped: the story moved there (before the card it fell on). */
54766
+ dropStory(id, { epic, release, before = null }) {
54767
+ if (!id || !this.model.story(id) || id === before) return null;
54768
+ return this.runModelEdit(this.model.moveStoryCommand(id, { epic, release, before }));
54769
+ }
54770
+
54771
+ /** A story's right-click menu. */
54772
+ storyMenuItems(id) {
54773
+ return [
54774
+ { label: 'Edit story…', action: () => this.editStory(id) },
54775
+ { label: 'Trace to…', action: () => this.editTraces(id) },
54776
+ { separator: true },
54777
+ { label: 'Remove story', action: () => this.runModelEdit(this.model.removeStoryCommand(id)) },
54778
+ ];
54779
+ }
54780
+
54781
+ // ===== Dialogs =====
54782
+
54783
+ /** The fields a story is edited in (one definition: the dialog shows them, saveStory reads them). */
54784
+ storyFields(story = {}) {
54785
+ const choices = (list, none) => [['', none], ...list.map(x => [x.id, x.name])];
54786
+ const statuses = [...new Set([...RequirementsModel.STATUSES, ...(story.status ? [story.status] : [])])];
54787
+ return [
54788
+ { key: 'name', label: 'Story', value: story.name ?? '' },
54789
+ { key: 'epic', label: 'Epic', type: 'select', value: story.epic ?? '', choices: choices(this.model.epics, 'No epic') },
54790
+ { key: 'release', label: 'Release', type: 'select', value: story.release ?? '', choices: choices(this.model.releases, 'Not planned') },
54791
+ { type: 'heading', label: 'As a … I want … so that …' },
54792
+ { key: 'as', label: 'As a', value: story.as ?? '' },
54793
+ { key: 'want', label: 'I want', value: story.want ?? '' },
54794
+ { key: 'soThat', label: 'So that', value: story.soThat ?? '' },
54795
+ { key: 'description', label: 'Description', type: 'textarea', rows: 3, value: story.description ?? '' },
54796
+ { key: 'criteria', label: 'Acceptance criteria (one a line)', type: 'textarea', rows: 4, value: (story.criteria ?? []).map(c => c.text).join('\n') },
54797
+ { key: 'status', label: 'Status', type: 'select', value: story.status ?? '', choices: [['', '—'], ...statuses.map(s => [s, s])] },
54798
+ { key: 'priority', label: 'Priority', value: story.priority ?? '' },
54799
+ { key: 'points', label: 'Points', type: 'number', value: story.points ?? '' },
54800
+ { key: 'labels', label: 'Labels (comma between)', value: (story.labels ?? []).join(', ') },
54801
+ ];
54802
+ }
54803
+
54804
+ /**
54805
+ * A story's values from its dialog, as RequirementsModel takes them; a
54806
+ * criterion whose text is unchanged keeps its id.
54807
+ */
54808
+ storyFromValues(values, story = null) {
54809
+ const old = new Map((story?.criteria ?? []).map(c => [c.text, c.id]));
54810
+ return {
54811
+ name: String(values.name ?? '').trim(), epic: values.epic ?? '', release: values.release ?? '',
54812
+ as: values.as ?? '', want: values.want ?? '', soThat: values.soThat ?? '', description: values.description ?? '',
54813
+ criteria: String(values.criteria ?? '').split('\n').map(t => t.trim()).filter(Boolean).map(text => (old.has(text) ? { id: old.get(text), text } : text)),
54814
+ status: values.status ?? '', priority: values.priority ?? '',
54815
+ points: values.points === '' || values.points == null || Number.isNaN(Number(values.points)) ? null : Number(values.points),
54816
+ labels: String(values.labels ?? '').split(',').map(l => l.trim()).filter(Boolean),
54817
+ };
54818
+ }
54819
+
54820
+ /** Edit a story (`id` null: a new one, where `at` says). */
54821
+ editStory(id, at = {}) {
54822
+ const story = id ? this.model.story(id) : null;
54823
+ if (id && !story) return null;
54824
+ return new FormDialog({
54825
+ title: story ? `Story: ${story.name}` : 'New story', document: this.document, submitLabel: story ? 'Save' : 'Add',
54826
+ fields: this.storyFields(story ?? { epic: at.epic ?? '', release: at.release ?? '' }),
54827
+ validate: (v) => (String(v.name ?? '').trim() ? null : 'A story needs a name'),
54828
+ onApply: (values) => this.runModelEdit(this.model.saveStoryCommand(id, this.storyFromValues(values, story))),
54829
+ }).open();
54830
+ }
54831
+
54832
+ /** Edit a story's traces: each a row (kind, what, its name), what the host offers suggested. */
54833
+ editTraces(id) {
54834
+ const story = this.model.story(id);
54835
+ if (!story) return null;
54836
+ const targets = this.traceTargets();
54837
+ return new FormDialog({
54838
+ title: `Traces: ${story.name}`, document: this.document,
54839
+ description: 'What realises this story: an ArchiMate requirement or work package, an element, a data concept, entity or table.',
54840
+ fields: [{
54841
+ key: 'traces', label: '', type: 'rows', addLabel: 'Add trace', value: story.traces,
54842
+ columns: [
54843
+ { key: 'kind', label: 'Kind', type: 'select', choices: RequirementsModel.TRACE_KINDS },
54844
+ { key: 'ref', label: 'What (its id)', suggestions: targets.map(t => t.ref) },
54845
+ { key: 'name', label: 'Name', suggestions: targets.map(t => t.name).filter(Boolean) },
54846
+ ],
54847
+ }],
54848
+ onApply: ({ traces = [] }) => {
54849
+ // A ref the host knows names itself, and says which model it is in.
54850
+ const known = new Map(targets.map(t => [t.ref, t]));
54851
+ const rows = traces.filter(t => t.ref).map(t => ({ ...known.get(t.ref), ...t, name: t.name || known.get(t.ref)?.name || '' }));
54852
+ return this.runModelEdit(this.model.saveStoryCommand(id, { traces: rows }));
54853
+ },
54854
+ }).open();
54855
+ }
54856
+
54857
+ editEpic(id) {
54858
+ const epic = id ? this.model.epic(id) : null;
54859
+ if (id && !epic) return null;
54860
+ return new FormDialog({
54861
+ title: epic ? `Epic: ${epic.name}` : 'New epic', document: this.document, submitLabel: epic ? 'Save' : 'Add',
54862
+ fields: [
54863
+ { key: 'name', label: 'Epic', value: epic?.name ?? '' },
54864
+ { key: 'description', label: 'Outcome', type: 'textarea', rows: 3, value: epic?.description ?? '' },
54865
+ ],
54866
+ validate: (v) => (String(v.name ?? '').trim() ? null : 'An epic needs a name'),
54867
+ onApply: (v) => this.runModelEdit(this.model.saveEpicCommand(id, { name: v.name.trim(), description: v.description ?? '' })),
54868
+ }).open();
54869
+ }
54870
+
54871
+ editRelease(id) {
54872
+ const release = id ? this.model.release(id) : null;
54873
+ if (id && !release) return null;
54874
+ return new FormDialog({
54875
+ title: release ? `Release: ${release.name}` : 'New release', document: this.document, submitLabel: release ? 'Save' : 'Add',
54876
+ fields: [{ key: 'name', label: 'Release', value: release?.name ?? '' }],
54877
+ validate: (v) => (String(v.name ?? '').trim() ? null : 'A release needs a name'),
54878
+ onApply: (v) => this.runModelEdit(this.model.saveReleaseCommand(id, { name: v.name.trim() })),
54879
+ }).open();
54880
+ }
54881
+
54882
+ dispose() {
54883
+ this.menuBar?.dispose?.();
54884
+ super.dispose();
54885
+ }
54886
+
54887
+ /** The spec again: the model, and the view shown. */
54888
+ serialize() {
54889
+ return { ...super.serialize(), ...this.model.toJSON(), view: this.view };
54890
+ }
53164
54891
  }
53165
54892
 
53166
54893
  const SVG_NS$1 = 'http://www.w3.org/2000/svg';
@@ -53872,6 +55599,7 @@ th { color: #555; font-weight: 500; }
53872
55599
  customViewpoints,
53873
55600
  // Canvas templates: placed on a canvas, saved from its shapes (the browser's library).
53874
55601
  templateMenu: this.templateMenu,
55602
+ ...this.editorHooks(),
53875
55603
  // Version and distinct shapes (⚙ Settings) change the palette too.
53876
55604
  onNodeOptionsChange: ({ archimateVersion, useDistinctShapes, viewpoint, viewpointMode, showDerived, showNestedLines }) => {
53877
55605
  this.showDerived = !!showDerived;
@@ -53938,11 +55666,40 @@ th { color: #555; font-weight: 500; }
53938
55666
  this.editor.render();
53939
55667
  }
53940
55668
 
55669
+ /**
55670
+ * A host's live status for an element — `{ text, color }`, drawn as a pill
55671
+ * on its boxes — or null. None here: a host subclass says (the workbench:
55672
+ * a component's lifecycle state). Asked whenever the view is drawn.
55673
+ */
55674
+ elementStatus(_element) {
55675
+ return null;
55676
+ }
55677
+
55678
+ /** A host's menu items for an element, after the editor's own on a box and in the tree. None here. */
55679
+ elementMenuExtras(_element) {
55680
+ return [];
55681
+ }
55682
+
55683
+ /** What the editor asks this part per element: its live status and its menu items (a host's). */
55684
+ editorHooks() {
55685
+ return {
55686
+ elementStatus: (element) => this.elementStatus(element),
55687
+ elementMenuExtras: (element) => this.elementMenuExtras(element),
55688
+ };
55689
+ }
55690
+
53941
55691
  /**
53942
55692
  * A model element's menu in the tree (elementMenuItems): its properties,
53943
- * the views it is on, adding it to this one, a view generated for it.
55693
+ * the views it is on, adding it to this one, a view generated for it; then
55694
+ * the host's (elementMenuExtras).
53944
55695
  */
53945
55696
  elementMenu(element) {
55697
+ const extras = this.elementMenuExtras(element) ?? [];
55698
+ return [...this._ownElementMenu(element), ...(extras.length ? [{ separator: true }, ...extras] : [])];
55699
+ }
55700
+
55701
+ /** @private */
55702
+ _ownElementMenu(element) {
53946
55703
  const model = this.model;
53947
55704
  return elementMenuItems(element, {
53948
55705
  model, view: model.currentView,
@@ -54048,6 +55805,7 @@ th { color: #555; font-weight: 500; }
54048
55805
  exports.BUILTIN_THEMES = BUILTIN_THEMES;
54049
55806
  exports.BackgroundCommand = BackgroundCommand;
54050
55807
  exports.BackgroundGroup = BackgroundGroup;
55808
+ exports.Benchmark = Benchmark;
54051
55809
  exports.BianActionTerm = BianActionTerm;
54052
55810
  exports.BianFunctionalPattern = BianFunctionalPattern;
54053
55811
  exports.BianLandscapeReader = BianLandscapeReader;
@@ -54134,6 +55892,7 @@ th { color: #555; font-weight: 500; }
54134
55892
  exports.EditHintCommand = EditHintCommand;
54135
55893
  exports.EditPropertyCommand = EditPropertyCommand;
54136
55894
  exports.EditViewItemCommand = EditViewItemCommand;
55895
+ exports.EditableModel = EditableModel;
54137
55896
  exports.EditorPart = EditorPart;
54138
55897
  exports.EditorShell = EditorShell;
54139
55898
  exports.ElementFilterRule = ElementFilterRule;
@@ -54189,7 +55948,9 @@ th { color: #555; font-weight: 500; }
54189
55948
  exports.Mat4 = Mat4;
54190
55949
  exports.MeasuredCategory = MeasuredCategory;
54191
55950
  exports.MenuBar = MenuBar;
55951
+ exports.ModelEditCommand = ModelEditCommand;
54192
55952
  exports.ModelFileFormat = ModelFileFormat;
55953
+ exports.ModelHosted = ModelHosted;
54193
55954
  exports.ModelScorecard = ModelScorecard;
54194
55955
  exports.ModelTree = ModelTree;
54195
55956
  exports.ModelsPart = ModelsPart;
@@ -54229,6 +55990,10 @@ th { color: #555; font-weight: 500; }
54229
55990
  exports.RemoveLineCommand = RemoveLineCommand;
54230
55991
  exports.RenderEngine = RenderEngine;
54231
55992
  exports.ReorderCommand = ReorderCommand;
55993
+ exports.RequirementsChecker = RequirementsChecker;
55994
+ exports.RequirementsFileFormat = RequirementsFileFormat;
55995
+ exports.RequirementsModel = RequirementsModel;
55996
+ exports.RequirementsPart = RequirementsPart;
54232
55997
  exports.ResizeCommand = ResizeCommand;
54233
55998
  exports.RotateCommand = RotateCommand;
54234
55999
  exports.RoundedRectangleTemplate = RoundedRectangleTemplate;
@@ -54248,9 +56013,12 @@ th { color: #555; font-weight: 500; }
54248
56013
  exports.SqlDdlFileFormat = SqlDdlFileFormat;
54249
56014
  exports.StadiumTemplate = StadiumTemplate;
54250
56015
  exports.StarTemplate = StarTemplate;
56016
+ exports.Storyboard = Storyboard;
56017
+ exports.StoryboardFileFormat = StoryboardFileFormat;
54251
56018
  exports.StructuredType = StructuredType;
54252
56019
  exports.SwimlaneTemplate = SwimlaneTemplate;
54253
56020
  exports.THEME_VARS = THEME_VARS;
56021
+ exports.TRACKERS = TRACKERS;
54254
56022
  exports.TREE_LAYERS = TREE_LAYERS;
54255
56023
  exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
54256
56024
  exports.TabsNode = TabsNode;
@@ -54265,6 +56033,8 @@ th { color: #555; font-weight: 500; }
54265
56033
  exports.ToolbarSelect = ToolbarSelect;
54266
56034
  exports.ToolbarSeparator = ToolbarSeparator;
54267
56035
  exports.ToolbarText = ToolbarText;
56036
+ exports.TrackerExport = TrackerExport;
56037
+ exports.TrackerExportFileFormat = TrackerExportFileFormat;
54268
56038
  exports.TrapezoidTemplate = TrapezoidTemplate;
54269
56039
  exports.TreeModeRegistry = TreeModeRegistry;
54270
56040
  exports.TriangleTemplate = TriangleTemplate;