@peter.prib/renderengine 0.2.10 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -6
- package/REFERENCE.md +123 -2
- package/dist/renderengine-workspace.umd.js +3015 -1221
- package/dist/renderengine.umd.js +1220 -176
- package/package.json +1 -1
- package/src/charting/ModelCheckDialog.js +10 -3
- package/src/charting/ModelComparisonDialog.js +10 -3
- package/src/charting/ModelFileFormat.js +4 -1
- package/src/charting/archimate/ArchimateCanvasEditor.js +14 -2
- package/src/charting/archimate/ArchimateElementTemplate.js +23 -0
- package/src/charting/arrangement/layouts.js +9 -4
- package/src/charting/datamodel/DataModel.js +18 -71
- package/src/charting/datamodel/DataModelChecker.js +1 -5
- package/src/charting/datamodel/DataModelCompared.js +1 -31
- package/src/charting/datamodel/LevelSchema.js +23 -0
- package/src/charting/datamodel/SqlDdl.js +18 -13
- package/src/charting/diagramModel/ListCompared.js +35 -0
- package/src/charting/model/Benchmark.js +59 -0
- package/src/charting/model/EditableModel.js +104 -0
- package/src/charting/{datamodel/DataModelEditCommand.js → model/ModelEditCommand.js} +47 -47
- package/src/charting/requirements/RequirementsChecker.js +76 -0
- package/src/charting/requirements/RequirementsCompared.js +33 -0
- package/src/charting/requirements/RequirementsFileFormat.js +32 -0
- package/src/charting/requirements/RequirementsImportFormats.js +48 -0
- package/src/charting/requirements/RequirementsModel.js +305 -0
- package/src/charting/requirements/Storyboard.js +94 -0
- package/src/charting/requirements/TrackerExport.js +273 -0
- package/src/charting/requirements/index.js +7 -0
- package/src/index.js +6 -0
- package/src/workspace/Workspace.js +329 -321
- package/src/workspace/index.js +2 -0
- package/src/workspace/parts/ArchimateCanvasPart.js +31 -1
- package/src/workspace/parts/DataModelPart.js +668 -735
- package/src/workspace/parts/EditorPart.js +1 -1
- package/src/workspace/parts/ModelFile.js +3 -1
- package/src/workspace/parts/ModelHosted.js +176 -0
- package/src/workspace/parts/ModelsPart.js +146 -7
- package/src/workspace/parts/RequirementsPart.js +465 -0
- package/src/workspace/storage/ModelLibrary.js +8 -2
package/dist/renderengine.umd.js
CHANGED
|
@@ -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
|
|
19760
|
-
* where the boxes
|
|
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
|
|
19792
|
-
pos[i].y
|
|
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:
|
|
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())
|
|
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 }),
|
|
@@ -41279,6 +41329,29 @@
|
|
|
41279
41329
|
return this.entities.find(e => e.id === id) ?? null;
|
|
41280
41330
|
}
|
|
41281
41331
|
|
|
41332
|
+
/** An entity by id, else by name (any case), or null. */
|
|
41333
|
+
named(text) {
|
|
41334
|
+
const s = String(text ?? '').trim();
|
|
41335
|
+
return this.entity(s) ?? this.entities.find(e => String(e.name ?? '').toLowerCase() === s.toLowerCase()) ?? null;
|
|
41336
|
+
}
|
|
41337
|
+
|
|
41338
|
+
/**
|
|
41339
|
+
* What a foreign key's `references` names: `{ entity, column }` (column
|
|
41340
|
+
* null when it names the table alone), or null. `table` or `table.column`,
|
|
41341
|
+
* the table by id or name; a table's own name may hold dots (a
|
|
41342
|
+
* schema-qualified `sales.customer`), so the whole text is tried as a
|
|
41343
|
+
* table first, then what is before its last dot, the rest its column.
|
|
41344
|
+
*/
|
|
41345
|
+
resolveReference(ref) {
|
|
41346
|
+
const text = String(ref ?? '').trim();
|
|
41347
|
+
if (!text) return null;
|
|
41348
|
+
const whole = this.named(text);
|
|
41349
|
+
if (whole) return { entity: whole, column: null };
|
|
41350
|
+
const dot = text.lastIndexOf('.');
|
|
41351
|
+
const entity = dot > 0 ? this.named(text.slice(0, dot)) : null;
|
|
41352
|
+
return entity ? { entity, column: text.slice(dot + 1).trim() || null } : null;
|
|
41353
|
+
}
|
|
41354
|
+
|
|
41282
41355
|
/** Add an entity (its id unique at this level). */
|
|
41283
41356
|
addEntity(entity) {
|
|
41284
41357
|
if (!entity?.id) throw new TypeError('An entity needs an id');
|
|
@@ -41719,17 +41792,13 @@
|
|
|
41719
41792
|
foreignKeys() {
|
|
41720
41793
|
return ATTRIBUTED.flatMap(level => {
|
|
41721
41794
|
const schema = this.model.level(level);
|
|
41722
|
-
const find = (ref) => {
|
|
41723
|
-
const [name] = String(ref).split('.');
|
|
41724
|
-
return schema.entity(name) ?? schema.entities.find(e => e.name.toLowerCase() === name.trim().toLowerCase()) ?? null;
|
|
41725
|
-
};
|
|
41726
41795
|
const related = (a, b) => schema.relationships.some(r => (r.source === a && r.target === b) || (r.source === b && r.target === a));
|
|
41727
41796
|
return schema.entities.flatMap(e => (e.attributes ?? []).filter(a => a.isFK).flatMap(a => {
|
|
41728
41797
|
if (!a.references) {
|
|
41729
41798
|
return schema.relationships.some(r => r.source === e.id || r.target === e.id) ? []
|
|
41730
41799
|
: [this._entity(level, e, 'foreign-key-unrelated', 'info', `"${e.name}.${a.name}" is a foreign key, but "${e.name}" has no relationship`)];
|
|
41731
41800
|
}
|
|
41732
|
-
const target =
|
|
41801
|
+
const target = schema.resolveReference(a.references)?.entity;
|
|
41733
41802
|
if (!target) return [this._entity(level, e, 'reference-missing', 'warning', `"${e.name}.${a.name}" references "${a.references}", which is not at the ${level} level`)];
|
|
41734
41803
|
return related(e.id, target.id) ? []
|
|
41735
41804
|
: [this._entity(level, e, 'foreign-key-without-relationship', 'warning', `"${e.name}.${a.name}" references "${target.name}", but they have no relationship`)];
|
|
@@ -42003,50 +42072,211 @@
|
|
|
42003
42072
|
}
|
|
42004
42073
|
}
|
|
42005
42074
|
|
|
42006
|
-
/**
|
|
42007
|
-
*
|
|
42008
|
-
*
|
|
42009
|
-
* the model as it was before and after (
|
|
42010
|
-
* puts the model back as it was (
|
|
42011
|
-
* undoable this way, whatever it touches.
|
|
42012
|
-
*/
|
|
42013
|
-
class
|
|
42014
|
-
/** A model edit is no edit on one view: undoing it shows no other. */
|
|
42015
|
-
viewId = null;
|
|
42016
|
-
|
|
42017
|
-
/**
|
|
42018
|
-
* @param {
|
|
42019
|
-
* @param {string} label - What the undo menu calls it ('Edit Customer')
|
|
42020
|
-
* @param {(model:
|
|
42021
|
-
*/
|
|
42022
|
-
constructor(model, label, change) {
|
|
42023
|
-
super(label);
|
|
42024
|
-
this.model = model;
|
|
42025
|
-
this.change = change;
|
|
42026
|
-
this.before = null;
|
|
42027
|
-
this.after = null;
|
|
42028
|
-
}
|
|
42029
|
-
|
|
42030
|
-
execute() {
|
|
42031
|
-
if (this.after) {
|
|
42032
|
-
this.model.restore(this.after);
|
|
42033
|
-
} else {
|
|
42034
|
-
this.before =
|
|
42035
|
-
this.result = this.change(this.model);
|
|
42036
|
-
this.after =
|
|
42037
|
-
}
|
|
42038
|
-
this.model.edited();
|
|
42039
|
-
}
|
|
42040
|
-
|
|
42041
|
-
undo() {
|
|
42042
|
-
this.model.restore(this.before);
|
|
42043
|
-
this.model.edited();
|
|
42044
|
-
}
|
|
42045
|
-
|
|
42046
|
-
/** The model as it is, a copy nothing later changes (its toJSON
|
|
42047
|
-
static snapshot(model) {
|
|
42048
|
-
return JSON.parse(JSON.stringify(model.toJSON()));
|
|
42049
|
-
}
|
|
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
|
+
}
|
|
42050
42280
|
}
|
|
42051
42281
|
|
|
42052
42282
|
/** Words that stay small inside a title. */
|
|
@@ -42700,8 +42930,42 @@
|
|
|
42700
42930
|
}
|
|
42701
42931
|
}
|
|
42702
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
|
+
|
|
42703
42967
|
/** A plain copy of JSON-shaped data. */
|
|
42704
|
-
const copy = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
42968
|
+
const copy$1 = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
42705
42969
|
|
|
42706
42970
|
/**
|
|
42707
42971
|
* The kinds two DataModels are compared on and merged by (ModelComparison,
|
|
@@ -42711,37 +42975,6 @@
|
|
|
42711
42975
|
* two models alike but for where their boxes are, are alike.
|
|
42712
42976
|
*/
|
|
42713
42977
|
|
|
42714
|
-
/** 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. */
|
|
42715
|
-
class ListCompared extends ComparedKind {
|
|
42716
|
-
constructor() {
|
|
42717
|
-
super();
|
|
42718
|
-
if (new.target === ListCompared) throw new TypeError('ListCompared is abstract — subclass it');
|
|
42719
|
-
}
|
|
42720
|
-
|
|
42721
|
-
/** The array the model holds them in. @abstract */
|
|
42722
|
-
list(model) { throw new Error(`${this.constructor.name} must implement list()`); }
|
|
42723
|
-
|
|
42724
|
-
items(model) { return this.list(model); }
|
|
42725
|
-
get(model, id) { return this.items(model).find(item => this.idOf(item) === id) ?? null; }
|
|
42726
|
-
|
|
42727
|
-
/** Put a copy of `item` at its place (in place of the one with its id, else last); undone, as it was. */
|
|
42728
|
-
bring(model, item) {
|
|
42729
|
-
const list = this.list(model);
|
|
42730
|
-
const twin = this.copyOf(item);
|
|
42731
|
-
const at = list.findIndex(x => this.idOf(x) === this.idOf(item));
|
|
42732
|
-
if (at < 0) {
|
|
42733
|
-
list.push(twin);
|
|
42734
|
-
return () => { const i = list.indexOf(twin); if (i >= 0) list.splice(i, 1); };
|
|
42735
|
-
}
|
|
42736
|
-
const old = list[at];
|
|
42737
|
-
list[at] = twin;
|
|
42738
|
-
return () => { const i = list.indexOf(twin); if (i >= 0) list[i] = old; };
|
|
42739
|
-
}
|
|
42740
|
-
|
|
42741
|
-
/** A copy to put in the other model. */
|
|
42742
|
-
copyOf(item) { return copy(item); }
|
|
42743
|
-
}
|
|
42744
|
-
|
|
42745
42978
|
/** One level's entities: compared on all but where they are drawn. */
|
|
42746
42979
|
class LevelEntitiesCompared extends ListCompared {
|
|
42747
42980
|
constructor(level) {
|
|
@@ -42755,7 +42988,7 @@
|
|
|
42755
42988
|
|
|
42756
42989
|
content(entity) {
|
|
42757
42990
|
const { x, y, ...rest } = entity;
|
|
42758
|
-
return copy(rest);
|
|
42991
|
+
return copy$1(rest);
|
|
42759
42992
|
}
|
|
42760
42993
|
}
|
|
42761
42994
|
|
|
@@ -42774,7 +43007,7 @@
|
|
|
42774
43007
|
list(model) { return model.level(this.level).relationships; }
|
|
42775
43008
|
idOf(r) { return r.id ?? `${r.source}->${r.target}${r.name ? `:${r.name}` : ''}`; }
|
|
42776
43009
|
nameOf(r) { return `${r.source} ${r.name || '→'} ${r.target}`; }
|
|
42777
|
-
content(r) { return copy(r); }
|
|
43010
|
+
content(r) { return copy$1(r); }
|
|
42778
43011
|
requires(r) { return [[`${this.level}-entity`, r.source], [`${this.level}-entity`, r.target]]; }
|
|
42779
43012
|
}
|
|
42780
43013
|
|
|
@@ -42801,8 +43034,8 @@
|
|
|
42801
43034
|
|
|
42802
43035
|
/** Perspectives and contexts are classes; their JSON is what counts, and a copy is one made from it. */
|
|
42803
43036
|
class ModelPartsCompared extends ListCompared {
|
|
42804
|
-
content(item) { return copy(item.toJSON()); }
|
|
42805
|
-
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())); }
|
|
42806
43039
|
}
|
|
42807
43040
|
|
|
42808
43041
|
class ContextsCompared extends ModelPartsCompared {
|
|
@@ -42844,11 +43077,13 @@
|
|
|
42844
43077
|
* (`{ entities, relationships }`, as dataModelDiagram takes them), is read as
|
|
42845
43078
|
* three levels mapped one to one.
|
|
42846
43079
|
*
|
|
42847
|
-
*
|
|
42848
|
-
*
|
|
42849
|
-
* 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.
|
|
42850
43082
|
*/
|
|
42851
|
-
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
|
+
|
|
42852
43087
|
/** The levels, outermost first. */
|
|
42853
43088
|
static LEVELS = LEVELS;
|
|
42854
43089
|
|
|
@@ -42932,11 +43167,7 @@
|
|
|
42932
43167
|
* @param {string} [spec.name] - What the model is called
|
|
42933
43168
|
*/
|
|
42934
43169
|
constructor(spec = {}) {
|
|
42935
|
-
|
|
42936
|
-
this.name = spec.name ?? '';
|
|
42937
|
-
this._version = 0;
|
|
42938
|
-
this._saved = 0;
|
|
42939
|
-
this._editListeners = [];
|
|
43170
|
+
super(spec);
|
|
42940
43171
|
const { levels, mappings = [], mappingLayout = {}, noteLayout = {} } = spec.levels ? spec : DataModel.levelsFromCombined(spec);
|
|
42941
43172
|
this.levels = Object.fromEntries(LEVELS.map(level => [level, new LevelSchema(level, levels?.[level] ?? {})]));
|
|
42942
43173
|
this.mappings = LEVELS.slice(1).map((to, i) => {
|
|
@@ -42963,6 +43194,14 @@
|
|
|
42963
43194
|
return new ModelScorecard(this);
|
|
42964
43195
|
}
|
|
42965
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
|
+
|
|
42966
43205
|
/** Keep a reviewer's scores and the weights (ModelScorecard.specFrom's), as one undo step. */
|
|
42967
43206
|
scorecardCommand(spec) {
|
|
42968
43207
|
return this.editCommand('Score the model', (model) => {
|
|
@@ -43010,46 +43249,6 @@
|
|
|
43010
43249
|
];
|
|
43011
43250
|
}
|
|
43012
43251
|
|
|
43013
|
-
/** How this model differs from another (ModelComparison), which can merge the other's into it. */
|
|
43014
|
-
compareWith(other) {
|
|
43015
|
-
return new ModelComparison(this, other);
|
|
43016
|
-
}
|
|
43017
|
-
|
|
43018
|
-
/** Its undo history: a merge is one step on it. */
|
|
43019
|
-
get history() {
|
|
43020
|
-
return (this._history ??= new CommandHistory());
|
|
43021
|
-
}
|
|
43022
|
-
|
|
43023
|
-
// ===== Changed since saved =====
|
|
43024
|
-
|
|
43025
|
-
/** Something changed (a perspective or context, a box or note moved): listeners hear of it. */
|
|
43026
|
-
edited() {
|
|
43027
|
-
this._version++;
|
|
43028
|
-
this._emitEdit();
|
|
43029
|
-
}
|
|
43030
|
-
|
|
43031
|
-
/** This state is the saved one (a file opened or saved). */
|
|
43032
|
-
markSaved() {
|
|
43033
|
-
this._saved = this._version;
|
|
43034
|
-
this._emitEdit();
|
|
43035
|
-
}
|
|
43036
|
-
|
|
43037
|
-
/** Whether it changed since markSaved. */
|
|
43038
|
-
isDirty() {
|
|
43039
|
-
return this._version !== this._saved;
|
|
43040
|
-
}
|
|
43041
|
-
|
|
43042
|
-
/** Call `fn(model)` after every change (and markSaved). Returns an unsubscribe function. */
|
|
43043
|
-
onEdit(fn) {
|
|
43044
|
-
this._editListeners.push(fn);
|
|
43045
|
-
return () => { this._editListeners = this._editListeners.filter(l => l !== fn); };
|
|
43046
|
-
}
|
|
43047
|
-
|
|
43048
|
-
/** @private */
|
|
43049
|
-
_emitEdit() {
|
|
43050
|
-
for (const fn of this._editListeners) fn(this);
|
|
43051
|
-
}
|
|
43052
|
-
|
|
43053
43252
|
/** A level's schema (LevelSchema). */
|
|
43054
43253
|
level(name) {
|
|
43055
43254
|
const schema = this.levels[name];
|
|
@@ -43265,13 +43464,7 @@
|
|
|
43265
43464
|
|
|
43266
43465
|
// ===== Perspectives and contexts of one's own =====
|
|
43267
43466
|
|
|
43268
|
-
// ===== Editing what a level holds (each
|
|
43269
|
-
|
|
43270
|
-
/** Put the model back as a snapshot says (DataModelEditCommand): its levels, mappings, layout, perspectives, contexts, data types. */
|
|
43271
|
-
restore(json) {
|
|
43272
|
-
const back = new DataModel(JSON.parse(JSON.stringify(json)));
|
|
43273
|
-
for (const key of ['name', 'levels', 'mappings', 'mappingLayout', 'noteLayout', 'perspectives', 'contexts', 'dataTypes', 'namingStandard', 'classificationScheme', 'scorecard']) this[key] = back[key];
|
|
43274
|
-
}
|
|
43467
|
+
// ===== Editing what a level holds (each an editCommand: one undo step) =====
|
|
43275
43468
|
|
|
43276
43469
|
/** Set how its data is classified (a ClassificationScheme spec), as one undo step. */
|
|
43277
43470
|
classificationSchemeCommand(spec) {
|
|
@@ -43287,11 +43480,6 @@
|
|
|
43287
43480
|
});
|
|
43288
43481
|
}
|
|
43289
43482
|
|
|
43290
|
-
/** A change made as one undo step. */
|
|
43291
|
-
editCommand(label, change) {
|
|
43292
|
-
return new DataModelEditCommand(this, label, change);
|
|
43293
|
-
}
|
|
43294
|
-
|
|
43295
43483
|
/**
|
|
43296
43484
|
* Add an entity at a level (`id` null: one made from its name) or change
|
|
43297
43485
|
* one (what the spec leaves out, where it is drawn among it, kept).
|
|
@@ -43643,13 +43831,6 @@
|
|
|
43643
43831
|
}
|
|
43644
43832
|
|
|
43645
43833
|
/** An id from a name, not yet among `taken`. @private */
|
|
43646
|
-
_freshId(name, taken) {
|
|
43647
|
-
const base = String(name ?? 'item').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'item';
|
|
43648
|
-
let id = base;
|
|
43649
|
-
for (let n = 2; taken.some(t => t.id === id); n++) id = `${base}-${n}`;
|
|
43650
|
-
return id;
|
|
43651
|
-
}
|
|
43652
|
-
|
|
43653
43834
|
toJSON() {
|
|
43654
43835
|
return {
|
|
43655
43836
|
name: this.name,
|
|
@@ -44671,6 +44852,15 @@
|
|
|
44671
44852
|
return /^[a-z_][a-z0-9_]*$/.test(s) && !RESERVED.has(s) ? s : `"${s.replace(/"/g, '""')}"`;
|
|
44672
44853
|
}
|
|
44673
44854
|
|
|
44855
|
+
/**
|
|
44856
|
+
* A table's name as SQL takes it: a schema-qualified one (`sales.customer`)
|
|
44857
|
+
* each part as name() has it, so it names the table in its schema rather
|
|
44858
|
+
* than one table called "sales.customer".
|
|
44859
|
+
*/
|
|
44860
|
+
static tableName(text) {
|
|
44861
|
+
return String(text).split('.').map(SqlDdl.name).join('.');
|
|
44862
|
+
}
|
|
44863
|
+
|
|
44674
44864
|
/** A text as an SQL string literal. */
|
|
44675
44865
|
static literal(text) {
|
|
44676
44866
|
return `'${String(text).replace(/'/g, "''")}'`;
|
|
@@ -44680,11 +44870,7 @@
|
|
|
44680
44870
|
write(model) {
|
|
44681
44871
|
const schema = model.level('physical');
|
|
44682
44872
|
const tables = schema.entities;
|
|
44683
|
-
const
|
|
44684
|
-
const [table, column] = String(ref).split('.').map(s => s.trim());
|
|
44685
|
-
const target = schema.entity(table) ?? tables.find(t => t.name.toLowerCase() === table.toLowerCase());
|
|
44686
|
-
return target ? { target, column } : null;
|
|
44687
|
-
};
|
|
44873
|
+
const table = SqlDdl.tableName;
|
|
44688
44874
|
const out = [`-- ${model.name || 'Data model'}: the physical level, ${tables.length} table${tables.length === 1 ? '' : 's'}`, ''];
|
|
44689
44875
|
for (const t of tables) {
|
|
44690
44876
|
const columns = t.attributes ?? [];
|
|
@@ -44694,28 +44880,28 @@
|
|
|
44694
44880
|
...(keys.length ? [` PRIMARY KEY (${keys.join(', ')})`] : []),
|
|
44695
44881
|
...columns.filter(c => c.isUnique && !c.isPK).map(c => ` UNIQUE (${SqlDdl.name(c.name)})`),
|
|
44696
44882
|
];
|
|
44697
|
-
out.push(`CREATE TABLE ${
|
|
44883
|
+
out.push(`CREATE TABLE ${table(t.name)} (`, lines.join(',\n'), ');', '');
|
|
44698
44884
|
}
|
|
44699
44885
|
for (const t of tables) {
|
|
44700
44886
|
for (const c of (t.attributes ?? []).filter(a => a.isFK && a.references)) {
|
|
44701
|
-
const ref =
|
|
44887
|
+
const ref = schema.resolveReference(c.references);
|
|
44702
44888
|
if (!ref) {
|
|
44703
44889
|
out.push(`-- ${t.name}.${c.name} references ${c.references}, which is not a table here`);
|
|
44704
44890
|
continue;
|
|
44705
44891
|
}
|
|
44706
|
-
const theirs = ref.column ? [ref.column] : (ref.
|
|
44892
|
+
const theirs = ref.column ? [ref.column] : (ref.entity.attributes ?? []).filter(a => a.isPK).map(a => a.name);
|
|
44707
44893
|
if (!theirs.length) {
|
|
44708
|
-
out.push(`-- ${t.name}.${c.name} references ${ref.
|
|
44894
|
+
out.push(`-- ${t.name}.${c.name} references ${ref.entity.name}, which has no primary key`);
|
|
44709
44895
|
continue;
|
|
44710
44896
|
}
|
|
44711
|
-
out.push(`ALTER TABLE ${
|
|
44712
|
-
+ ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${
|
|
44897
|
+
out.push(`ALTER TABLE ${table(t.name)} ADD CONSTRAINT ${SqlDdl.name(`fk_${t.name}_${c.name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_'))}`
|
|
44898
|
+
+ ` FOREIGN KEY (${SqlDdl.name(c.name)}) REFERENCES ${table(ref.entity.name)} (${theirs.map(SqlDdl.name).join(', ')});`);
|
|
44713
44899
|
}
|
|
44714
44900
|
}
|
|
44715
44901
|
if (this.comments) {
|
|
44716
44902
|
const notes = tables.flatMap(t => [
|
|
44717
|
-
...(t.description ? [`COMMENT ON TABLE ${
|
|
44718
|
-
...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${
|
|
44903
|
+
...(t.description ? [`COMMENT ON TABLE ${table(t.name)} IS ${SqlDdl.literal(t.description)};`] : []),
|
|
44904
|
+
...(t.attributes ?? []).filter(c => c.description).map(c => `COMMENT ON COLUMN ${table(t.name)}.${SqlDdl.name(c.name)} IS ${SqlDdl.literal(c.description)};`),
|
|
44719
44905
|
]);
|
|
44720
44906
|
if (notes.length) out.push('', ...notes);
|
|
44721
44907
|
}
|
|
@@ -44744,6 +44930,853 @@
|
|
|
44744
44930
|
}
|
|
44745
44931
|
}
|
|
44746
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(/ /g, ' ').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/&/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 */
|
|
45539
|
+
static _json(text) {
|
|
45540
|
+
const s = String(text ?? '').trim();
|
|
45541
|
+
if (!s.startsWith('{') && !s.startsWith('[')) return null;
|
|
45542
|
+
try { return JSON.parse(s); } catch { return null; }
|
|
45543
|
+
}
|
|
45544
|
+
|
|
45545
|
+
/**
|
|
45546
|
+
* The export as a requirements model's spec.
|
|
45547
|
+
* @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
|
|
45548
|
+
*/
|
|
45549
|
+
read(text) {
|
|
45550
|
+
const tracker = this.tracker ?? TrackerExport.detect(text) ?? 'jira';
|
|
45551
|
+
const def = TRACKERS[tracker];
|
|
45552
|
+
if (!def) throw new Error(`Unknown tracker: "${tracker}"`);
|
|
45553
|
+
const json = TrackerExport._json(text);
|
|
45554
|
+
const issues = json ? this._fromJson(tracker, json) : this._fromCsv(def, text);
|
|
45555
|
+
return { ...this._model(tracker, def, issues), tracker };
|
|
45556
|
+
}
|
|
45557
|
+
|
|
45558
|
+
/** CSV rows as issues: each field from the first of its columns there. @private */
|
|
45559
|
+
_fromCsv(def, text) {
|
|
45560
|
+
const { columns, rows } = parseCsvTable(text);
|
|
45561
|
+
const lower = new Map(columns.map(c => [c.toLowerCase(), c]));
|
|
45562
|
+
const columnOf = (field) => [...(this.columns[field] ?? []), ...(def.columns[field] ?? [])].map(c => lower.get(c.toLowerCase())).find(Boolean);
|
|
45563
|
+
const fields = Object.keys({ ...def.columns, ...this.columns, parentName: [] });
|
|
45564
|
+
const at = Object.fromEntries(fields.map(f => [f, columnOf(f)]));
|
|
45565
|
+
return rows.map(row => Object.fromEntries(fields.map(f => [f, at[f] ? row[at[f]] : ''])));
|
|
45566
|
+
}
|
|
45567
|
+
|
|
45568
|
+
/** An API's JSON as issues, in the CSV's fields. @private */
|
|
45569
|
+
_fromJson(tracker, json) {
|
|
45570
|
+
if (tracker === 'jira') {
|
|
45571
|
+
return (json.issues ?? []).map(({ key, id, fields: f = {} }) => {
|
|
45572
|
+
const custom = (names) => Object.entries(f).find(([k]) => names.some(n => k.toLowerCase().includes(n)))?.[1];
|
|
45573
|
+
// A field the host named (its custom field's key), else Jira Cloud's usual one.
|
|
45574
|
+
const field = (name) => [...(this.columns[name] ?? []), ...(JIRA_FIELDS[name] ?? [])].map(k => f[k]).find(v => v != null && v !== '');
|
|
45575
|
+
return {
|
|
45576
|
+
key, id: String(id ?? ''), type: f.issuetype?.name ?? '', title: f.summary ?? '', description: f.description ?? '',
|
|
45577
|
+
parent: f.parent?.key ?? f.parent?.id ?? '', status: f.status?.name ?? '', priority: f.priority?.name ?? '',
|
|
45578
|
+
points: field('points') ?? custom(['storypoint', 'story_point']) ?? '',
|
|
45579
|
+
release: (f.fixVersions ?? [])[0]?.name ?? '', labels: (f.labels ?? []).join(' '),
|
|
45580
|
+
criteria: field('criteria') ?? custom(['acceptance']) ?? '', url: '',
|
|
45581
|
+
};
|
|
45582
|
+
});
|
|
45583
|
+
}
|
|
45584
|
+
if (tracker === 'azure') {
|
|
45585
|
+
return (json.value ?? json.workItems ?? []).map(({ id, fields: f = {}, url }) => ({
|
|
45586
|
+
key: String(id), id: String(id), type: f['System.WorkItemType'] ?? '', title: f['System.Title'] ?? '',
|
|
45587
|
+
description: f['System.Description'] ?? '', parent: String(f['System.Parent'] ?? ''), status: f['System.State'] ?? '',
|
|
45588
|
+
priority: String(f['Microsoft.VSTS.Common.Priority'] ?? ''),
|
|
45589
|
+
points: f['Microsoft.VSTS.Scheduling.StoryPoints'] ?? f['Microsoft.VSTS.Scheduling.Effort'] ?? '',
|
|
45590
|
+
release: f['System.IterationPath'] ?? '', labels: f['System.Tags'] ?? '',
|
|
45591
|
+
criteria: f['Microsoft.VSTS.Common.AcceptanceCriteria'] ?? '', url: url ?? '',
|
|
45592
|
+
}));
|
|
45593
|
+
}
|
|
45594
|
+
return (Array.isArray(json) ? json : []).map(i => ({
|
|
45595
|
+
key: String(i.iid ?? i.id), id: String(i.iid ?? i.id), type: '', title: i.title ?? '', description: i.description ?? '',
|
|
45596
|
+
parent: String(i.epic?.id ?? i.epic_iid ?? ''), parentName: i.epic?.title ?? '', status: i.state ?? '', priority: '',
|
|
45597
|
+
points: i.weight ?? '', release: i.milestone?.title ?? '', labels: (i.labels ?? []).map(l => l.name ?? l).join(','),
|
|
45598
|
+
criteria: '', url: i.web_url ?? '',
|
|
45599
|
+
}));
|
|
45600
|
+
}
|
|
45601
|
+
|
|
45602
|
+
/** Issues as epics, releases and stories. @private */
|
|
45603
|
+
_model(tracker, def, issues) {
|
|
45604
|
+
const is = (list, type) => list.some(t => t.toLowerCase() === String(type ?? '').trim().toLowerCase());
|
|
45605
|
+
const keyOf = new Map(); // an issue's id or key → its key (Jira's CSV names a parent by its id)
|
|
45606
|
+
for (const i of issues) for (const k of [i.key, i.id]) if (k) keyOf.set(String(k), String(i.key || i.id));
|
|
45607
|
+
const source = (i, key) => ({ system: tracker, key, ...(i.url && { url: i.url }) });
|
|
45608
|
+
|
|
45609
|
+
const epics = new Map();
|
|
45610
|
+
for (const i of issues) {
|
|
45611
|
+
if (!is(def.epicTypes, i.type)) continue;
|
|
45612
|
+
const key = String(i.key || i.id);
|
|
45613
|
+
epics.set(key, { id: key, name: plainText(i.title), description: plainText(i.description), source: source(i, key) });
|
|
45614
|
+
}
|
|
45615
|
+
const releases = new Map();
|
|
45616
|
+
const stories = [];
|
|
45617
|
+
for (const i of issues) {
|
|
45618
|
+
if (is(def.epicTypes, i.type) || is(def.skipTypes, i.type) || !String(i.title ?? '').trim()) continue;
|
|
45619
|
+
const key = String(i.key || i.id);
|
|
45620
|
+
const parent = i.parent ? keyOf.get(String(i.parent)) ?? String(i.parent) : '';
|
|
45621
|
+
if (parent && !epics.has(parent) && i.parentName) epics.set(parent, { id: parent, name: plainText(i.parentName), description: '', source: { system: tracker, key: parent } });
|
|
45622
|
+
const release = plainText(i.release);
|
|
45623
|
+
if (release && !releases.has(release)) releases.set(release, { id: release, name: release.split(/[\\/]/).pop() });
|
|
45624
|
+
const own = criteriaField(i.criteria);
|
|
45625
|
+
const inText = own.length ? { criteria: [], rest: plainText(i.description) } : criteriaFrom(i.description);
|
|
45626
|
+
const said = narrativeFrom(i.description);
|
|
45627
|
+
const { as, want, soThat } = said.want ? said : narrativeFrom(i.title);
|
|
45628
|
+
stories.push({
|
|
45629
|
+
id: key, name: plainText(i.title), epic: parent, release,
|
|
45630
|
+
as, want, soThat, description: said.want ? withoutNarrative(inText.rest) : inText.rest,
|
|
45631
|
+
criteria: own.length ? own : inText.criteria,
|
|
45632
|
+
status: plainText(i.status), priority: plainText(i.priority), points: i.points === '' ? null : i.points,
|
|
45633
|
+
labels: String(i.labels ?? '').split(def.labelSeparator).map(l => l.trim()).filter(Boolean),
|
|
45634
|
+
source: source(i, key), traces: [],
|
|
45635
|
+
});
|
|
45636
|
+
}
|
|
45637
|
+
// A story whose parent is no epic here (a story's sub-item, an epic not exported) is under none.
|
|
45638
|
+
for (const s of stories) if (s.epic && !epics.has(s.epic)) s.epic = '';
|
|
45639
|
+
return { epics: [...epics.values()], releases: [...releases.values()], stories };
|
|
45640
|
+
}
|
|
45641
|
+
}
|
|
45642
|
+
|
|
45643
|
+
/**
|
|
45644
|
+
* Storyboard — epics and stories from a storyboard or requirements document
|
|
45645
|
+
* written in Markdown (or plain text with Markdown headings), as teams keep
|
|
45646
|
+
* them beside or before a tracker:
|
|
45647
|
+
*
|
|
45648
|
+
* - a heading that says "Epic" (`## Epic: Checkout`) is an epic, one that says
|
|
45649
|
+
* "Story" (`### Story: Pay by card`) a story; otherwise the document's one
|
|
45650
|
+
* title (`#`) is its name, the next level down are epics and the level
|
|
45651
|
+
* below that stories;
|
|
45652
|
+
* - under an epic, a list item that reads "As a … I want …" is a story too;
|
|
45653
|
+
* - under a story, "As a … I want … so that …" is its narrative, the list
|
|
45654
|
+
* under "Acceptance criteria" (or its Given/When/Then lines) its criteria,
|
|
45655
|
+
* and the rest its description — a picture (``) kept there
|
|
45656
|
+
* by its caption.
|
|
45657
|
+
*
|
|
45658
|
+
* `read(text)` gives a RequirementsModel spec (`{name, epics, stories}`); ids
|
|
45659
|
+
* are made from names (prefix: `prefix`, so two storyboards read into one
|
|
45660
|
+
* model stay apart).
|
|
45661
|
+
*/
|
|
45662
|
+
class Storyboard {
|
|
45663
|
+
/** @param {{prefix?: string}} [options] */
|
|
45664
|
+
constructor({ prefix = '' } = {}) {
|
|
45665
|
+
this.prefix = prefix;
|
|
45666
|
+
}
|
|
45667
|
+
|
|
45668
|
+
read(text) {
|
|
45669
|
+
const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
45670
|
+
const headings = lines.map((line, at) => ({ at, m: /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line) })).filter(h => h.m)
|
|
45671
|
+
.map(({ at, m }) => ({ at, level: m[1].length, text: m[2].trim() }));
|
|
45672
|
+
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);
|
|
45673
|
+
const top = Math.min(...headings.map(h => h.level));
|
|
45674
|
+
const tops = headings.filter(h => h.level === top);
|
|
45675
|
+
// One top heading, a title (#), names the document; its children are the epics.
|
|
45676
|
+
const named = top === 1 && tops.length === 1 && !kindOf(tops[0]) && headings.length > 1;
|
|
45677
|
+
const epicLevel = named ? top + 1 : top;
|
|
45678
|
+
const strip = (t) => t.replace(/^(epic|(user\s+)?story)\s*[:\-–—]?\s*/i, '').trim();
|
|
45679
|
+
|
|
45680
|
+
const epics = [], stories = [];
|
|
45681
|
+
const ids = new Set();
|
|
45682
|
+
const idFor = (name, kind) => {
|
|
45683
|
+
const base = `${this.prefix}${String(name).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || kind}`;
|
|
45684
|
+
let id = base;
|
|
45685
|
+
for (let n = 2; ids.has(id); n++) id = `${base}-${n}`;
|
|
45686
|
+
ids.add(id);
|
|
45687
|
+
return id;
|
|
45688
|
+
};
|
|
45689
|
+
const bodyOf = (h, i) => lines.slice(h.at + 1, headings[i + 1]?.at ?? lines.length).join('\n');
|
|
45690
|
+
let epic = null;
|
|
45691
|
+
headings.forEach((h, i) => {
|
|
45692
|
+
if (named && h === tops[0]) return;
|
|
45693
|
+
const kind = kindOf(h) ?? (h.level === epicLevel ? 'epic' : h.level === epicLevel + 1 ? 'story' : null);
|
|
45694
|
+
const body = bodyOf(h, i);
|
|
45695
|
+
if (kind === 'epic') {
|
|
45696
|
+
epic = { id: idFor(strip(h.text), 'epic'), name: strip(h.text), description: '' };
|
|
45697
|
+
epics.push(epic);
|
|
45698
|
+
const listed = this._listedStories(body);
|
|
45699
|
+
epic.description = listed.rest;
|
|
45700
|
+
for (const s of listed.stories) stories.push({ ...s, id: idFor(s.name, 'story'), epic: epic.id });
|
|
45701
|
+
} else if (kind === 'story') {
|
|
45702
|
+
stories.push({ ...this._story(strip(h.text), body), id: idFor(strip(h.text), 'story'), epic: epic?.id ?? '' });
|
|
45703
|
+
}
|
|
45704
|
+
});
|
|
45705
|
+
if (!headings.length || (!epics.length && !stories.length)) {
|
|
45706
|
+
for (const s of this._listedStories(lines.join('\n')).stories) stories.push({ ...s, id: idFor(s.name, 'story') });
|
|
45707
|
+
}
|
|
45708
|
+
return { name: named ? tops[0].text : '', epics, stories };
|
|
45709
|
+
}
|
|
45710
|
+
|
|
45711
|
+
/** A story from its heading and what is under it. @private */
|
|
45712
|
+
_story(name, body) {
|
|
45713
|
+
const pictures = [...body.matchAll(/!\[([^\]]*)\]\([^)]*\)/g)].map(m => m[1]).filter(Boolean);
|
|
45714
|
+
const text = body.replace(/!\[([^\]]*)\]\([^)]*\)/g, '').trim();
|
|
45715
|
+
const said = narrativeFrom(text);
|
|
45716
|
+
const { as, want, soThat } = said.want ? said : narrativeFrom(name);
|
|
45717
|
+
const { criteria, rest } = criteriaFrom(text);
|
|
45718
|
+
const description = [withoutNarrative(rest), ...pictures.map(c => `Picture: ${c}`)].filter(Boolean).join('\n');
|
|
45719
|
+
return { name, as, want, soThat, description, criteria };
|
|
45720
|
+
}
|
|
45721
|
+
|
|
45722
|
+
/** "As a … I want …" list items: stories; the rest of the text, kept. @private */
|
|
45723
|
+
_listedStories(body) {
|
|
45724
|
+
const stories = [];
|
|
45725
|
+
const rest = [];
|
|
45726
|
+
for (const line of body.split('\n')) {
|
|
45727
|
+
const item = /^\s*(?:[-*•+]|\d+[.)])\s+(.*)$/.exec(line);
|
|
45728
|
+
const said = item && narrativeFrom(item[1]);
|
|
45729
|
+
if (said?.want) stories.push({ name: said.want.replace(/^to\s+/i, '').replace(/^./, c => c.toUpperCase()), ...said, description: '', criteria: [] });
|
|
45730
|
+
else rest.push(line);
|
|
45731
|
+
}
|
|
45732
|
+
return { stories, rest: rest.join('\n').trim() };
|
|
45733
|
+
}
|
|
45734
|
+
}
|
|
45735
|
+
|
|
45736
|
+
/**
|
|
45737
|
+
* The files a requirements model is read from that another tool wrote — they
|
|
45738
|
+
* import only: a tracker's export (TrackerExport: Jira, Azure DevOps,
|
|
45739
|
+
* GitLab, CSV or JSON) and a storyboard (Storyboard: Markdown). Each reads
|
|
45740
|
+
* the file as a new model; `spec(text)` is what it holds, for a part that
|
|
45741
|
+
* brings it into the model open (RequirementsModel#bringCommand).
|
|
45742
|
+
*/
|
|
45743
|
+
class RequirementsImportFormat extends ModelFileFormat {
|
|
45744
|
+
get exports() { return false; }
|
|
45745
|
+
|
|
45746
|
+
/** What the file holds, as a RequirementsModel spec. @abstract */
|
|
45747
|
+
spec(text) { throw new Error(`${this.constructor.name} must implement spec()`); }
|
|
45748
|
+
|
|
45749
|
+
import(text) {
|
|
45750
|
+
return new RequirementsModel(this.spec(text));
|
|
45751
|
+
}
|
|
45752
|
+
}
|
|
45753
|
+
|
|
45754
|
+
class TrackerExportFileFormat extends RequirementsImportFormat {
|
|
45755
|
+
get label() { return 'Tracker export (Jira, Azure DevOps, GitLab)'; }
|
|
45756
|
+
get accept() { return '.csv,.json'; }
|
|
45757
|
+
get featureName() { return 'TrackerExport'; }
|
|
45758
|
+
|
|
45759
|
+
/** A CSV or JSON export one of the trackers wrote. */
|
|
45760
|
+
recognizes(text) {
|
|
45761
|
+
return !!TrackerExport.detect(text);
|
|
45762
|
+
}
|
|
45763
|
+
|
|
45764
|
+
spec(text) {
|
|
45765
|
+
const { tracker, ...spec } = new TrackerExport().read(text);
|
|
45766
|
+
return { name: `${TrackerExport.label(tracker)} export`, ...spec };
|
|
45767
|
+
}
|
|
45768
|
+
}
|
|
45769
|
+
|
|
45770
|
+
class StoryboardFileFormat extends RequirementsImportFormat {
|
|
45771
|
+
get label() { return 'Storyboard (Markdown)'; }
|
|
45772
|
+
get accept() { return '.md,.markdown,.txt'; }
|
|
45773
|
+
get featureName() { return 'Storyboard'; }
|
|
45774
|
+
|
|
45775
|
+
spec(text) {
|
|
45776
|
+
return new Storyboard().read(text);
|
|
45777
|
+
}
|
|
45778
|
+
}
|
|
45779
|
+
|
|
44747
45780
|
/**
|
|
44748
45781
|
* propertiesDialog — the "Show/Edit Properties" dialog, as a shipped feature.
|
|
44749
45782
|
*
|
|
@@ -47655,6 +48688,7 @@
|
|
|
47655
48688
|
exports.AttributedLevel = AttributedLevel;
|
|
47656
48689
|
exports.BackgroundCommand = BackgroundCommand;
|
|
47657
48690
|
exports.BackgroundGroup = BackgroundGroup;
|
|
48691
|
+
exports.Benchmark = Benchmark;
|
|
47658
48692
|
exports.BianActionTerm = BianActionTerm;
|
|
47659
48693
|
exports.BianFunctionalPattern = BianFunctionalPattern;
|
|
47660
48694
|
exports.BianLandscapeReader = BianLandscapeReader;
|
|
@@ -47732,6 +48766,7 @@
|
|
|
47732
48766
|
exports.EditHintCommand = EditHintCommand;
|
|
47733
48767
|
exports.EditPropertyCommand = EditPropertyCommand;
|
|
47734
48768
|
exports.EditViewItemCommand = EditViewItemCommand;
|
|
48769
|
+
exports.EditableModel = EditableModel;
|
|
47735
48770
|
exports.EditorShell = EditorShell;
|
|
47736
48771
|
exports.ElementFilterRule = ElementFilterRule;
|
|
47737
48772
|
exports.ElementInstance = ElementInstance;
|
|
@@ -47776,6 +48811,7 @@
|
|
|
47776
48811
|
exports.Mat4 = Mat4;
|
|
47777
48812
|
exports.MeasuredCategory = MeasuredCategory;
|
|
47778
48813
|
exports.MenuBar = MenuBar;
|
|
48814
|
+
exports.ModelEditCommand = ModelEditCommand;
|
|
47779
48815
|
exports.ModelFileFormat = ModelFileFormat;
|
|
47780
48816
|
exports.ModelScorecard = ModelScorecard;
|
|
47781
48817
|
exports.ModelTree = ModelTree;
|
|
@@ -47810,6 +48846,9 @@
|
|
|
47810
48846
|
exports.RemoveLineCommand = RemoveLineCommand;
|
|
47811
48847
|
exports.RenderEngine = RenderEngine;
|
|
47812
48848
|
exports.ReorderCommand = ReorderCommand;
|
|
48849
|
+
exports.RequirementsChecker = RequirementsChecker;
|
|
48850
|
+
exports.RequirementsFileFormat = RequirementsFileFormat;
|
|
48851
|
+
exports.RequirementsModel = RequirementsModel;
|
|
47813
48852
|
exports.ResizeCommand = ResizeCommand;
|
|
47814
48853
|
exports.RotateCommand = RotateCommand;
|
|
47815
48854
|
exports.RoundedRectangleTemplate = RoundedRectangleTemplate;
|
|
@@ -47826,8 +48865,11 @@
|
|
|
47826
48865
|
exports.SqlDdlFileFormat = SqlDdlFileFormat;
|
|
47827
48866
|
exports.StadiumTemplate = StadiumTemplate;
|
|
47828
48867
|
exports.StarTemplate = StarTemplate;
|
|
48868
|
+
exports.Storyboard = Storyboard;
|
|
48869
|
+
exports.StoryboardFileFormat = StoryboardFileFormat;
|
|
47829
48870
|
exports.StructuredType = StructuredType;
|
|
47830
48871
|
exports.SwimlaneTemplate = SwimlaneTemplate;
|
|
48872
|
+
exports.TRACKERS = TRACKERS;
|
|
47831
48873
|
exports.TREE_LAYERS = TREE_LAYERS;
|
|
47832
48874
|
exports.TREE_MODE_CATALOG = TREE_MODE_CATALOG;
|
|
47833
48875
|
exports.Template = Template;
|
|
@@ -47841,6 +48883,8 @@
|
|
|
47841
48883
|
exports.ToolbarSelect = ToolbarSelect;
|
|
47842
48884
|
exports.ToolbarSeparator = ToolbarSeparator;
|
|
47843
48885
|
exports.ToolbarText = ToolbarText;
|
|
48886
|
+
exports.TrackerExport = TrackerExport;
|
|
48887
|
+
exports.TrackerExportFileFormat = TrackerExportFileFormat;
|
|
47844
48888
|
exports.TrapezoidTemplate = TrapezoidTemplate;
|
|
47845
48889
|
exports.TreeModeRegistry = TreeModeRegistry;
|
|
47846
48890
|
exports.TriangleTemplate = TriangleTemplate;
|