@peter.prib/renderengine 0.2.11 → 0.5.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 (41) hide show
  1. package/README.md +22 -6
  2. package/REFERENCE.md +132 -2
  3. package/dist/renderengine-workspace.umd.js +3346 -1245
  4. package/dist/renderengine.umd.js +1509 -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/AzureDevOpsApi.js +55 -0
  19. package/src/charting/requirements/GitLabApi.js +43 -0
  20. package/src/charting/requirements/JiraApi.js +69 -0
  21. package/src/charting/requirements/RequirementsChecker.js +76 -0
  22. package/src/charting/requirements/RequirementsCompared.js +33 -0
  23. package/src/charting/requirements/RequirementsFileFormat.js +32 -0
  24. package/src/charting/requirements/RequirementsImportFormats.js +48 -0
  25. package/src/charting/requirements/RequirementsModel.js +305 -0
  26. package/src/charting/requirements/Storyboard.js +94 -0
  27. package/src/charting/requirements/TrackerApi.js +142 -0
  28. package/src/charting/requirements/TrackerExport.js +274 -0
  29. package/src/charting/requirements/index.js +12 -0
  30. package/src/charting/requirements/trackerApis.js +21 -0
  31. package/src/index.js +7 -0
  32. package/src/workspace/Workspace.js +329 -321
  33. package/src/workspace/index.js +2 -0
  34. package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
  35. package/src/workspace/parts/DataModelPart.js +668 -735
  36. package/src/workspace/parts/EditorPart.js +1 -1
  37. package/src/workspace/parts/ModelFile.js +3 -1
  38. package/src/workspace/parts/ModelHosted.js +176 -0
  39. package/src/workspace/parts/ModelsPart.js +146 -7
  40. package/src/workspace/parts/RequirementsPart.js +465 -0
  41. package/src/workspace/storage/ModelLibrary.js +8 -2
@@ -19756,14 +19756,19 @@
19756
19756
 
19757
19757
  /**
19758
19758
  * Fruchterman–Reingold: lines pull their ends together, every pair of boxes
19759
- * pushes apart, cooling over the iterations. Deterministic: it starts from
19760
- * where the boxes are (boxes on one spot are spread round it first).
19759
+ * pushes apart, cooling over the iterations, all within a frame — a square
19760
+ * round where the boxes were, room for them a grid's worth — so a box with no
19761
+ * lines is not pushed away for ever. Deterministic: it starts from where the
19762
+ * boxes are (boxes on one spot are spread round it first).
19761
19763
  */
19762
19764
  function forceDirected(boxes, edges, gap) {
19763
19765
  const size = Math.max(...boxes.map(b => Math.max(b.width, b.height))) + gap;
19764
19766
  const k = size * 1.2;
19765
19767
  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 }));
19766
19768
  const index = new Map(boxes.map((b, i) => [b.id, i]));
19769
+ const centre = { x: pos.reduce((t, p) => t + p.x, 0) / pos.length, y: pos.reduce((t, p) => t + p.y, 0) / pos.length };
19770
+ const half = (k * (Math.ceil(Math.sqrt(boxes.length)) + 1)) / 2;
19771
+ const inFrame = (v, c) => Math.min(c + half, Math.max(c - half, v));
19767
19772
  let temperature = size * 2;
19768
19773
  for (let iteration = 0; iteration < 120; iteration++) {
19769
19774
  const shift = pos.map(() => ({ x: 0, y: 0 }));
@@ -19788,8 +19793,8 @@
19788
19793
  for (let i = 0; i < pos.length; i++) {
19789
19794
  const d = Math.max(1e-9, Math.hypot(shift[i].x, shift[i].y));
19790
19795
  const step = Math.min(d, temperature);
19791
- pos[i].x += (shift[i].x / d) * step;
19792
- pos[i].y += (shift[i].y / d) * step;
19796
+ pos[i].x = inFrame(pos[i].x + (shift[i].x / d) * step, centre.x);
19797
+ pos[i].y = inFrame(pos[i].y + (shift[i].y / d) * step, centre.y);
19793
19798
  }
19794
19799
  temperature *= 0.95;
19795
19800
  }
@@ -24639,6 +24644,8 @@
24639
24644
  this.instance = instance;
24640
24645
  this.model = model;
24641
24646
  this.nodeOptions = nodeOptions;
24647
+ /** A host's live status, `{text, color}` (the editor's elementStatus), or null: drawn as a pill. */
24648
+ this.status = null;
24642
24649
  }
24643
24650
 
24644
24651
  /** State lives on the instance and the model element, not here. @protected */
@@ -24754,6 +24761,27 @@
24754
24761
  * gets no glyph badge (buildNode leaves iconShape unset) but keeps an icon the element sets.
24755
24762
  */
24756
24763
  decorations(ctx) {
24764
+ return [...this._badge(ctx), ...this.statusPill(ctx)];
24765
+ }
24766
+
24767
+ /**
24768
+ * The host's status (`status`), as a small pill inside the box's bottom
24769
+ * right corner: its text, on its colour. Nothing without one.
24770
+ */
24771
+ statusPill({ w, h }) {
24772
+ const text = this.status?.text;
24773
+ if (!text) return [];
24774
+ const label = String(text);
24775
+ const ph = 12, pw = Math.min(w - 6, label.length * 5.5 + 10);
24776
+ const x = w / 2 - pw - 3, y = h / 2 - ph - 3;
24777
+ return [Glyph.group([
24778
+ Glyph.rect(x, y, pw, ph, this.status.color || '#455a64', null, { rx: 6, ry: 6 }),
24779
+ Glyph.text(label, x + pw / 2, y + ph / 2, { fill: '#ffffff', textAnchor: 'middle', verticalAlign: 'middle', fontSize: '8px' }),
24780
+ ], { 'pointer-events': 'none', class: 'element-status' })];
24781
+ }
24782
+
24783
+ /** The type badge (decorations). @private */
24784
+ _badge(ctx) {
24757
24785
  const node = this.node;
24758
24786
  const { w, h } = ctx;
24759
24787
  const fixed = ArchimateElementTemplate.badgeSize(w, h);
@@ -28223,7 +28251,9 @@
28223
28251
  * ModelCheckDialog — a model check's findings (ModelChecker#run), listed:
28224
28252
  * each a button (⚠ a warning, ℹ for a look) that shows what it names
28225
28253
  * (`onReveal`), with what can be done about it beside (`actionsFor`), or
28226
- * "nothing found". Any editor or part that checks a model shows it this way.
28254
+ * "nothing found". Any editor or part that checks a model shows it this way;
28255
+ * anything else listed to be opened (search results) too, with its own
28256
+ * `title` and `empty` text.
28227
28257
  */
28228
28258
  class ModelCheckDialog {
28229
28259
  /**
@@ -28232,9 +28262,13 @@
28232
28262
  * @param {(finding: Object) => void} [options.onReveal] - Show what a finding names
28233
28263
  * @param {(finding: Object) => Array<{label: string, action: Function}>} [options.actionsFor]
28234
28264
  * @param {Document} [options.document]
28265
+ * @param {string} [options.title] - In place of "Model check: n findings"
28266
+ * @param {string} [options.empty] - What an empty list says
28235
28267
  */
28236
- constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document }) {
28268
+ constructor({ findings, onReveal = () => {}, actionsFor = () => [], document: doc = globalThis.document, title = null, empty = 'Nothing found: the model passes every check.' }) {
28237
28269
  this.findings = findings;
28270
+ this._title = title;
28271
+ this.empty = empty;
28238
28272
  this.onReveal = onReveal;
28239
28273
  this.actionsFor = actionsFor;
28240
28274
  this.document = doc;
@@ -28242,6 +28276,7 @@
28242
28276
 
28243
28277
  /** The dialog's title: how many findings. */
28244
28278
  get title() {
28279
+ if (this._title) return this._title;
28245
28280
  const n = this.findings.length;
28246
28281
  return `Model check: ${n} finding${n === 1 ? '' : 's'}`;
28247
28282
  }
@@ -28267,7 +28302,7 @@
28267
28302
  on: { click: () => { dialog?.close(); act.action(); } },
28268
28303
  })),
28269
28304
  ],
28270
- })) : [{ text: 'Nothing found: the model passes every check.' }],
28305
+ })) : [{ text: this.empty }],
28271
28306
  }, this.document);
28272
28307
  dialog = new FormDialog({
28273
28308
  title: this.title,
@@ -33184,8 +33219,12 @@
33184
33219
  * elements (null cancels). Without it the first of KIND_PREFERENCE that is allowed is used.
33185
33220
  * @param {(nodeOptions: Object) => void} [options.onNodeOptionsChange] - The ⚙ Settings menu changed
33186
33221
  * the version or distinct shapes (the host may need to redraw more than the canvas, e.g. a palette)
33222
+ * @param {(element: Object) => ({text: string, color?: string}|null)} [options.elementStatus] - A host's
33223
+ * live status for an element (a component's lifecycle state), drawn on its boxes
33224
+ * @param {(element: Object) => Array<Object>} [options.elementMenuExtras] - A host's menu items for an
33225
+ * element (its transitions), at the end of a box's right-click menu
33187
33226
  */
33188
- constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, ...options }) {
33227
+ constructor({ view, model, nodeOptions = {}, chooseKind = null, onNodeOptionsChange = null, templateMenu = null, customViewpoints = [], settingsManager = null, elementStatus = null, elementMenuExtras = null, ...options }) {
33189
33228
  super({ ...options, templates: new Map(), edges: new Map(), history: view.history, arrangement: view.arrangement });
33190
33229
  this.view = view;
33191
33230
  this.model = model;
@@ -33198,6 +33237,9 @@
33198
33237
  this.settingsManager = settingsManager;
33199
33238
  /** Canvas templates (a CanvasTemplateMenu): placed on the canvas, saved from its shapes. */
33200
33239
  this.templateMenu = templateMenu;
33240
+ /** A host's live status per element, drawn on its boxes, and its menu items per element. */
33241
+ this.elementStatus = elementStatus;
33242
+ this.elementMenuExtras = elementMenuExtras;
33201
33243
  this.addDropHandler({
33202
33244
  accepts: (dt) => Array.from(dt.types).includes(ARCHIMATE_TYPE_MIME),
33203
33245
  drop: (dt, point) => this.addElement(dt.getData(ARCHIMATE_TYPE_MIME), point),
@@ -33235,6 +33277,7 @@
33235
33277
  ...(missing.length ? [{ label: `Add Missing Relationships (${missing.length})`, action: () => this.addMissingRelationships(elementId) }] : []),
33236
33278
  ...hintMenuItems(this.view, elementId, { onChange: () => this.render() }),
33237
33279
  ...(items.length ? [{ label: 'Save as template', items }] : []),
33280
+ ...(element && this.elementMenuExtras ? this.elementMenuExtras(element) ?? [] : []),
33238
33281
  ];
33239
33282
  }
33240
33283
 
@@ -33459,7 +33502,11 @@
33459
33502
  templates.set(inst.id, existing?.instance === inst ? existing
33460
33503
  : new ArchimateElementTemplate(inst, model, { nodeOptions: this.nodeOptions }));
33461
33504
  }
33462
- for (const t of templates.values()) t.nodeOptions = this.nodeOptions;
33505
+ for (const t of templates.values()) {
33506
+ t.nodeOptions = this.nodeOptions;
33507
+ // Asked afresh at every sync: a host's status is live, not part of the model.
33508
+ t.status = this.elementStatus ? this.elementStatus(t.instance.resolve(model)) ?? null : null;
33509
+ }
33463
33510
  // The view's groups (Archi groups: model folders with bounds on this view).
33464
33511
  for (const folder of view.placedFolders) {
33465
33512
  const existing = this.templates.get(folder.id);
@@ -33958,6 +34005,9 @@
33958
34005
  /** Whether files in this format can be imported (opened) as well as exported. A subclass that only exports says no. */
33959
34006
  get imports() { return true; }
33960
34007
 
34008
+ /** Whether a model can be exported in this format. A subclass that only reads (another tool's export) says no. */
34009
+ get exports() { return true; }
34010
+
33961
34011
  /** True when import takes the file's bytes (a zip archive), else its text. */
33962
34012
  get readsBytes() { return false; }
33963
34013
 
@@ -34017,7 +34067,7 @@
34017
34067
  const choose = chooseFile ?? ((accept) => pickFile(accept, doc()));
34018
34068
  const tell = report ?? ((message) => (doc().defaultView ?? globalThis).alert(message));
34019
34069
  const groups = formats.map(format => [
34020
- only !== 'import' && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
34070
+ only !== 'import' && format.exports !== false && enabled(format, 'export') && { label: only ? format.label : `Export ${format.label}`, action: () => save(format.export(model()), format.fileName) },
34021
34071
  only !== 'export' && format.imports && enabled(format, 'import') && {
34022
34072
  label: only ? `${format.label}…` : `Import ${format.label}…`,
34023
34073
  action: () => importFile(format, { choose, onImport, report: tell }),
@@ -42022,50 +42072,211 @@
42022
42072
  }
42023
42073
  }
42024
42074
 
42025
- /**
42026
- * DataModelEditCommand — one edit of a DataModel (an entity, an attribute, a
42027
- * relationship, a delete) as one undo step: run, it makes the change and keeps
42028
- * the model as it was before and after (DataModel#toJSON); undone or redone, it
42029
- * puts the model back as it was (DataModel#restore). Any change to a model is
42030
- * undoable this way, whatever it touches.
42031
- */
42032
- class DataModelEditCommand extends Command {
42033
- /** A model edit is no edit on one view: undoing it shows no other. */
42034
- viewId = null;
42035
-
42036
- /**
42037
- * @param {DataModel} model
42038
- * @param {string} label - What the undo menu calls it ('Edit Customer')
42039
- * @param {(model: DataModel) => *} change - Makes the change
42040
- */
42041
- constructor(model, label, change) {
42042
- super(label);
42043
- this.model = model;
42044
- this.change = change;
42045
- this.before = null;
42046
- this.after = null;
42047
- }
42048
-
42049
- execute() {
42050
- if (this.after) {
42051
- this.model.restore(this.after);
42052
- } else {
42053
- this.before = DataModelEditCommand.snapshot(this.model);
42054
- this.result = this.change(this.model);
42055
- this.after = DataModelEditCommand.snapshot(this.model);
42056
- }
42057
- this.model.edited();
42058
- }
42059
-
42060
- undo() {
42061
- this.model.restore(this.before);
42062
- this.model.edited();
42063
- }
42064
-
42065
- /** The model as it is, a copy nothing later changes (its toJSON shares some arrays). */
42066
- static snapshot(model) {
42067
- return JSON.parse(JSON.stringify(model.toJSON()));
42068
- }
42075
+ /**
42076
+ * ModelEditCommand — one edit of an EditableModel (a data model's entity, a
42077
+ * requirements model's story, a delete) as one undo step: run, it makes the
42078
+ * change and keeps the model as it was before and after (its toJSON); undone
42079
+ * or redone, it puts the model back as it was (its restore). Any change to a
42080
+ * model is undoable this way, whatever it touches.
42081
+ */
42082
+ class ModelEditCommand extends Command {
42083
+ /** A model edit is no edit on one view: undoing it shows no other. */
42084
+ viewId = null;
42085
+
42086
+ /**
42087
+ * @param {EditableModel} model
42088
+ * @param {string} label - What the undo menu calls it ('Edit Customer')
42089
+ * @param {(model: EditableModel) => *} change - Makes the change
42090
+ */
42091
+ constructor(model, label, change) {
42092
+ super(label);
42093
+ this.model = model;
42094
+ this.change = change;
42095
+ this.before = null;
42096
+ this.after = null;
42097
+ }
42098
+
42099
+ execute() {
42100
+ if (this.after) {
42101
+ this.model.restore(this.after);
42102
+ } else {
42103
+ this.before = ModelEditCommand.snapshot(this.model);
42104
+ this.result = this.change(this.model);
42105
+ this.after = ModelEditCommand.snapshot(this.model);
42106
+ }
42107
+ this.model.edited();
42108
+ }
42109
+
42110
+ undo() {
42111
+ this.model.restore(this.before);
42112
+ this.model.edited();
42113
+ }
42114
+
42115
+ /** The model as it is, a copy nothing later changes (its toJSON may share arrays). */
42116
+ static snapshot(model) {
42117
+ return JSON.parse(JSON.stringify(model.toJSON()));
42118
+ }
42119
+ }
42120
+
42121
+ /**
42122
+ * EditableModel — base of a model edited as a whole rather than view by view
42123
+ * (DataModel, RequirementsModel; a ViewSetModel keeps its views' histories
42124
+ * its own way). What a part needs of any model it edits, keeps and merges:
42125
+ *
42126
+ * - a name;
42127
+ * - whether it changed since it was saved (`edited()`, `markSaved()`,
42128
+ * `isDirty()`, `onEdit(fn)`), so a part shows it, keeps it and asks before
42129
+ * it is lost;
42130
+ * - an undo history, every edit one step on it (`editCommand(label, change)`:
42131
+ * the model before and after, as its toJSON, put back by `restore`);
42132
+ * - how it differs from another of its kind (`compareWith`, over the
42133
+ * subclass's `comparedKinds()`), which merges the other's into it.
42134
+ *
42135
+ * A subclass gives `toJSON()`, its constructor reading that back, and
42136
+ * `static STATE`: the fields `restore` puts back.
42137
+ */
42138
+ class EditableModel {
42139
+ /** The fields a snapshot puts back (restore). A subclass lists its own, `name` among them. */
42140
+ static STATE = Object.freeze(['name']);
42141
+
42142
+ /** @param {{name?: string}} [spec] */
42143
+ constructor(spec = {}) {
42144
+ if (new.target === EditableModel) throw new TypeError('EditableModel is abstract — subclass it');
42145
+ /** What the model is called. */
42146
+ this.name = spec.name ?? '';
42147
+ this._version = 0;
42148
+ this._saved = 0;
42149
+ this._editListeners = [];
42150
+ }
42151
+
42152
+ /** The kinds it is compared on and merged by (ComparedKind), in the order a merge brings them. None here. */
42153
+ comparedKinds() {
42154
+ return [];
42155
+ }
42156
+
42157
+ /** How this model differs from another (ModelComparison), which can merge the other's into it. */
42158
+ compareWith(other) {
42159
+ return new ModelComparison(this, other);
42160
+ }
42161
+
42162
+ /** Its undo history: a merge is one step on it. */
42163
+ get history() {
42164
+ return (this._history ??= new CommandHistory());
42165
+ }
42166
+
42167
+ /** A change made as one undo step (ModelEditCommand), not yet run. */
42168
+ editCommand(label, change) {
42169
+ return new ModelEditCommand(this, label, change);
42170
+ }
42171
+
42172
+ /** Put the model back as a snapshot (its toJSON) says: the fields its class lists (STATE). */
42173
+ restore(json) {
42174
+ const back = new this.constructor(JSON.parse(JSON.stringify(json)));
42175
+ for (const key of this.constructor.STATE) this[key] = back[key];
42176
+ }
42177
+
42178
+ // ===== Changed since saved =====
42179
+
42180
+ /** Something changed: listeners hear of it. */
42181
+ edited() {
42182
+ this._version++;
42183
+ this._emitEdit();
42184
+ }
42185
+
42186
+ /** This state is the saved one (a file opened or saved). */
42187
+ markSaved() {
42188
+ this._saved = this._version;
42189
+ this._emitEdit();
42190
+ }
42191
+
42192
+ /** Whether it changed since markSaved. */
42193
+ isDirty() {
42194
+ return this._version !== this._saved;
42195
+ }
42196
+
42197
+ /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
42198
+ onEdit(fn) {
42199
+ this._editListeners.push(fn);
42200
+ return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
42201
+ }
42202
+
42203
+ /** @private */
42204
+ _emitEdit() {
42205
+ for (const fn of this._editListeners) fn(this);
42206
+ }
42207
+
42208
+ /** An id made from `name`, unlike any of `taken`'s ids ('customer', 'customer-2'). */
42209
+ _freshId(name, taken) {
42210
+ const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
42211
+ let id = base;
42212
+ for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
42213
+ return id;
42214
+ }
42215
+
42216
+ /** The model as plain JSON, read back by its constructor. @abstract */
42217
+ toJSON() {
42218
+ throw new Error(`${this.constructor.name} must implement toJSON()`);
42219
+ }
42220
+ }
42221
+
42222
+ /**
42223
+ * Benchmark — what a model is measured by, so a change to it (a proposal, a
42224
+ * merge) says whether it made the model better. Each measure:
42225
+ * `{ key, label, value, better: 'lower'|'higher', format?: 'count'|'share'|'score' }`
42226
+ * (a share is 0..1; a value of null is not measured yet).
42227
+ *
42228
+ * Every model with a checker is measured by its findings — warnings counted
42229
+ * apart (`Benchmark.findings`); a model with more to say gives its own
42230
+ * `benchmark()` (a data model its Scorecard, a requirements model how many
42231
+ * stories say when they are done and trace to something), starting from those.
42232
+ */
42233
+ class Benchmark {
42234
+ /** A model's measures: its own benchmark(), else its checker's findings, else none. */
42235
+ static of(model) {
42236
+ if (typeof model?.benchmark === 'function') return model.benchmark();
42237
+ return Benchmark.findings(model);
42238
+ }
42239
+
42240
+ /** The check's warnings and all its findings, each fewer the better. */
42241
+ static findings(model) {
42242
+ if (typeof model?.checker !== 'function') return [];
42243
+ const found = model.checker().run();
42244
+ return [
42245
+ { key: 'warnings', label: 'Check warnings', value: found.filter(f => f.severity === 'warning').length, better: 'lower', format: 'count' },
42246
+ { key: 'findings', label: 'Check findings', value: found.length, better: 'lower', format: 'count' },
42247
+ ];
42248
+ }
42249
+
42250
+ /**
42251
+ * Two models' measures side by side, matched by key: `{key, label, format,
42252
+ * before, after, change}` — change 'better', 'worse', 'same', or null when
42253
+ * either is not measured.
42254
+ */
42255
+ static compare(before, after) {
42256
+ const then = new Map(before.map(m => [m.key, m]));
42257
+ return after.filter(m => then.has(m.key)).map(m => {
42258
+ const was = then.get(m.key).value, now = m.value;
42259
+ const change = was == null || now == null ? null
42260
+ : was === now ? 'same'
42261
+ : (now < was) === (m.better === 'lower') ? 'better' : 'worse';
42262
+ return { key: m.key, label: m.label, format: m.format ?? 'count', before: was, after: now, change };
42263
+ });
42264
+ }
42265
+
42266
+ /** A measure's value as it reads ('3', '50%', '72 of 100', '—'). */
42267
+ static format(value, format = 'count') {
42268
+ if (value == null) return '—';
42269
+ if (format === 'share') return `${Math.round(value * 100)}%`;
42270
+ if (format === 'score') return `${value} of 100`;
42271
+ return String(value);
42272
+ }
42273
+
42274
+ /** A comparison in a line: "Check findings 7 → 3 (better); …" — what did not change left out unless nothing did. */
42275
+ static text(rows) {
42276
+ const moved = rows.filter(r => r.change !== 'same');
42277
+ const shown = moved.length ? moved : rows;
42278
+ 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('; ');
42279
+ }
42069
42280
  }
42070
42281
 
42071
42282
  /** Words that stay small inside a title. */
@@ -42719,8 +42930,42 @@
42719
42930
  }
42720
42931
  }
42721
42932
 
42933
+ /** A plain copy of JSON-shaped data. */
42934
+ const copy$2 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
42935
+
42936
+ /** 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. */
42937
+ class ListCompared extends ComparedKind {
42938
+ constructor() {
42939
+ super();
42940
+ if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
42941
+ }
42942
+
42943
+ /** The array the model holds them in. @abstract */
42944
+ list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
42945
+
42946
+ items(model) { return this.list(model); }
42947
+ get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
42948
+
42949
+ /** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
42950
+ bring(model, item) {
42951
+ const list = this.list(model);
42952
+ const twin = this.copyOf(item);
42953
+ const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
42954
+ if (at < 0) {
42955
+ list.push(twin);
42956
+ return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
42957
+ }
42958
+ const old = list[at];
42959
+ list[at] = twin;
42960
+ return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
42961
+ }
42962
+
42963
+ /** A copy to put in the other model. */
42964
+ copyOf(item) { return copy$2(item); }
42965
+ }
42966
+
42722
42967
  /** A plain copy of JSON-shaped data. */
42723
- const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
42968
+ const copy$1 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
42724
42969
 
42725
42970
  /**
42726
42971
  * The kinds two DataModels are compared on and merged by (ModelComparison,
@@ -42730,37 +42975,6 @@
42730
42975
  * two models alike but for where their boxes are, are alike.
42731
42976
  */
42732
42977
 
42733
- /** 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. */
42734
- class ListCompared extends ComparedKind {
42735
- constructor() {
42736
- super();
42737
- if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
42738
- }
42739
-
42740
- /** The array the model holds them in. @abstract */
42741
- list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
42742
-
42743
- items(model) { return this.list(model); }
42744
- get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
42745
-
42746
- /** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
42747
- bring(model, item) {
42748
- const list = this.list(model);
42749
- const twin = this.copyOf(item);
42750
- const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
42751
- if (at < 0) {
42752
- list.push(twin);
42753
- return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
42754
- }
42755
- const old = list[at];
42756
- list[at] = twin;
42757
- return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
42758
- }
42759
-
42760
- /** A copy to put in the other model. */
42761
- copyOf(item) { return copy(item); }
42762
- }
42763
-
42764
42978
  /** One level's entities: compared on all but where they are drawn. */
42765
42979
  class LevelEntitiesCompared extends ListCompared {
42766
42980
  constructor(level) {
@@ -42774,7 +42988,7 @@
42774
42988
 
42775
42989
  content(entity) {
42776
42990
  const { x, y, ...rest } = entity;
42777
- return copy(rest);
42991
+ return copy$1(rest);
42778
42992
  }
42779
42993
  }
42780
42994
 
@@ -42793,7 +43007,7 @@
42793
43007
  list(model) { return model.level(this.level).relationships; }
42794
43008
  idOf(r) { return r.id ?? `${r.source}->${r.target}${r.name ? `:${r.name}` : ''}`; }
42795
43009
  nameOf(r) { return `${r.source} ${r.name || '→'} ${r.target}`; }
42796
- content(r) { return copy(r); }
43010
+ content(r) { return copy$1(r); }
42797
43011
  requires(r) { return [[`${this.level}-entity`, r.source], [`${this.level}-entity`, r.target]]; }
42798
43012
  }
42799
43013
 
@@ -42820,8 +43034,8 @@
42820
43034
 
42821
43035
  /** Perspectives and contexts are classes; their JSON is what counts, and a copy is one made from it. */
42822
43036
  class ModelPartsCompared extends ListCompared {
42823
- content(item) { return copy(item.toJSON()); }
42824
- copyOf(item) { return new item.constructor(copy(item.toJSON())); }
43037
+ content(item) { return copy$1(item.toJSON()); }
43038
+ copyOf(item) { return new item.constructor(copy$1(item.toJSON())); }
42825
43039
  }
42826
43040
 
42827
43041
  class ContextsCompared extends ModelPartsCompared {
@@ -42863,11 +43077,13 @@
42863
43077
  * (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
42864
43078
  * three levels mapped one to one.
42865
43079
  *
42866
- * It knows whether it changed since it was saved (`edited()`, `markSaved()`,
42867
- * `isDirty()`, `onEdit(fn)`), as a ViewSetModel does, so a part can show it,
42868
- * keep it and ask before it is lost.
43080
+ * What any model a part edits has — whether it changed since saved, its undo
43081
+ * history, compare and merge — is EditableModel's.
42869
43082
  */
42870
- class DataModel {
43083
+ class DataModel extends EditableModel {
43084
+ /** What a snapshot puts back (EditableModel#restore). */
43085
+ static STATE = Object.freeze(['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']);
43086
+
42871
43087
  /** The levels, outermost first. */
42872
43088
  static LEVELS = LEVELS;
42873
43089
 
@@ -42951,11 +43167,7 @@
42951
43167
  * @param {string} [spec.name] - What the model is called
42952
43168
  */
42953
43169
  constructor(spec = {}) {
42954
- /** What the model is called. */
42955
- this.name = spec.name ?? '';
42956
- this._version = 0;
42957
- this._saved = 0;
42958
- this._editListeners = [];
43170
+ super(spec);
42959
43171
  const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
42960
43172
  this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
42961
43173
  this.mappings = LEVELS.slice(1).map((to, i) => {
@@ -42982,6 +43194,14 @@
42982
43194
  return new ModelScorecard(this);
42983
43195
  }
42984
43196
 
43197
+ /** What it is measured by (Benchmark): its check's findings, and its Scorecard total. */
43198
+ benchmark() {
43199
+ return [
43200
+ ...Benchmark.findings(this),
43201
+ { key: 'scorecard', label: 'Scorecard', value: this.scored().total().score, better: 'higher', format: 'score' },
43202
+ ];
43203
+ }
43204
+
42985
43205
  /** Keep a reviewer's scores and the weights (ModelScorecard.specFrom's), as one undo step. */
42986
43206
  scorecardCommand(spec) {
42987
43207
  return this.editCommand('Score the model', (model) => {
@@ -43029,46 +43249,6 @@
43029
43249
  ];
43030
43250
  }
43031
43251
 
43032
- /** How this model differs from another (ModelComparison), which can merge the other's into it. */
43033
- compareWith(other) {
43034
- return new ModelComparison(this, other);
43035
- }
43036
-
43037
- /** Its undo history: a merge is one step on it. */
43038
- get history() {
43039
- return (this._history ??= new CommandHistory());
43040
- }
43041
-
43042
- // ===== Changed since saved =====
43043
-
43044
- /** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
43045
- edited() {
43046
- this._version++;
43047
- this._emitEdit();
43048
- }
43049
-
43050
- /** This state is the saved one (a file opened or saved). */
43051
- markSaved() {
43052
- this._saved = this._version;
43053
- this._emitEdit();
43054
- }
43055
-
43056
- /** Whether it changed since markSaved. */
43057
- isDirty() {
43058
- return this._version !== this._saved;
43059
- }
43060
-
43061
- /** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
43062
- onEdit(fn) {
43063
- this._editListeners.push(fn);
43064
- return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
43065
- }
43066
-
43067
- /** @private */
43068
- _emitEdit() {
43069
- for (const fn of this._editListeners) fn(this);
43070
- }
43071
-
43072
43252
  /** A level's schema (LevelSchema). */
43073
43253
  level(name) {
43074
43254
  const schema = this.levels[name];
@@ -43284,13 +43464,7 @@
43284
43464
 
43285
43465
  // ===== Perspectives and contexts of one's own =====
43286
43466
 
43287
- // ===== Editing what a level holds (each a DataModelEditCommand: one undo step) =====
43288
-
43289
- /** Put the model back as a snapshot says (DataModelEditCommand): its levels, mappings, layout, perspectives, contexts, data types. */
43290
- restore(json) {
43291
- const back = new DataModel(JSON.parse(JSON.stringify(json)));
43292
- for (const key of ['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']) this[key] = back[key];
43293
- }
43467
+ // ===== Editing what a level holds (each an editCommand: one undo step) =====
43294
43468
 
43295
43469
  /** Set how its data is classified (a ClassificationScheme spec), as one undo step. */
43296
43470
  classificationSchemeCommand(spec) {
@@ -43306,11 +43480,6 @@
43306
43480
  });
43307
43481
  }
43308
43482
 
43309
- /** A change made as one undo step. */
43310
- editCommand(label, change) {
43311
- return new DataModelEditCommand(this, label, change);
43312
- }
43313
-
43314
43483
  /**
43315
43484
  * Add an entity at a level (`id` null: one made from its name) or change
43316
43485
  * one (what the spec leaves out, where it is drawn among it, kept).
@@ -43662,13 +43831,6 @@
43662
43831
  }
43663
43832
 
43664
43833
  /** An id from a name, not yet among `taken`. @private */
43665
- _freshId(name, taken) {
43666
- const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
43667
- let id = base;
43668
- for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
43669
- return id;
43670
- }
43671
-
43672
43834
  toJSON() {
43673
43835
  return {
43674
43836
  name: this.name,
@@ -44768,6 +44930,1177 @@
44768
44930
  }
44769
44931
  }
44770
44932
 
44933
+ /**
44934
+ * The kinds two RequirementsModels are compared on and merged by
44935
+ * (ModelComparison): epics, releases, then stories, in the order a merge
44936
+ * brings them — a story needs its epic and its release.
44937
+ */
44938
+
44939
+ class EpicsCompared extends ListCompared {
44940
+ get key() { return 'epic'; }
44941
+ get label() { return 'Epics'; }
44942
+ list(model) { return model.epics; }
44943
+ content(epic) { return { ...epic }; }
44944
+ }
44945
+
44946
+ class ReleasesCompared extends ListCompared {
44947
+ get key() { return 'release'; }
44948
+ get label() { return 'Releases'; }
44949
+ list(model) { return model.releases; }
44950
+ content(release) { return { ...release }; }
44951
+ }
44952
+
44953
+ /** Stories: compared on everything, where they sit in the list aside. */
44954
+ class StoriesCompared extends ListCompared {
44955
+ get key() { return 'story'; }
44956
+ get label() { return 'Stories'; }
44957
+ list(model) { return model.stories; }
44958
+ content(story) { return JSON.parse(JSON.stringify(story)); }
44959
+
44960
+ requires(story) {
44961
+ return [...(story.epic ? [['epic', story.epic]] : []), ...(story.release ? [['release', story.release]] : [])];
44962
+ }
44963
+ }
44964
+
44965
+ /**
44966
+ * RequirementsChecker — what a requirements model is missing, as a team
44967
+ * reviewing a backlog would say it: a story with no acceptance criteria
44968
+ * (nothing to say when it is done), one that says neither who wants it nor
44969
+ * what (no "As a … I want …"), one that traces to nothing (nothing realises
44970
+ * it), one under no epic, an epic with no stories, two stories of one name.
44971
+ *
44972
+ * With `resolve(trace)` — the host's way to find what a trace names in its
44973
+ * other models (true, false, or undefined when it cannot tell) — a trace to
44974
+ * something that is not there is found too.
44975
+ *
44976
+ * Findings name a story or an epic (`kind`), as ModelChecker's do.
44977
+ */
44978
+ class RequirementsChecker extends ModelChecker {
44979
+ /**
44980
+ * @param {RequirementsModel} model
44981
+ * @param {{resolve?: (trace: Object) => (boolean|undefined)}} [options]
44982
+ */
44983
+ constructor(model, { resolve = null } = {}) {
44984
+ super(model);
44985
+ this.resolve = resolve;
44986
+ }
44987
+
44988
+ checks() {
44989
+ return [this.noCriteria, this.noNarrative, this.untraced, this.brokenTraces, this.noEpic, this.emptyEpics, this.sameNames];
44990
+ }
44991
+
44992
+ /** @private */
44993
+ _story(story, code, severity, message) {
44994
+ return { code, severity, kind: 'story', ids: [story.id], message };
44995
+ }
44996
+
44997
+ noCriteria() {
44998
+ return this.model.stories.filter(s => !s.criteria.length)
44999
+ .map(s => this._story(s, 'story-no-criteria', 'warning', `"${s.name}" has no acceptance criteria: nothing says when it is done`));
45000
+ }
45001
+
45002
+ noNarrative() {
45003
+ return this.model.stories.filter(s => !s.want && !s.description)
45004
+ .map(s => this._story(s, 'story-no-narrative', 'info', `"${s.name}" says neither who wants it nor what (As a … I want … so that …)`));
45005
+ }
45006
+
45007
+ untraced() {
45008
+ return this.model.stories.filter(s => !s.traces.length)
45009
+ .map(s => this._story(s, 'story-untraced', 'info', `"${s.name}" traces to nothing that realises it`));
45010
+ }
45011
+
45012
+ brokenTraces() {
45013
+ if (!this.resolve) return [];
45014
+ return this.model.stories.flatMap(s => s.traces.filter(t => this.resolve(t) === false)
45015
+ .map(t => this._story(s, 'trace-missing', 'warning', `"${s.name}" traces to ${t.kind} "${t.name || t.ref}", which is not there`)));
45016
+ }
45017
+
45018
+ noEpic() {
45019
+ if (!this.model.epics.length) return [];
45020
+ return this.model.stories.filter(s => !this.model.epic(s.epic))
45021
+ .map(s => this._story(s, 'story-no-epic', 'info', `"${s.name}" is under no epic`));
45022
+ }
45023
+
45024
+ emptyEpics() {
45025
+ return this.model.epics.filter(e => !this.model.storiesOf(e.id).length)
45026
+ .map(e => ({ code: 'epic-empty', severity: 'info', kind: 'epic', ids: [e.id], message: `Epic "${e.name}" has no stories` }));
45027
+ }
45028
+
45029
+ sameNames() {
45030
+ const byName = new Map();
45031
+ for (const s of this.model.stories) {
45032
+ const key = s.name.trim().toLowerCase();
45033
+ if (key) byName.set(key, [...(byName.get(key) ?? []), s]);
45034
+ }
45035
+ return [...byName.values()].filter(list => list.length > 1)
45036
+ .map(list => ({ code: 'story-duplicate', severity: 'warning', kind: 'story', ids: list.map(s => s.id), message: `${list.length} stories are called "${list[0].name}"` }));
45037
+ }
45038
+ }
45039
+
45040
+ /** A plain copy of JSON-shaped data. */
45041
+ const copy = (value) => JSON.parse(JSON.stringify(value));
45042
+
45043
+ /**
45044
+ * What a story can trace to, and what its trace is called: ArchiMate's
45045
+ * motivation and implementation elements, any other element of an
45046
+ * architecture, and a data model's concepts, entities and tables — so a trace
45047
+ * runs epic → story → requirement → component → entity → table. A trace's
45048
+ * `kind` is any string; these are the ones offered.
45049
+ */
45050
+ const TRACE_KINDS = Object.freeze([
45051
+ ['requirement', 'Requirement (ArchiMate)'],
45052
+ ['work-package', 'Work package (ArchiMate)'],
45053
+ ['element', 'Architecture element'],
45054
+ ['concept', 'Data concept'],
45055
+ ['entity', 'Data entity'],
45056
+ ['table', 'Table'],
45057
+ ]);
45058
+
45059
+ /** Where a story is in its life, in order; a status is any string, these are offered. */
45060
+ const STATUSES = Object.freeze(['To do', 'In progress', 'Done']);
45061
+
45062
+ /**
45063
+ * RequirementsModel — what a system is to do, as an agile team writes it:
45064
+ * **epics** (large outcomes), the **stories** that deliver them ("As a … I
45065
+ * want … so that …"), each with its **acceptance criteria**, sliced into
45066
+ * **releases**; and **traces** from a story to what realises it in the other
45067
+ * models (TRACE_KINDS: an ArchiMate requirement or work package, an element,
45068
+ * a data concept, entity or table — `{kind, ref, name?, model?}`, `ref` that
45069
+ * model's id).
45070
+ *
45071
+ * Read as a **story map** (`storyMap()`): epics across in order, releases
45072
+ * down, each cell the stories of that epic in that release; stories with no
45073
+ * epic or no release have a column and a row of their own. `coverage()` and
45074
+ * the checker (RequirementsChecker) say what is missing — a story with no
45075
+ * acceptance criteria, nothing realising it — the numbers a bench measures.
45076
+ *
45077
+ * Spec (its toJSON): `{ name?, epics: [{id, name, description?}], releases:
45078
+ * [{id, name}], stories: [{id, name, epic?, release?, as?, want?, soThat?,
45079
+ * description?, criteria: [{id, text}], status?, priority?, points?, labels?,
45080
+ * source?: {system, key, url?}, traces: [{kind, ref, name?, model?}]}] }`.
45081
+ * Ids are made from names when not given. Every edit is a command (one undo
45082
+ * step); compare and merge, and changed-since-saved, are EditableModel's.
45083
+ */
45084
+ class RequirementsModel extends EditableModel {
45085
+ static STATE = Object.freeze(['name', 'epics', 'releases', 'stories']);
45086
+
45087
+ static TRACE_KINDS = TRACE_KINDS;
45088
+
45089
+ static STATUSES = STATUSES;
45090
+
45091
+ /** A story map's column for stories with no epic, and its row for those in no release. */
45092
+ static NO_EPIC = '';
45093
+ static NO_RELEASE = '';
45094
+
45095
+ constructor(spec = {}) {
45096
+ super(spec);
45097
+ this.epics = [];
45098
+ this.releases = [];
45099
+ this.stories = [];
45100
+ for (const e of spec.epics ?? []) this.epics.push(this._epic(e));
45101
+ for (const r of spec.releases ?? []) this.releases.push(this._release(r));
45102
+ for (const s of spec.stories ?? []) this.stories.push(this._story(s));
45103
+ }
45104
+
45105
+ // ===== Reading =====
45106
+
45107
+ epic(id) { return this.epics.find(e => e.id === id) ?? null; }
45108
+ release(id) { return this.releases.find(r => r.id === id) ?? null; }
45109
+ story(id) { return this.stories.find(s => s.id === id) ?? null; }
45110
+
45111
+ /** The stories of an epic (NO_EPIC: of none), in order; with `release`, only those in it (NO_RELEASE: in none). */
45112
+ storiesOf(epicId, release) {
45113
+ return this.stories.filter(s => (s.epic || '') === (epicId || '')
45114
+ && (release === undefined || (s.release || '') === (release || '')));
45115
+ }
45116
+
45117
+ /** The story's sentence: "As a … I want … so that …", from what it has. */
45118
+ static narrative(story) {
45119
+ 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}`]
45120
+ .filter(Boolean).join(', ');
45121
+ }
45122
+
45123
+ /**
45124
+ * The story map: `columns` the epics in order (then NO_EPIC, if any story
45125
+ * has none), `rows` the releases in order (then NO_RELEASE, if any story is
45126
+ * in none, or there are no releases), and `cell(epicId, releaseId)` the
45127
+ * stories there, in order.
45128
+ */
45129
+ storyMap() {
45130
+ const columns = [...this.epics.map(e => ({ id: e.id, name: e.name, epic: e }))];
45131
+ if (this.stories.some(s => !this.epic(s.epic))) columns.push({ id: RequirementsModel.NO_EPIC, name: 'No epic', epic: null });
45132
+ const rows = this.releases.map(r => ({ id: r.id, name: r.name, release: r }));
45133
+ 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 });
45134
+ const epicOf = (s) => (this.epic(s.epic) ? s.epic : RequirementsModel.NO_EPIC);
45135
+ const releaseOf = (s) => (this.release(s.release) ? s.release : RequirementsModel.NO_RELEASE);
45136
+ return {
45137
+ columns, rows,
45138
+ cell: (epicId, releaseId) => this.stories.filter(s => epicOf(s) === epicId && releaseOf(s) === releaseId),
45139
+ };
45140
+ }
45141
+
45142
+ /** The stories that trace to `ref` (with `kind`, only traces of that kind): what a change to it touches. */
45143
+ tracingTo(ref, kind) {
45144
+ return this.stories.filter(s => s.traces.some(t => t.ref === ref && (!kind || t.kind === kind)));
45145
+ }
45146
+
45147
+ /**
45148
+ * How complete it is, as numbers to measure by: stories, how many have
45149
+ * acceptance criteria, how many trace to something, epics with no stories,
45150
+ * and each as a share (0..1; 1 with nothing to count).
45151
+ */
45152
+ coverage() {
45153
+ const stories = this.stories.length;
45154
+ const withCriteria = this.stories.filter(s => s.criteria.length).length;
45155
+ const traced = this.stories.filter(s => s.traces.length).length;
45156
+ const emptyEpics = this.epics.filter(e => !this.storiesOf(e.id).length).length;
45157
+ const share = (n, of) => (of ? n / of : 1);
45158
+ return {
45159
+ stories, withCriteria, traced, epics: this.epics.length, emptyEpics,
45160
+ criteriaShare: share(withCriteria, stories), traceShare: share(traced, stories),
45161
+ };
45162
+ }
45163
+
45164
+ /** What it is measured by (Benchmark): its check's findings, and how many stories say when they are done and trace to something. */
45165
+ benchmark() {
45166
+ const c = this.coverage();
45167
+ return [
45168
+ ...Benchmark.findings(this),
45169
+ { key: 'criteria', label: 'Stories with acceptance criteria', value: c.criteriaShare, better: 'higher', format: 'share' },
45170
+ { key: 'traced', label: 'Stories traced', value: c.traceShare, better: 'higher', format: 'share' },
45171
+ ];
45172
+ }
45173
+
45174
+ /** Its checks (RequirementsChecker). */
45175
+ checker(options) {
45176
+ return new RequirementsChecker(this, options);
45177
+ }
45178
+
45179
+ comparedKinds() {
45180
+ return [new EpicsCompared(), new ReleasesCompared(), new StoriesCompared()];
45181
+ }
45182
+
45183
+ // ===== Editing (each an editCommand: one undo step) =====
45184
+
45185
+ /** Add an epic (`id` null) or change one. The command's result is the epic. */
45186
+ saveEpicCommand(id, spec) {
45187
+ return this.editCommand(id ? `Edit epic ${spec.name ?? id}` : `New epic ${spec.name ?? ''}`.trim(), (model) => {
45188
+ if (!id) { const epic = model._epic(spec); model.epics.push(epic); return epic; }
45189
+ const epic = model.epic(id);
45190
+ if (epic) Object.assign(epic, model._epic({ ...epic, ...spec, id }));
45191
+ return epic;
45192
+ });
45193
+ }
45194
+
45195
+ /** Remove an epic; its stories stay, with no epic. */
45196
+ removeEpicCommand(id) {
45197
+ return this.editCommand(`Remove epic ${this.epic(id)?.name ?? id}`, (model) => {
45198
+ model.epics = model.epics.filter(e => e.id !== id);
45199
+ for (const s of model.stories) if (s.epic === id) s.epic = '';
45200
+ });
45201
+ }
45202
+
45203
+ /** Add a release (`id` null) or rename one. */
45204
+ saveReleaseCommand(id, spec) {
45205
+ return this.editCommand(id ? `Edit release ${spec.name ?? id}` : `New release ${spec.name ?? ''}`.trim(), (model) => {
45206
+ if (!id) { const release = model._release(spec); model.releases.push(release); return release; }
45207
+ const release = model.release(id);
45208
+ if (release) Object.assign(release, model._release({ ...release, ...spec, id }));
45209
+ return release;
45210
+ });
45211
+ }
45212
+
45213
+ /** Remove a release; its stories stay, planned in none. */
45214
+ removeReleaseCommand(id) {
45215
+ return this.editCommand(`Remove release ${this.release(id)?.name ?? id}`, (model) => {
45216
+ model.releases = model.releases.filter(r => r.id !== id);
45217
+ for (const s of model.stories) if (s.release === id) s.release = '';
45218
+ });
45219
+ }
45220
+
45221
+ /** Add a story (`id` null) or change one — what the spec leaves out kept. The command's result is the story. */
45222
+ saveStoryCommand(id, spec) {
45223
+ return this.editCommand(id ? `Edit ${spec.name ?? this.story(id)?.name ?? id}` : `New story ${spec.name ?? ''}`.trim(), (model) => {
45224
+ if (!id) { const story = model._story(spec); model.stories.push(story); return story; }
45225
+ const at = model.stories.findIndex(s => s.id === id);
45226
+ if (at < 0) return null;
45227
+ model.stories[at] = model._story({ ...model.stories[at], ...spec, id });
45228
+ return model.stories[at];
45229
+ });
45230
+ }
45231
+
45232
+ removeStoryCommand(id) {
45233
+ return this.editCommand(`Remove ${this.story(id)?.name ?? id}`, (model) => {
45234
+ model.stories = model.stories.filter(s => s.id !== id);
45235
+ });
45236
+ }
45237
+
45238
+ /**
45239
+ * Move a story on the map: to an epic and a release (NO_EPIC, NO_RELEASE:
45240
+ * none), before another story there (`before`; none: last).
45241
+ */
45242
+ moveStoryCommand(id, { epic, release, before = null }) {
45243
+ return this.editCommand(`Move ${this.story(id)?.name ?? id}`, (model) => {
45244
+ const story = model.story(id);
45245
+ if (!story || before === id) return;
45246
+ if (epic !== undefined) story.epic = epic || '';
45247
+ if (release !== undefined) story.release = release || '';
45248
+ model.stories.splice(model.stories.indexOf(story), 1);
45249
+ const at = before ? model.stories.findIndex(s => s.id === before) : -1;
45250
+ model.stories.splice(at < 0 ? model.stories.length : at, 0, story);
45251
+ });
45252
+ }
45253
+
45254
+ /** Trace a story to something that realises it (`{kind, ref, name?, model?}`); once per kind and ref. */
45255
+ traceCommand(storyId, trace) {
45256
+ return this.editCommand(`Trace ${this.story(storyId)?.name ?? storyId} to ${trace.name || trace.ref}`, (model) => {
45257
+ const story = model.story(storyId);
45258
+ if (!story || story.traces.some(t => t.kind === trace.kind && t.ref === trace.ref)) return;
45259
+ story.traces.push(RequirementsModel._trace(trace));
45260
+ });
45261
+ }
45262
+
45263
+ /** Remove a story's trace to `ref` (of `kind`, if given). */
45264
+ untraceCommand(storyId, ref, kind) {
45265
+ return this.editCommand(`Remove the trace of ${this.story(storyId)?.name ?? storyId}`, (model) => {
45266
+ const story = model.story(storyId);
45267
+ if (story) story.traces = story.traces.filter(t => !(t.ref === ref && (!kind || t.kind === kind)));
45268
+ });
45269
+ }
45270
+
45271
+ /**
45272
+ * Bring epics, releases and stories in (an import): one undo step. One with
45273
+ * the id of one there replaces it; the rest are added.
45274
+ */
45275
+ bringCommand({ epics = [], releases = [], stories = [] }, label = 'Import') {
45276
+ return this.editCommand(label, (model) => {
45277
+ const put = (list, item) => {
45278
+ const at = list.findIndex(x => x.id === item.id);
45279
+ if (at < 0) list.push(item); else list[at] = item;
45280
+ };
45281
+ for (const e of epics) put(model.epics, model._epic(e));
45282
+ for (const r of releases) put(model.releases, model._release(r));
45283
+ for (const s of stories) put(model.stories, model._story(s));
45284
+ });
45285
+ }
45286
+
45287
+ // ===== Its parts, read from a spec =====
45288
+
45289
+ /** @private */
45290
+ _epic({ id, name = '', description = '', source } = {}) {
45291
+ return {
45292
+ id: id ?? this._freshId(name || 'epic', this.epics), name, description,
45293
+ ...(source && { source: copy(source) }),
45294
+ };
45295
+ }
45296
+
45297
+ /** @private */
45298
+ _release({ id, name = '' } = {}) {
45299
+ return { id: id ?? this._freshId(name || 'release', this.releases), name };
45300
+ }
45301
+
45302
+ /** @private */
45303
+ _story(spec = {}) {
45304
+ const { id, name = '', epic = '', release = '', as = '', want = '', soThat = '', description = '', status = '', priority = '', points = null, labels = [], source, criteria = [], traces = [] } = spec;
45305
+ const story = {
45306
+ id: id ?? this._freshId(name || 'story', this.stories), name, epic: epic ?? '', release: release ?? '',
45307
+ as, want, soThat, description, status, priority,
45308
+ points: Number.isFinite(Number(points)) && points !== null && points !== '' ? Number(points) : null,
45309
+ labels: [...labels],
45310
+ criteria: [],
45311
+ traces: traces.map(t => RequirementsModel._trace(t)),
45312
+ ...(source && { source: copy(source) }),
45313
+ };
45314
+ // A new criterion is numbered past every id the story's criteria have, so none is given twice.
45315
+ const given = new Set(criteria.map(c => (typeof c === 'object' && c?.id) || null).filter(Boolean));
45316
+ let n = 0;
45317
+ const fresh = () => { let id; do id = `${story.id}-ac${++n}`; while (given.has(id)); given.add(id); return id; };
45318
+ for (const c of criteria) {
45319
+ const text = typeof c === 'string' ? c : c?.text;
45320
+ if (!String(text ?? '').trim()) continue;
45321
+ story.criteria.push({ id: (typeof c === 'object' && c.id) || fresh(), text: String(text).trim() });
45322
+ }
45323
+ return story;
45324
+ }
45325
+
45326
+ /** @private */
45327
+ static _trace({ kind = 'element', ref, name = '', model = '' } = {}) {
45328
+ return { kind, ref: String(ref ?? ''), ...(name && { name }), ...(model && { model }) };
45329
+ }
45330
+
45331
+ toJSON() {
45332
+ return {
45333
+ name: this.name,
45334
+ epics: copy(this.epics),
45335
+ releases: copy(this.releases),
45336
+ stories: copy(this.stories),
45337
+ };
45338
+ }
45339
+ }
45340
+
45341
+ /**
45342
+ * RequirementsFileFormat — a requirements model as a file
45343
+ * (`.requirements.json`): its epics, releases and stories
45344
+ * (RequirementsModel#toJSON), read back by `new RequirementsModel(json)`.
45345
+ */
45346
+ class RequirementsFileFormat extends ModelFileFormat {
45347
+ get label() { return '.requirements.json'; }
45348
+ get accept() { return '.requirements.json,.json'; }
45349
+ get fileName() { return 'model.requirements.json'; }
45350
+ get featureName() { return 'Requirements'; }
45351
+
45352
+ /** JSON with a requirements model's shape: stories, or epics. */
45353
+ recognizes(text) {
45354
+ try {
45355
+ const json = JSON.parse(text);
45356
+ return !!json && typeof json === 'object' && (Array.isArray(json.stories) || Array.isArray(json.epics));
45357
+ } catch {
45358
+ return false;
45359
+ }
45360
+ }
45361
+
45362
+ export(model) {
45363
+ return { data: JSON.stringify(model.toJSON(), null, 2), mimeType: 'application/json' };
45364
+ }
45365
+
45366
+ import(text) {
45367
+ return new RequirementsModel(JSON.parse(text));
45368
+ }
45369
+ }
45370
+
45371
+ /**
45372
+ * Where each tracker's export keeps what a story needs: per tracker, the
45373
+ * column names (CSV) each field may be under, first found wins; which issue
45374
+ * types are epics; how its list fields are separated. One definition per
45375
+ * tracker — the CSV and the JSON readers both map onto these fields. A host
45376
+ * gives its own (a custom field's column) through `new TrackerExport({ columns })`.
45377
+ */
45378
+ const TRACKERS = Object.freeze({
45379
+ jira: {
45380
+ label: 'Jira',
45381
+ // Columns only its exports have, to tell it by.
45382
+ marks: ['Issue key', 'Issue Type'],
45383
+ columns: {
45384
+ key: ['Issue key', 'Key'], id: ['Issue id'], type: ['Issue Type'], title: ['Summary'],
45385
+ description: ['Description'], parent: ['Parent', 'Parent id', 'Parent key', 'Custom field (Epic Link)', 'Epic Link'],
45386
+ status: ['Status'], priority: ['Priority'],
45387
+ points: ['Custom field (Story Points)', 'Story Points', 'Custom field (Story point estimate)', 'Story point estimate'],
45388
+ release: ['Fix Version/s', 'Fix versions', 'Sprint'], labels: ['Labels'],
45389
+ criteria: ['Custom field (Acceptance Criteria)', 'Acceptance Criteria'], url: [],
45390
+ },
45391
+ epicTypes: ['Epic'], skipTypes: ['Sub-task', 'Subtask'], labelSeparator: /[\s,]+/,
45392
+ },
45393
+ azure: {
45394
+ label: 'Azure DevOps',
45395
+ marks: ['Work Item Type'],
45396
+ columns: {
45397
+ key: ['ID'], id: ['ID'], type: ['Work Item Type'], title: ['Title', 'Title 1', 'Title 2', 'Title 3'],
45398
+ description: ['Description'], parent: ['Parent'], status: ['State'], priority: ['Priority'],
45399
+ points: ['Story Points', 'Effort', 'Size'], release: ['Iteration Path'], labels: ['Tags'],
45400
+ criteria: ['Acceptance Criteria'], url: [],
45401
+ },
45402
+ epicTypes: ['Epic', 'Feature'], skipTypes: ['Task'], labelSeparator: /\s*;\s*/,
45403
+ },
45404
+ gitlab: {
45405
+ label: 'GitLab',
45406
+ marks: ['Issue ID', 'URL'],
45407
+ columns: {
45408
+ key: ['Issue ID', 'IID'], id: ['Issue ID', 'IID'], type: [], title: ['Title'], description: ['Description'],
45409
+ parent: ['Epic ID'], parentName: ['Epic Title'], status: ['State'], priority: [], points: ['Weight'],
45410
+ release: ['Milestone'], labels: ['Labels'], criteria: [], url: ['URL'],
45411
+ },
45412
+ epicTypes: [], skipTypes: [], labelSeparator: /\s*,\s*/,
45413
+ },
45414
+ });
45415
+
45416
+ /**
45417
+ * Where Jira's JSON keeps what has no field of its own: custom fields, by key.
45418
+ * Jira Cloud's "Story point estimate" is customfield_10016 unless a site
45419
+ * changed it; a host names its own (`columns: { points: ['customfield_10026'] }`).
45420
+ */
45421
+ const JIRA_FIELDS = Object.freeze({
45422
+ points: ['story_points', 'storyPoints', 'customfield_10016'],
45423
+ criteria: ['acceptanceCriteria'],
45424
+ });
45425
+
45426
+ /** Text with HTML in it (Azure DevOps keeps descriptions as HTML) as lines of plain text. */
45427
+ function plainText(value) {
45428
+ if (value == null) return '';
45429
+ if (typeof value === 'object') return documentText(value);
45430
+ return String(value)
45431
+ .replace(/<\s*(br|\/p|\/div|\/li|\/h\d)\s*\/?>/gi, '\n').replace(/<\s*li[^>]*>/gi, '- ')
45432
+ .replace(/<[^>]+>/g, '')
45433
+ .replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&amp;/g, '&')
45434
+ .replace(/\r\n?/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
45435
+ }
45436
+
45437
+ /** Jira Cloud's description, a document of nodes, as text: each paragraph and list item a line. */
45438
+ function documentText(node) {
45439
+ if (!node || typeof node !== 'object') return '';
45440
+ if (node.type === 'text') return node.text ?? '';
45441
+ const inner = (node.content ?? []).map(documentText).join('');
45442
+ return ['paragraph', 'heading', 'listItem', 'codeBlock'].includes(node.type) ? `${node.type === 'listItem' ? '- ' : ''}${inner.trim()}\n` : inner;
45443
+ }
45444
+
45445
+ /** The lines of a list: "- x", "* x", "1. x", "• x", "[ ] x" — each item a line, its marker gone. */
45446
+ function listItems(text) {
45447
+ return String(text ?? '').split('\n')
45448
+ .map(line => line.replace(/^\s*(?:[-*•+]|\d+[.)]|\[[ xX]?\])\s+/, '').trim())
45449
+ .filter(Boolean);
45450
+ }
45451
+
45452
+ /**
45453
+ * Acceptance criteria in a story's text: the lines under an "Acceptance
45454
+ * criteria" heading, else its Given/When/Then scenarios (one each), else none.
45455
+ * @returns {{criteria: Array<string>, rest: string}} - and the text without them
45456
+ */
45457
+ function criteriaFrom(text) {
45458
+ const s = plainText(text);
45459
+ const heading = /^\s*(?:#+\s*|\*\*)?acceptance criteria\b[^\n]*\n/im.exec(s);
45460
+ if (heading) {
45461
+ const after = s.slice(heading.index + heading[0].length);
45462
+ const end = after.search(/\n\s*\n\s*(?:#+\s|\*\*|[A-Z][^\n:]{0,40}:\s*\n)/);
45463
+ const block = end < 0 ? after : after.slice(0, end);
45464
+ return { criteria: listItems(block), rest: (s.slice(0, heading.index) + (end < 0 ? '' : after.slice(end))).replace(/\n{3,}/g, '\n\n').trim() };
45465
+ }
45466
+ const scenarios = [];
45467
+ let current = null;
45468
+ for (const line of s.split('\n')) {
45469
+ const t = line.replace(/^\s*(?:[-*•]\s+)?/, '').trim();
45470
+ 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;
45471
+ }
45472
+ 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() };
45473
+ return { criteria: [], rest: s };
45474
+ }
45475
+
45476
+ /** A field that holds only acceptance criteria: as criteriaFrom reads them, else each of its lines one. */
45477
+ function criteriaField(text) {
45478
+ const found = criteriaFrom(text).criteria;
45479
+ return found.length ? found : listItems(plainText(text));
45480
+ }
45481
+
45482
+ /** "As a <who>, I want <what> so that <why>" in a text: its three parts (empty where it says nothing). */
45483
+ function narrativeFrom(text) {
45484
+ 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));
45485
+ return m ? { as: m[1].trim(), want: m[2].trim(), soThat: (m[3] ?? '').trim() } : { as: '', want: '', soThat: '' };
45486
+ }
45487
+
45488
+ /** A text without its "As a … I want …" line: what is left to describe the story, once its sentence is read (narrativeFrom). */
45489
+ function withoutNarrative(text) {
45490
+ return String(text ?? '').replace(/^\s*\**\s*as an?\s[^\n]*\bi\s+(want|need|would like)\b[^\n]*\n?/im, '').trim();
45491
+ }
45492
+
45493
+ /**
45494
+ * TrackerExport — epics and stories from what an issue tracker exports: Jira,
45495
+ * Azure DevOps or GitLab (TRACKERS), as CSV or as their API's JSON. An issue
45496
+ * of an epic type becomes an epic (GitLab's epics are named by their issues'
45497
+ * Epic ID and title); the rest become stories under their parent epic, with
45498
+ * their status, priority, points, labels and release (fix version, iteration
45499
+ * or milestone). Acceptance criteria come from their own field, else from
45500
+ * the description (criteriaFrom); "As a … I want … so that …" from the
45501
+ * description or the title (narrativeFrom). Each keeps where it came from
45502
+ * (`source: {system, key, url?}`), and its id is the tracker's key, so
45503
+ * reading a newer export over a model replaces what it had.
45504
+ *
45505
+ * `read(text)` gives a RequirementsModel spec (`{epics, releases, stories}`).
45506
+ */
45507
+ class TrackerExport {
45508
+ /**
45509
+ * @param {Object} [options]
45510
+ * @param {string} [options.tracker] - 'jira', 'azure', 'gitlab' (default: told from the export)
45511
+ * @param {Object<string, Array<string>>} [options.columns] - Column names to look in first, by field (in Jira's JSON, field keys)
45512
+ */
45513
+ constructor({ tracker = null, columns = {} } = {}) {
45514
+ this.tracker = tracker;
45515
+ this.columns = columns;
45516
+ }
45517
+
45518
+ /** What a tracker is called ('Jira'). */
45519
+ static label(tracker) {
45520
+ return TRACKERS[tracker]?.label ?? 'Tracker';
45521
+ }
45522
+
45523
+ /** Which tracker an export's columns (or JSON) are from, or null. */
45524
+ static detect(text) {
45525
+ const json = TrackerExport._json(text);
45526
+ if (json) {
45527
+ if (Array.isArray(json.issues) && json.issues[0]?.fields) return 'jira';
45528
+ if (Array.isArray(json.value ?? json.workItems) && (json.value ?? json.workItems)[0]?.fields) return 'azure';
45529
+ if (Array.isArray(json) && json[0] && 'iid' in json[0]) return 'gitlab';
45530
+ return null;
45531
+ }
45532
+ const { columns } = parseCsvTable(text);
45533
+ const has = new Set(columns.map(c => c.toLowerCase()));
45534
+ const found = Object.entries(TRACKERS).find(([, t]) => t.marks.every(m => has.has(m.toLowerCase())));
45535
+ return found?.[0] ?? null;
45536
+ }
45537
+
45538
+ /** @private — an API's JSON, given as text or already parsed. */
45539
+ static _json(text) {
45540
+ if (text && typeof text === 'object') return text;
45541
+ const s = String(text ?? '').trim();
45542
+ if (!s.startsWith('{') && !s.startsWith('[')) return null;
45543
+ try { return JSON.parse(s); } catch { return null; }
45544
+ }
45545
+
45546
+ /**
45547
+ * The export (its text; an API's JSON as text or parsed — TrackerApi) as a requirements model's spec.
45548
+ * @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
45549
+ */
45550
+ read(text) {
45551
+ const tracker = this.tracker ?? TrackerExport.detect(text) ?? 'jira';
45552
+ const def = TRACKERS[tracker];
45553
+ if (!def) throw new Error(`Unknown tracker: "${tracker}"`);
45554
+ const json = TrackerExport._json(text);
45555
+ const issues = json ? this._fromJson(tracker, json) : this._fromCsv(def, text);
45556
+ return { ...this._model(tracker, def, issues), tracker };
45557
+ }
45558
+
45559
+ /** CSV rows as issues: each field from the first of its columns there. @private */
45560
+ _fromCsv(def, text) {
45561
+ const { columns, rows } = parseCsvTable(text);
45562
+ const lower = new Map(columns.map(c => [c.toLowerCase(), c]));
45563
+ const columnOf = (field) => [...(this.columns[field] ?? []), ...(def.columns[field] ?? [])].map(c => lower.get(c.toLowerCase())).find(Boolean);
45564
+ const fields = Object.keys({ ...def.columns, ...this.columns, parentName: [] });
45565
+ const at = Object.fromEntries(fields.map(f => [f, columnOf(f)]));
45566
+ return rows.map(row => Object.fromEntries(fields.map(f => [f, at[f] ? row[at[f]] : ''])));
45567
+ }
45568
+
45569
+ /** An API's JSON as issues, in the CSV's fields. @private */
45570
+ _fromJson(tracker, json) {
45571
+ if (tracker === 'jira') {
45572
+ return (json.issues ?? []).map(({ key, id, fields: f = {} }) => {
45573
+ const custom = (names) => Object.entries(f).find(([k]) => names.some(n => k.toLowerCase().includes(n)))?.[1];
45574
+ // A field the host named (its custom field's key), else Jira Cloud's usual one.
45575
+ const field = (name) => [...(this.columns[name] ?? []), ...(JIRA_FIELDS[name] ?? [])].map(k => f[k]).find(v => v != null && v !== '');
45576
+ return {
45577
+ key, id: String(id ?? ''), type: f.issuetype?.name ?? '', title: f.summary ?? '', description: f.description ?? '',
45578
+ parent: f.parent?.key ?? f.parent?.id ?? '', status: f.status?.name ?? '', priority: f.priority?.name ?? '',
45579
+ points: field('points') ?? custom(['storypoint', 'story_point']) ?? '',
45580
+ release: (f.fixVersions ?? [])[0]?.name ?? '', labels: (f.labels ?? []).join(' '),
45581
+ criteria: field('criteria') ?? custom(['acceptance']) ?? '', url: '',
45582
+ };
45583
+ });
45584
+ }
45585
+ if (tracker === 'azure') {
45586
+ return (json.value ?? json.workItems ?? []).map(({ id, fields: f = {}, url }) => ({
45587
+ key: String(id), id: String(id), type: f['System.WorkItemType'] ?? '', title: f['System.Title'] ?? '',
45588
+ description: f['System.Description'] ?? '', parent: String(f['System.Parent'] ?? ''), status: f['System.State'] ?? '',
45589
+ priority: String(f['Microsoft.VSTS.Common.Priority'] ?? ''),
45590
+ points: f['Microsoft.VSTS.Scheduling.StoryPoints'] ?? f['Microsoft.VSTS.Scheduling.Effort'] ?? '',
45591
+ release: f['System.IterationPath'] ?? '', labels: f['System.Tags'] ?? '',
45592
+ criteria: f['Microsoft.VSTS.Common.AcceptanceCriteria'] ?? '', url: url ?? '',
45593
+ }));
45594
+ }
45595
+ return (Array.isArray(json) ? json : []).map(i => ({
45596
+ key: String(i.iid ?? i.id), id: String(i.iid ?? i.id), type: '', title: i.title ?? '', description: i.description ?? '',
45597
+ parent: String(i.epic?.id ?? i.epic_iid ?? ''), parentName: i.epic?.title ?? '', status: i.state ?? '', priority: '',
45598
+ points: i.weight ?? '', release: i.milestone?.title ?? '', labels: (i.labels ?? []).map(l => l.name ?? l).join(','),
45599
+ criteria: '', url: i.web_url ?? '',
45600
+ }));
45601
+ }
45602
+
45603
+ /** Issues as epics, releases and stories. @private */
45604
+ _model(tracker, def, issues) {
45605
+ const is = (list, type) => list.some(t => t.toLowerCase() === String(type ?? '').trim().toLowerCase());
45606
+ const keyOf = new Map(); // an issue's id or key → its key (Jira's CSV names a parent by its id)
45607
+ for (const i of issues) for (const k of [i.key, i.id]) if (k) keyOf.set(String(k), String(i.key || i.id));
45608
+ const source = (i, key) => ({ system: tracker, key, ...(i.url && { url: i.url }) });
45609
+
45610
+ const epics = new Map();
45611
+ for (const i of issues) {
45612
+ if (!is(def.epicTypes, i.type)) continue;
45613
+ const key = String(i.key || i.id);
45614
+ epics.set(key, { id: key, name: plainText(i.title), description: plainText(i.description), source: source(i, key) });
45615
+ }
45616
+ const releases = new Map();
45617
+ const stories = [];
45618
+ for (const i of issues) {
45619
+ if (is(def.epicTypes, i.type) || is(def.skipTypes, i.type) || !String(i.title ?? '').trim()) continue;
45620
+ const key = String(i.key || i.id);
45621
+ const parent = i.parent ? keyOf.get(String(i.parent)) ?? String(i.parent) : '';
45622
+ if (parent && !epics.has(parent) && i.parentName) epics.set(parent, { id: parent, name: plainText(i.parentName), description: '', source: { system: tracker, key: parent } });
45623
+ const release = plainText(i.release);
45624
+ if (release && !releases.has(release)) releases.set(release, { id: release, name: release.split(/[\\/]/).pop() });
45625
+ const own = criteriaField(i.criteria);
45626
+ const inText = own.length ? { criteria: [], rest: plainText(i.description) } : criteriaFrom(i.description);
45627
+ const said = narrativeFrom(i.description);
45628
+ const { as, want, soThat } = said.want ? said : narrativeFrom(i.title);
45629
+ stories.push({
45630
+ id: key, name: plainText(i.title), epic: parent, release,
45631
+ as, want, soThat, description: said.want ? withoutNarrative(inText.rest) : inText.rest,
45632
+ criteria: own.length ? own : inText.criteria,
45633
+ status: plainText(i.status), priority: plainText(i.priority), points: i.points === '' ? null : i.points,
45634
+ labels: String(i.labels ?? '').split(def.labelSeparator).map(l => l.trim()).filter(Boolean),
45635
+ source: source(i, key), traces: [],
45636
+ });
45637
+ }
45638
+ // A story whose parent is no epic here (a story's sub-item, an epic not exported) is under none.
45639
+ for (const s of stories) if (s.epic && !epics.has(s.epic)) s.epic = '';
45640
+ return { epics: [...epics.values()], releases: [...releases.values()], stories };
45641
+ }
45642
+ }
45643
+
45644
+ /**
45645
+ * Storyboard — epics and stories from a storyboard or requirements document
45646
+ * written in Markdown (or plain text with Markdown headings), as teams keep
45647
+ * them beside or before a tracker:
45648
+ *
45649
+ * - a heading that says "Epic" (`## Epic: Checkout`) is an epic, one that says
45650
+ * "Story" (`### Story: Pay by card`) a story; otherwise the document's one
45651
+ * title (`#`) is its name, the next level down are epics and the level
45652
+ * below that stories;
45653
+ * - under an epic, a list item that reads "As a … I want …" is a story too;
45654
+ * - under a story, "As a … I want … so that …" is its narrative, the list
45655
+ * under "Acceptance criteria" (or its Given/When/Then lines) its criteria,
45656
+ * and the rest its description — a picture (`![caption](file)`) kept there
45657
+ * by its caption.
45658
+ *
45659
+ * `read(text)` gives a RequirementsModel spec (`{name, epics, stories}`); ids
45660
+ * are made from names (prefix: `prefix`, so two storyboards read into one
45661
+ * model stay apart).
45662
+ */
45663
+ class Storyboard {
45664
+ /** @param {{prefix?: string}} [options] */
45665
+ constructor({ prefix = '' } = {}) {
45666
+ this.prefix = prefix;
45667
+ }
45668
+
45669
+ read(text) {
45670
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
45671
+ const headings = lines.map((line, at) => ({ at, m: /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line) })).filter(h => h.m)
45672
+ .map(({ at, m }) => ({ at, level: m[1].length, text: m[2].trim() }));
45673
+ 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);
45674
+ const top = Math.min(...headings.map(h => h.level));
45675
+ const tops = headings.filter(h => h.level === top);
45676
+ // One top heading, a title (#), names the document; its children are the epics.
45677
+ const named = top === 1 && tops.length === 1 && !kindOf(tops[0]) && headings.length > 1;
45678
+ const epicLevel = named ? top + 1 : top;
45679
+ const strip = (t) => t.replace(/^(epic|(user\s+)?story)\s*[:\-–—]?\s*/i, '').trim();
45680
+
45681
+ const epics = [], stories = [];
45682
+ const ids = new Set();
45683
+ const idFor = (name, kind) => {
45684
+ const base = `${this.prefix}${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || kind}`;
45685
+ let id = base;
45686
+ for (let n = 2; ids.has(id); n++) id = `${base}-${n}`;
45687
+ ids.add(id);
45688
+ return id;
45689
+ };
45690
+ const bodyOf = (h, i) => lines.slice(h.at + 1, headings[i + 1]?.at ?? lines.length).join('\n');
45691
+ let epic = null;
45692
+ headings.forEach((h, i) => {
45693
+ if (named && h === tops[0]) return;
45694
+ const kind = kindOf(h) ?? (h.level === epicLevel ? 'epic' : h.level === epicLevel + 1 ? 'story' : null);
45695
+ const body = bodyOf(h, i);
45696
+ if (kind === 'epic') {
45697
+ epic = { id: idFor(strip(h.text), 'epic'), name: strip(h.text), description: '' };
45698
+ epics.push(epic);
45699
+ const listed = this._listedStories(body);
45700
+ epic.description = listed.rest;
45701
+ for (const s of listed.stories) stories.push({ ...s, id: idFor(s.name, 'story'), epic: epic.id });
45702
+ } else if (kind === 'story') {
45703
+ stories.push({ ...this._story(strip(h.text), body), id: idFor(strip(h.text), 'story'), epic: epic?.id ?? '' });
45704
+ }
45705
+ });
45706
+ if (!headings.length || (!epics.length && !stories.length)) {
45707
+ for (const s of this._listedStories(lines.join('\n')).stories) stories.push({ ...s, id: idFor(s.name, 'story') });
45708
+ }
45709
+ return { name: named ? tops[0].text : '', epics, stories };
45710
+ }
45711
+
45712
+ /** A story from its heading and what is under it. @private */
45713
+ _story(name, body) {
45714
+ const pictures = [...body.matchAll(/!\[([^\]]*)\]\([^)]*\)/g)].map(m => m[1]).filter(Boolean);
45715
+ const text = body.replace(/!\[([^\]]*)\]\([^)]*\)/g, '').trim();
45716
+ const said = narrativeFrom(text);
45717
+ const { as, want, soThat } = said.want ? said : narrativeFrom(name);
45718
+ const { criteria, rest } = criteriaFrom(text);
45719
+ const description = [withoutNarrative(rest), ...pictures.map(c => `Picture: ${c}`)].filter(Boolean).join('\n');
45720
+ return { name, as, want, soThat, description, criteria };
45721
+ }
45722
+
45723
+ /** "As a … I want …" list items: stories; the rest of the text, kept. @private */
45724
+ _listedStories(body) {
45725
+ const stories = [];
45726
+ const rest = [];
45727
+ for (const line of body.split('\n')) {
45728
+ const item = /^\s*(?:[-*•+]|\d+[.)])\s+(.*)$/.exec(line);
45729
+ const said = item && narrativeFrom(item[1]);
45730
+ if (said?.want) stories.push({ name: said.want.replace(/^to\s+/i, '').replace(/^./, c => c.toUpperCase()), ...said, description: '', criteria: [] });
45731
+ else rest.push(line);
45732
+ }
45733
+ return { stories, rest: rest.join('\n').trim() };
45734
+ }
45735
+ }
45736
+
45737
+ /**
45738
+ * The files a requirements model is read from that another tool wrote — they
45739
+ * import only: a tracker's export (TrackerExport: Jira, Azure DevOps,
45740
+ * GitLab, CSV or JSON) and a storyboard (Storyboard: Markdown). Each reads
45741
+ * the file as a new model; `spec(text)` is what it holds, for a part that
45742
+ * brings it into the model open (RequirementsModel#bringCommand).
45743
+ */
45744
+ class RequirementsImportFormat extends ModelFileFormat {
45745
+ get exports() { return false; }
45746
+
45747
+ /** What the file holds, as a RequirementsModel spec. @abstract */
45748
+ spec(text) { throw new Error(`${this.constructor.name} must implement spec()`); }
45749
+
45750
+ import(text) {
45751
+ return new RequirementsModel(this.spec(text));
45752
+ }
45753
+ }
45754
+
45755
+ class TrackerExportFileFormat extends RequirementsImportFormat {
45756
+ get label() { return 'Tracker export (Jira, Azure DevOps, GitLab)'; }
45757
+ get accept() { return '.csv,.json'; }
45758
+ get featureName() { return 'TrackerExport'; }
45759
+
45760
+ /** A CSV or JSON export one of the trackers wrote. */
45761
+ recognizes(text) {
45762
+ return !!TrackerExport.detect(text);
45763
+ }
45764
+
45765
+ spec(text) {
45766
+ const { tracker, ...spec } = new TrackerExport().read(text);
45767
+ return { name: `${TrackerExport.label(tracker)} export`, ...spec };
45768
+ }
45769
+ }
45770
+
45771
+ class StoryboardFileFormat extends RequirementsImportFormat {
45772
+ get label() { return 'Storyboard (Markdown)'; }
45773
+ get accept() { return '.md,.markdown,.txt'; }
45774
+ get featureName() { return 'Storyboard'; }
45775
+
45776
+ spec(text) {
45777
+ return new Storyboard().read(text);
45778
+ }
45779
+ }
45780
+
45781
+ /** Text as base64, whatever its characters (btoa takes only Latin-1). */
45782
+ function base64(text) {
45783
+ const bytes = new TextEncoder().encode(String(text));
45784
+ let binary = '';
45785
+ for (const b of bytes) binary += String.fromCharCode(b);
45786
+ return btoa(binary);
45787
+ }
45788
+
45789
+ /**
45790
+ * TrackerApi — a tracker read through its API rather than from an export: the
45791
+ * issues a connection names, page by page, as the JSON `TrackerExport` reads
45792
+ * (so an API's answer and a file saved from it make the same model). A
45793
+ * subclass per tracker (`JiraApi`, `AzureDevOpsApi`, `GitLabApi`) says where
45794
+ * its issues are, how it pages and how it is signed in to; what a connection
45795
+ * asks for is `fields`, which a host builds its form from.
45796
+ *
45797
+ * `fetch` is given (a server's, or a test's): a tracker's token belongs where
45798
+ * the request is made, which is seldom a browser. Nothing here keeps or logs
45799
+ * the token; an error says what the tracker answered, not what was sent.
45800
+ */
45801
+ class TrackerApi {
45802
+ /** The tracker's key in TRACKERS ('jira'). */
45803
+ static tracker = null;
45804
+
45805
+ /** Most issues read from one connection: a connection naming a whole site is stopped there (`truncated`). */
45806
+ static MAX_ISSUES = 5000;
45807
+
45808
+ /**
45809
+ * What a connection asks for, in order: `{key, label, type?, choices?,
45810
+ * required?, secret?, placeholder?, hint?}` — `secret` written, never
45811
+ * shown back. The tracker's own between its address and its token.
45812
+ */
45813
+ static get fields() {
45814
+ return [
45815
+ { key: 'name', label: 'Name', required: true, placeholder: 'What people pick it by, e.g. "Shop backlog"' },
45816
+ { key: 'baseUrl', label: 'Address', required: true, placeholder: this.baseUrlPlaceholder },
45817
+ ...this.ownFields,
45818
+ { key: 'token', label: 'Token', type: 'password', required: true, secret: true, hint: this.tokenHint },
45819
+ ];
45820
+ }
45821
+
45822
+ /** @protected */
45823
+ static get ownFields() { return []; }
45824
+
45825
+ /** @protected */
45826
+ static baseUrlPlaceholder = 'https://…';
45827
+
45828
+ /** @protected */
45829
+ static tokenHint = 'An access token that may read the issues';
45830
+
45831
+ /**
45832
+ * @param {Object} connection - Its `fields`' values (and `columns`, a TrackerExport's own columns)
45833
+ * @param {Object} [options]
45834
+ * @param {Function} [options.fetch] - The fetch to read with (default: the global one)
45835
+ * @param {number} [options.maxIssues] - Most issues read (default MAX_ISSUES)
45836
+ */
45837
+ constructor(connection, { fetch = globalThis.fetch, maxIssues = new.target.MAX_ISSUES } = {}) {
45838
+ this.connection = connection ?? {};
45839
+ this.fetch = fetch;
45840
+ this.maxIssues = maxIssues;
45841
+ /** Whether the last read stopped at maxIssues. */
45842
+ this.truncated = false;
45843
+ }
45844
+
45845
+ get tracker() { return this.constructor.tracker; }
45846
+
45847
+ /** What the tracker is called ('Jira'). */
45848
+ get label() { return TrackerExport.label(this.tracker); }
45849
+
45850
+ /** What is wrong with the connection, before anything is asked of the tracker: messages, none when it will do. */
45851
+ problems() {
45852
+ const c = this.connection;
45853
+ const out = this.constructor.fields.filter(f => f.required && !String(c[f.key] ?? '').trim()).map(f => `${f.label} is needed.`);
45854
+ if (c.baseUrl && !/^https?:\/\/[^\s/]+/i.test(String(c.baseUrl).trim())) out.push(`Address must start http:// or https:// ("${c.baseUrl}").`);
45855
+ return out;
45856
+ }
45857
+
45858
+ /** The issues, as the tracker's API gives them (the JSON TrackerExport reads). */
45859
+ async read() {
45860
+ const problems = this.problems();
45861
+ if (problems.length) throw new Error(`${this.label} connection "${this.connection.name ?? ''}": ${problems.join(' ')}`);
45862
+ this.truncated = false;
45863
+ return this._read();
45864
+ }
45865
+
45866
+ /** The issues as a requirements model's spec, named as the connection is. */
45867
+ async model() {
45868
+ const json = await this.read();
45869
+ const { tracker, ...spec } = new TrackerExport({ tracker: this.tracker, columns: this.connection.columns ?? {} }).read(json);
45870
+ return { name: this.connection.name || `${this.label} issues`, ...spec };
45871
+ }
45872
+
45873
+ /** @protected @abstract */
45874
+ async _read() {
45875
+ throw new Error(`${this.constructor.name} does not say how to read ${this.label}`);
45876
+ }
45877
+
45878
+ /** The headers that sign in. @protected @abstract */
45879
+ headers() {
45880
+ return {};
45881
+ }
45882
+
45883
+ /** A path on the tracker's address. @protected */
45884
+ url(path) {
45885
+ return `${String(this.connection.baseUrl).trim().replace(/\/+$/, '')}${path}`;
45886
+ }
45887
+
45888
+ /**
45889
+ * A request to the tracker, its answer's JSON and headers; an answer
45890
+ * other than OK an error saying what the tracker said. @protected
45891
+ */
45892
+ async request(url, { method = 'GET', body } = {}) {
45893
+ if (typeof this.fetch !== 'function') throw new Error('No fetch to read the tracker with');
45894
+ const res = await this.fetch(url, {
45895
+ method,
45896
+ headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...this.headers() },
45897
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
45898
+ });
45899
+ if (!res.ok) {
45900
+ const text = await res.text().catch(() => '');
45901
+ throw new Error(`${this.label} answered ${res.status}${res.statusText ? ` ${res.statusText}` : ''}${text ? `: ${text.slice(0, 300)}` : ''}`);
45902
+ }
45903
+ return { json: await res.json(), headers: res.headers };
45904
+ }
45905
+
45906
+ /** Issues kept up to maxIssues; true once it is reached. @protected */
45907
+ keep(into, more) {
45908
+ for (const issue of more) {
45909
+ if (into.length >= this.maxIssues) { this.truncated = true; return true; }
45910
+ into.push(issue);
45911
+ }
45912
+ return into.length >= this.maxIssues;
45913
+ }
45914
+
45915
+ /** @protected */
45916
+ static base64 = base64;
45917
+
45918
+ /** @protected — the tracker's issue types not read (TRACKERS: its sub-tasks). */
45919
+ get skipTypes() { return TRACKERS[this.tracker]?.skipTypes ?? []; }
45920
+ }
45921
+
45922
+ /**
45923
+ * Jira through its REST API: the issues a JQL query finds (a project's, by
45924
+ * default), every field (custom ones too: story points, acceptance criteria).
45925
+ * Jira Cloud signs in with an email and an API token and pages by token
45926
+ * (`/rest/api/3/search/jql`); Jira Server and Data Center with a personal
45927
+ * access token (no email), paging by offset (`/rest/api/2/search`).
45928
+ */
45929
+ class JiraApi extends TrackerApi {
45930
+ static tracker = 'jira';
45931
+
45932
+ static PAGE = 100;
45933
+
45934
+ static baseUrlPlaceholder = 'https://your-site.atlassian.net';
45935
+
45936
+ static tokenHint = 'Jira Cloud: an API token (with the email above). Server / Data Center: a personal access token (no email).';
45937
+
45938
+ static get ownFields() {
45939
+ return [
45940
+ { key: 'edition', label: 'Jira', type: 'select', choices: [['cloud', 'Cloud'], ['server', 'Server / Data Center']], value: 'cloud' },
45941
+ { key: 'user', label: 'Email', placeholder: 'The account the API token is for (Cloud)' },
45942
+ { key: 'project', label: 'Project key', placeholder: 'SHOP — or leave it and give a JQL query' },
45943
+ { key: 'query', label: 'JQL', placeholder: 'project = SHOP AND issuetype != Sub-task (instead of the project)' },
45944
+ ];
45945
+ }
45946
+
45947
+ problems() {
45948
+ const out = super.problems();
45949
+ if (!String(this.connection.project ?? '').trim() && !String(this.connection.query ?? '').trim()) out.push('A project key or a JQL query is needed.');
45950
+ return out;
45951
+ }
45952
+
45953
+ /** The JQL: the one given, else the project's issues in key order. */
45954
+ get jql() {
45955
+ const { query, project } = this.connection;
45956
+ if (String(query ?? '').trim()) return String(query).trim();
45957
+ return `project = "${String(project).trim().replace(/"/g, '\\"')}" ORDER BY key`;
45958
+ }
45959
+
45960
+ headers() {
45961
+ const { user, token } = this.connection;
45962
+ return { Authorization: String(user ?? '').trim() ? `Basic ${TrackerApi.base64(`${String(user).trim()}:${token}`)}` : `Bearer ${token}` };
45963
+ }
45964
+
45965
+ async _read() {
45966
+ const issues = [];
45967
+ const page = this.constructor.PAGE;
45968
+ const query = (extra) => new URLSearchParams({ jql: this.jql, fields: '*all', maxResults: String(page), ...extra }).toString();
45969
+ if (this.connection.edition === 'server') {
45970
+ for (let startAt = 0; ; startAt += page) {
45971
+ const { json } = await this.request(this.url(`/rest/api/2/search?${query({ startAt: String(startAt) })}`));
45972
+ const got = json.issues ?? [];
45973
+ const more = got.length > 0 && startAt + got.length < (json.total ?? 0);
45974
+ if (this.keep(issues, got)) { this.truncated ||= more; break; }
45975
+ if (!more) break;
45976
+ }
45977
+ } else {
45978
+ for (let token = null; ;) {
45979
+ const { json } = await this.request(this.url(`/rest/api/3/search/jql?${query(token ? { nextPageToken: token } : {})}`));
45980
+ const got = json.issues ?? [];
45981
+ token = json.isLast ? null : json.nextPageToken ?? null;
45982
+ if (this.keep(issues, got)) { this.truncated ||= !!token; break; }
45983
+ if (!token || !got.length) break;
45984
+ }
45985
+ }
45986
+ return { issues };
45987
+ }
45988
+ }
45989
+
45990
+ /**
45991
+ * Azure DevOps (Boards) through its REST API: a WIQL query for a project's
45992
+ * work items (but its tasks — TRACKERS), then the items, 200 at a time. Signs
45993
+ * in with a personal access token. Each item's link is its page in Boards.
45994
+ */
45995
+ class AzureDevOpsApi extends TrackerApi {
45996
+ static tracker = 'azure';
45997
+
45998
+ /** Most ids one work-items request takes. */
45999
+ static BATCH = 200;
46000
+
46001
+ static API_VERSION = '7.1';
46002
+
46003
+ static baseUrlPlaceholder = 'https://dev.azure.com/your-organisation';
46004
+
46005
+ static tokenHint = 'A personal access token with Work Items (Read)';
46006
+
46007
+ static get ownFields() {
46008
+ return [
46009
+ { key: 'project', label: 'Project', required: true, placeholder: 'The project, e.g. Shop' },
46010
+ { key: 'query', label: 'WIQL condition', placeholder: "[System.AreaPath] UNDER 'Shop\\Checkout' (and the project's)" },
46011
+ ];
46012
+ }
46013
+
46014
+ headers() {
46015
+ return { Authorization: `Basic ${TrackerApi.base64(`:${this.connection.token}`)}` };
46016
+ }
46017
+
46018
+ /** The WIQL: the project's work items, not its tasks, and the condition given. */
46019
+ get wiql() {
46020
+ const types = this.skipTypes.map(t => `'${t.replace(/'/g, "''")}'`).join(', ');
46021
+ const condition = String(this.connection.query ?? '').trim();
46022
+ return `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project`
46023
+ + (types ? ` AND [System.WorkItemType] NOT IN (${types})` : '')
46024
+ + (condition ? ` AND (${condition})` : '')
46025
+ + ' ORDER BY [System.Id]';
46026
+ }
46027
+
46028
+ async _read() {
46029
+ const project = encodeURIComponent(String(this.connection.project).trim());
46030
+ const version = `api-version=${this.constructor.API_VERSION}`;
46031
+ const { json } = await this.request(this.url(`/${project}/_apis/wit/wiql?${version}`), { method: 'POST', body: { query: this.wiql } });
46032
+ const ids = (json.workItems ?? []).map(w => w.id);
46033
+ const value = [];
46034
+ for (let i = 0; i < ids.length; i += this.constructor.BATCH) {
46035
+ const batch = ids.slice(i, i + this.constructor.BATCH);
46036
+ const { json: items } = await this.request(this.url(`/${project}/_apis/wit/workitems?ids=${batch.join(',')}&${version}`));
46037
+ const got = (items.value ?? []).map(w => ({ ...w, url: this.url(`/${project}/_workitems/edit/${w.id}`) }));
46038
+ if (this.keep(value, got)) { this.truncated ||= i + batch.length < ids.length; break; }
46039
+ }
46040
+ return { value };
46041
+ }
46042
+ }
46043
+
46044
+ /**
46045
+ * GitLab through its REST API: a project's issues, open and closed, 100 a
46046
+ * page; filtered as its issues API filters (`labels=…&milestone=…`). Signs
46047
+ * in with a personal, project or group access token. An issue's epic
46048
+ * (GitLab Premium) is its epic.
46049
+ */
46050
+ class GitLabApi extends TrackerApi {
46051
+ static tracker = 'gitlab';
46052
+
46053
+ static PAGE = 100;
46054
+
46055
+ static baseUrlPlaceholder = 'https://gitlab.com';
46056
+
46057
+ static tokenHint = 'An access token with read_api';
46058
+
46059
+ static get ownFields() {
46060
+ return [
46061
+ { key: 'project', label: 'Project', required: true, placeholder: 'group/project, or its id' },
46062
+ { key: 'query', label: 'Filter', placeholder: 'labels=requirements&milestone=Release 2 (as the issues API takes)' },
46063
+ ];
46064
+ }
46065
+
46066
+ headers() {
46067
+ return { 'PRIVATE-TOKEN': String(this.connection.token) };
46068
+ }
46069
+
46070
+ async _read() {
46071
+ const project = encodeURIComponent(String(this.connection.project).trim());
46072
+ const issues = [];
46073
+ for (let page = 1; page;) {
46074
+ const params = new URLSearchParams(String(this.connection.query ?? '').trim());
46075
+ for (const [k, v] of Object.entries({ state: params.get('state') ?? 'all', per_page: String(this.constructor.PAGE), page: String(page) })) params.set(k, v);
46076
+ const { json, headers } = await this.request(this.url(`/api/v4/projects/${project}/issues?${params}`));
46077
+ const got = Array.isArray(json) ? json : [];
46078
+ const next = Number(headers?.get?.('x-next-page')) || (got.length === this.constructor.PAGE && !headers?.has?.('x-next-page') ? page + 1 : 0);
46079
+ if (this.keep(issues, got)) { this.truncated ||= !!next; break; }
46080
+ page = got.length ? next : 0;
46081
+ }
46082
+ return issues;
46083
+ }
46084
+ }
46085
+
46086
+ /** Each tracker's API, by its key in TRACKERS. */
46087
+ const TRACKER_APIS = Object.freeze({ jira: JiraApi, azure: AzureDevOpsApi, gitlab: GitLabApi });
46088
+
46089
+ /**
46090
+ * The API reader for a connection (`connection.tracker`: 'jira', 'azure',
46091
+ * 'gitlab'); `options` as TrackerApi takes them.
46092
+ */
46093
+ function trackerApi(connection, options) {
46094
+ const Api = TRACKER_APIS[connection?.tracker];
46095
+ if (!Api) throw new Error(`No API reader for tracker "${connection?.tracker ?? ''}" (${Object.keys(TRACKER_APIS).join(', ')})`);
46096
+ return new Api(connection, options);
46097
+ }
46098
+
46099
+ /** What each tracker's connection asks for: `[{tracker, label, fields}]`, for a host's form. */
46100
+ function trackerConnectionFields() {
46101
+ return Object.entries(TRACKER_APIS).map(([tracker, Api]) => ({ tracker, label: new Api({}).label, fields: Api.fields }));
46102
+ }
46103
+
44771
46104
  /**
44772
46105
  * propertiesDialog — the "Show/Edit Properties" dialog, as a shipped feature.
44773
46106
  *
@@ -47677,8 +49010,10 @@
47677
49010
  exports.ArrowMarker = ArrowMarker;
47678
49011
  exports.AssociationKind = AssociationKind;
47679
49012
  exports.AttributedLevel = AttributedLevel;
49013
+ exports.AzureDevOpsApi = AzureDevOpsApi;
47680
49014
  exports.BackgroundCommand = BackgroundCommand;
47681
49015
  exports.BackgroundGroup = BackgroundGroup;
49016
+ exports.Benchmark = Benchmark;
47682
49017
  exports.BianActionTerm = BianActionTerm;
47683
49018
  exports.BianFunctionalPattern = BianFunctionalPattern;
47684
49019
  exports.BianLandscapeReader = BianLandscapeReader;
@@ -47756,6 +49091,7 @@
47756
49091
  exports.EditHintCommand = EditHintCommand;
47757
49092
  exports.EditPropertyCommand = EditPropertyCommand;
47758
49093
  exports.EditViewItemCommand = EditViewItemCommand;
49094
+ exports.EditableModel = EditableModel;
47759
49095
  exports.EditorShell = EditorShell;
47760
49096
  exports.ElementFilterRule = ElementFilterRule;
47761
49097
  exports.ElementInstance = ElementInstance;
@@ -47769,6 +49105,7 @@
47769
49105
  exports.FormDialog = FormDialog;
47770
49106
  exports.GLYPHS = GLYPHS;
47771
49107
  exports.GeneralizationKind = GeneralizationKind;
49108
+ exports.GitLabApi = GitLabApi;
47772
49109
  exports.Glyph = Glyph;
47773
49110
  exports.GlyphTemplate = GlyphTemplate;
47774
49111
  exports.GraphDataWindow = GraphDataWindow;
@@ -47783,6 +49120,7 @@
47783
49120
  exports.ITIconNotation = ITIconNotation;
47784
49121
  exports.ImageSelector = ImageSelector;
47785
49122
  exports.ItemGroup = ItemGroup;
49123
+ exports.JiraApi = JiraApi;
47786
49124
  exports.KIND_PREFERENCE = KIND_PREFERENCE;
47787
49125
  exports.LAYER_CHOICES = LAYER_CHOICES;
47788
49126
  exports.LINE_ONLY_PROPERTIES = LINE_ONLY_PROPERTIES;
@@ -47800,6 +49138,7 @@
47800
49138
  exports.Mat4 = Mat4;
47801
49139
  exports.MeasuredCategory = MeasuredCategory;
47802
49140
  exports.MenuBar = MenuBar;
49141
+ exports.ModelEditCommand = ModelEditCommand;
47803
49142
  exports.ModelFileFormat = ModelFileFormat;
47804
49143
  exports.ModelScorecard = ModelScorecard;
47805
49144
  exports.ModelTree = ModelTree;
@@ -47834,6 +49173,9 @@
47834
49173
  exports.RemoveLineCommand = RemoveLineCommand;
47835
49174
  exports.RenderEngine = RenderEngine;
47836
49175
  exports.ReorderCommand = ReorderCommand;
49176
+ exports.RequirementsChecker = RequirementsChecker;
49177
+ exports.RequirementsFileFormat = RequirementsFileFormat;
49178
+ exports.RequirementsModel = RequirementsModel;
47837
49179
  exports.ResizeCommand = ResizeCommand;
47838
49180
  exports.RotateCommand = RotateCommand;
47839
49181
  exports.RoundedRectangleTemplate = RoundedRectangleTemplate;
@@ -47850,8 +49192,12 @@
47850
49192
  exports.SqlDdlFileFormat = SqlDdlFileFormat;
47851
49193
  exports.StadiumTemplate = StadiumTemplate;
47852
49194
  exports.StarTemplate = StarTemplate;
49195
+ exports.Storyboard = Storyboard;
49196
+ exports.StoryboardFileFormat = StoryboardFileFormat;
47853
49197
  exports.StructuredType = StructuredType;
47854
49198
  exports.SwimlaneTemplate = SwimlaneTemplate;
49199
+ exports.TRACKERS = TRACKERS;
49200
+ exports.TRACKER_APIS = TRACKER_APIS;
47855
49201
  exports.TREE_LAYERS = TREE_LAYERS;
47856
49202
  exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
47857
49203
  exports.Template = Template;
@@ -47865,6 +49211,9 @@
47865
49211
  exports.ToolbarSelect = ToolbarSelect;
47866
49212
  exports.ToolbarSeparator = ToolbarSeparator;
47867
49213
  exports.ToolbarText = ToolbarText;
49214
+ exports.TrackerApi = TrackerApi;
49215
+ exports.TrackerExport = TrackerExport;
49216
+ exports.TrackerExportFileFormat = TrackerExportFileFormat;
47868
49217
  exports.TrapezoidTemplate = TrapezoidTemplate;
47869
49218
  exports.TreeModeRegistry = TreeModeRegistry;
47870
49219
  exports.TriangleTemplate = TriangleTemplate;
@@ -47947,6 +49296,8 @@
47947
49296
  exports.toArchimateModel = toArchimateModel;
47948
49297
  exports.toExchangeXML = toExchangeXML;
47949
49298
  exports.toPngBlob = toPngBlob;
49299
+ exports.trackerApi = trackerApi;
49300
+ exports.trackerConnectionFields = trackerConnectionFields;
47950
49301
  exports.treeLayerOf = treeLayerOf;
47951
49302
  exports.treeModeRegistry = treeModeRegistry;
47952
49303
  exports.viewItemChanges = viewItemChanges;