@peter.prib/renderengine 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +4 -0
  2. package/REFERENCE.md +267 -17
  3. package/dist/renderengine-workspace.umd.js +5289 -2895
  4. package/dist/renderengine.umd.js +4243 -2120
  5. package/examples/archimate.html +8 -28
  6. package/examples/data-model.html +45 -556
  7. package/package.json +70 -70
  8. package/src/archimateArchiFormat.js +11 -3
  9. package/src/archimateExchangeFormat.js +17 -9
  10. package/src/charting/ArchimateDiagramEditor.js +1 -1
  11. package/src/charting/Axis.js +20 -0
  12. package/src/charting/DiagramEditor.js +35 -3
  13. package/src/charting/Dialog.js +195 -0
  14. package/src/charting/DuplicatesDialog.js +108 -0
  15. package/src/charting/FormDialog.js +28 -97
  16. package/src/charting/InteractionManager.js +3 -0
  17. package/src/charting/Legend.js +45 -2
  18. package/src/charting/MergeCompareDialog.js +81 -0
  19. package/src/charting/PopupMenu.js +12 -3
  20. package/src/charting/SettingsDialog.js +6 -2
  21. package/src/charting/SettingsManager.js +14 -1
  22. package/src/charting/archimate/ArchimateCanvasEditor.js +190 -15
  23. package/src/charting/archimate/ArchimatePalette.js +1 -1
  24. package/src/charting/charts/AreaChart.js +5 -2
  25. package/src/charting/charts/BarChart.js +5 -3
  26. package/src/charting/charts/BarChart3DOrbit.js +5 -7
  27. package/src/charting/charts/BubbleChart.js +5 -7
  28. package/src/charting/charts/BubbleChart3D.js +5 -7
  29. package/src/charting/charts/CandlestickChart.js +3 -1
  30. package/src/charting/charts/DensityChart.js +4 -6
  31. package/src/charting/charts/GroupedBarChart.js +2 -2
  32. package/src/charting/charts/LineChart.js +5 -2
  33. package/src/charting/charts/ParallelCoordinatesChart.js +2 -2
  34. package/src/charting/charts/PipeChart.js +4 -6
  35. package/src/charting/charts/RadarChart.js +5 -6
  36. package/src/charting/charts/ScatterChart.js +5 -7
  37. package/src/charting/charts/StackedBarChart.js +4 -2
  38. package/src/charting/charts/StackedBarChart3DOrbit.js +5 -7
  39. package/src/charting/charts/StreamGraphChart.js +5 -2
  40. package/src/charting/charts/WaterfallChart.js +2 -1
  41. package/src/charting/content/ContentSource.js +129 -0
  42. package/src/charting/content/ContentSources.js +77 -0
  43. package/src/charting/datamodel/DataModel.js +116 -0
  44. package/src/charting/diagrams/ArchimateModel.js +38 -3
  45. package/src/charting/diagrams/ArchimateModelChecker.js +13 -4
  46. package/src/charting/diagrams/MergeElementsCommand.js +124 -101
  47. package/src/charting/diagrams/SvgZoom.js +20 -1
  48. package/src/charting/diagrams/duplicates.js +95 -13
  49. package/src/charting/duplicateSearchForm.js +58 -0
  50. package/src/charting/modelTree/ModelTree.js +239 -202
  51. package/src/charting/modelTree/ModelTreePanel.js +955 -919
  52. package/src/charting/modelTree/TreeBuilder.js +93 -45
  53. package/src/charting/modelTree/TreeViewMode.js +9 -6
  54. package/src/charting/modelTree/viewFolders.js +15 -6
  55. package/src/charting/requirements/AzureDevOpsApi.js +55 -0
  56. package/src/charting/requirements/GitLabApi.js +43 -0
  57. package/src/charting/requirements/JiraApi.js +69 -0
  58. package/src/charting/requirements/TrackerApi.js +142 -0
  59. package/src/charting/requirements/TrackerExport.js +3 -2
  60. package/src/charting/requirements/index.js +5 -0
  61. package/src/charting/requirements/trackerApis.js +21 -0
  62. package/src/icons/IconSet.js +86 -0
  63. package/src/icons/index.js +59 -0
  64. package/src/index.js +67 -10
  65. package/src/workspace/dom.js +56 -42
  66. package/src/workspace/index.js +1 -1
  67. package/src/workspace/nav/NavEntry.js +42 -0
  68. package/src/workspace/nav/NavGroup.js +43 -0
  69. package/src/workspace/nav/NavItem.js +53 -0
  70. package/src/workspace/nav/NavMenu.js +143 -0
  71. package/src/workspace/nav/NavSection.js +17 -0
  72. package/src/workspace/nav/index.js +5 -0
  73. package/src/workspace/parts/ArchimateCanvasPart.js +8 -3
  74. package/src/workspace/parts/DataModelPart.js +91 -0
  75. package/src/workspace/parts/EditorPart.js +8 -0
  76. package/src/workspace/parts/ModelHosted.js +1 -0
  77. package/src/workspace/parts/ModelsPart.js +765 -622
  78. package/src/workspace/styles.js +28 -1
@@ -1,3 +1,20 @@
1
+ /** A row of the tree, branch or leaf: one line, compact; a label too long ends in an ellipsis. */
2
+ const ROW_STYLE = `
3
+ cursor: pointer;
4
+ padding: 1px 4px;
5
+ margin: 0;
6
+ border-radius: 3px;
7
+ user-select: none;
8
+ border-left: 3px solid transparent;
9
+ transition: background-color 0.1s;
10
+ display: flex;
11
+ align-items: center;
12
+ gap: 4px;
13
+ line-height: 1.35;
14
+ white-space: nowrap;
15
+ overflow: hidden;
16
+ `;
17
+
1
18
  /**
2
19
  * Generic tree component builder for hierarchical node rendering.
3
20
  * Handles expand/collapse, selection, context menus, and recursive rendering.
@@ -19,8 +36,14 @@ export class TreeBuilder {
19
36
  * @param {Map<string, boolean>} [options.openState] - Which folders are open, by node id: kept
20
37
  * by the caller so a rebuilt tree opens them as they were. A folder not in it is open unless
21
38
  * its node says `collapsed: true`; opening or closing one records it here.
39
+ * @param {(node) => boolean} [options.filter] - Which leaves are shown: a folder is shown when a
40
+ * leaf under it is, and open while filtering. None: every node.
41
+ * @param {number} [options.leafLimit] - The leaves a folder shows; the rest wait behind a
42
+ * "▸ N more" row, which shows them. The folders inside it always show.
43
+ * @param {Set<string>} [options.shownAll] - The folders (by node id) showing every leaf, kept by
44
+ * the caller as openState is; asking for "more" records the folder here.
22
45
  */
23
- constructor(doc, { container, rootNodes = [], onNodeClick = null, onNodeContext = null, getNodeChildren = null, openState = new Map(), iconFor = null } = {}) {
46
+ constructor(doc, { container, rootNodes = [], onNodeClick = null, onNodeContext = null, getNodeChildren = null, openState = new Map(), iconFor = null, filter = null, leafLimit = Infinity, shownAll = new Set() } = {}) {
24
47
  this.doc = doc;
25
48
  this.container = container;
26
49
  this.rootNodes = rootNodes;
@@ -29,7 +52,12 @@ export class TreeBuilder {
29
52
  this.getNodeChildren = getNodeChildren;
30
53
  this.openState = openState;
31
54
  this.iconFor = iconFor;
55
+ this.filter = filter;
56
+ this.leafLimit = leafLimit;
57
+ this.shownAll = shownAll;
32
58
  this.selectedNode = null;
59
+ /** Each node's shown children, asked for once per render (getNodeChildren makes new nodes). */
60
+ this._children = new WeakMap();
33
61
  }
34
62
 
35
63
  /**
@@ -40,10 +68,25 @@ export class TreeBuilder {
40
68
  const container = this.container;
41
69
  if (!container) return;
42
70
 
43
- container.innerHTML = '';
44
- for (const node of nodes) {
45
- container.appendChild(this._renderNode(node));
71
+ this._children = new WeakMap();
72
+ container.replaceChildren(...nodes.filter(node => this._shown(node)).map(node => this._renderNode(node)));
73
+ }
74
+
75
+ /** A node's children that are shown: with a filter, the leaves it keeps and the folders holding one. @private */
76
+ _childrenOf(node) {
77
+ if (!this._children.has(node)) {
78
+ const all = this.getNodeChildren?.(node) || [];
79
+ this._children.set(node, this.filter ? all.filter(child => this._shown(child)) : all);
46
80
  }
81
+ return this._children.get(node);
82
+ }
83
+
84
+ /** Whether a node is shown: always without a filter; else a leaf the filter keeps, or a folder with a shown child. @private */
85
+ _shown(node) {
86
+ if (!this.filter) return true;
87
+ if (this._childrenOf(node).length) return true;
88
+ // A folder whose children are all filtered out is not a leaf to test.
89
+ return !(this.getNodeChildren?.(node) || []).length && !!this.filter(node);
47
90
  }
48
91
 
49
92
  /**
@@ -51,14 +94,8 @@ export class TreeBuilder {
51
94
  * @private
52
95
  */
53
96
  _renderNode(node) {
54
- const children = this.getNodeChildren?.(node) || [];
55
- const hasChildren = children.length > 0;
56
-
57
- if (hasChildren) {
58
- return this._renderExpandable(node, children);
59
- } else {
60
- return this._renderLeaf(node);
61
- }
97
+ const children = this._childrenOf(node);
98
+ return children.length ? this._renderExpandable(node, children) : this._renderLeaf(node);
62
99
  }
63
100
 
64
101
  /**
@@ -67,23 +104,13 @@ export class TreeBuilder {
67
104
  */
68
105
  _renderExpandable(node, children) {
69
106
  const details = this.doc.createElement('details');
70
- details.open = this.openState.get(node.id) ?? !node.collapsed;
107
+ // Open while filtering, to show what was found (not recorded); else as it was.
108
+ details.open = this.filter ? true : this.openState.get(node.id) ?? !node.collapsed;
71
109
  details.setAttribute('data-node-id', node.id);
72
- details.addEventListener('toggle', () => this.openState.set(node.id, details.open));
110
+ details.addEventListener('toggle', () => { if (!this.filter) this.openState.set(node.id, details.open); });
73
111
 
74
112
  const summary = this.doc.createElement('summary');
75
- summary.style.cssText = `
76
- cursor: pointer;
77
- padding: 4px 6px;
78
- margin: 2px 0;
79
- border-radius: 3px;
80
- user-select: none;
81
- border-left: 3px solid transparent;
82
- transition: background-color 0.1s;
83
- display: flex;
84
- align-items: center;
85
- gap: 6px;
86
- `;
113
+ summary.style.cssText = ROW_STYLE;
87
114
  summary.setAttribute('data-node-id', node.id);
88
115
 
89
116
  this._applyNodeStyling(summary, node);
@@ -95,15 +122,45 @@ export class TreeBuilder {
95
122
  details.appendChild(summary);
96
123
 
97
124
  const childContainer = this.doc.createElement('div');
98
- childContainer.style.cssText = 'padding-left: 16px;';
99
- for (const child of children) {
100
- childContainer.appendChild(this._renderNode(child));
101
- }
125
+ childContainer.style.cssText = 'padding-left: 12px;';
126
+ this._fillChildren(childContainer, node, children);
102
127
  details.appendChild(childContainer);
103
128
 
104
129
  return details;
105
130
  }
106
131
 
132
+ /**
133
+ * A folder's children: its folders, and its leaves up to leafLimit, then a
134
+ * "▸ N more" row that shows the rest (unless the folder shows them all already).
135
+ * @private
136
+ */
137
+ _fillChildren(container, node, children) {
138
+ const limit = this.shownAll.has(node.id) ? Infinity : this.leafLimit;
139
+ let leaves = 0;
140
+ let waiting = 0;
141
+ const rows = [];
142
+ for (const child of children) {
143
+ if (!this._childrenOf(child).length && leaves++ >= limit) { waiting++; continue; }
144
+ rows.push(this._renderNode(child));
145
+ }
146
+ if (waiting) rows.push(this._moreRow(waiting, () => {
147
+ this.shownAll.add(node.id);
148
+ this._fillChildren(container, node, children);
149
+ }));
150
+ container.replaceChildren(...rows);
151
+ }
152
+
153
+ /** The row standing for `count` leaves not shown; choosing it calls `showAll`. @private */
154
+ _moreRow(count, showAll) {
155
+ const row = this.doc.createElement('div');
156
+ row.setAttribute('data-tree-more', String(count));
157
+ row.style.cssText = `${ROW_STYLE} color: #666; font-style: italic;`;
158
+ row.textContent = `▸ ${count} more`;
159
+ row.title = 'Show them all';
160
+ row.addEventListener('click', (e) => { e.stopPropagation(); showAll(); });
161
+ return row;
162
+ }
163
+
107
164
  /**
108
165
  * Render a leaf node without children.
109
166
  * @private
@@ -111,23 +168,12 @@ export class TreeBuilder {
111
168
  _renderLeaf(node) {
112
169
  const leaf = this.doc.createElement('div');
113
170
  leaf.setAttribute('data-node-id', node.id);
114
- // Element leaves are filter targets and drag sources; mark them here so a rebuilt tree keeps that.
171
+ // Element leaves are drag sources; mark them here so a rebuilt tree keeps that.
115
172
  if (node.type === 'element' && node._element) {
116
173
  leaf.setAttribute('data-element-id', node._element.id);
117
174
  leaf.draggable = true;
118
175
  }
119
- leaf.style.cssText = `
120
- padding: 4px 6px;
121
- margin: 2px 0;
122
- border-radius: 3px;
123
- cursor: pointer;
124
- user-select: none;
125
- border-left: 3px solid transparent;
126
- transition: background-color 0.1s;
127
- display: flex;
128
- align-items: center;
129
- gap: 6px;
130
- `;
176
+ leaf.style.cssText = ROW_STYLE;
131
177
 
132
178
  this._applyNodeStyling(leaf, node);
133
179
  leaf.addEventListener('click', (e) => this._handleNodeClick(e, leaf, node));
@@ -153,13 +199,15 @@ export class TreeBuilder {
153
199
  } else if (node.icon) {
154
200
  const icon = this.doc.createElement('span');
155
201
  icon.textContent = node.icon;
156
- icon.style.cssText = 'flex-shrink: 0; font-size: 14px;';
202
+ icon.style.cssText = 'flex-shrink: 0; font-size: 11px;';
157
203
  element.appendChild(icon);
158
204
  }
159
205
 
160
- // Label
206
+ // Label: one line, cut with an ellipsis; the whole of it on hover.
161
207
  const label = this.doc.createElement('span');
162
208
  label.textContent = node.label || 'Untitled';
209
+ label.style.cssText = 'overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;';
210
+ element.title = label.textContent;
163
211
  element.appendChild(label);
164
212
  }
165
213
 
@@ -7,6 +7,13 @@ export class TreeViewMode {
7
7
  static key = null; // registry key; subclasses override
8
8
  static label = ''; // human-readable label; subclasses override
9
9
 
10
+ /** Whether `text` is in any of `fields` (case ignored); no text matches everything. */
11
+ static textMatches(text, fields) {
12
+ if (!text) return true;
13
+ const lower = text.toLowerCase();
14
+ return fields.some(f => String(f ?? '').toLowerCase().includes(lower));
15
+ }
16
+
10
17
  constructor() {
11
18
  if (new.target === TreeViewMode) {
12
19
  throw new TypeError('TreeViewMode is abstract — subclass it');
@@ -59,12 +66,8 @@ export class TreeViewMode {
59
66
  */
60
67
  _matchesFilter(item, { text = '', typeIds = null, specializationIds = null, propertyIds = null, inDescriptions = false } = {}) {
61
68
  // Text filter (label or type; the description too when asked)
62
- if (text) {
63
- const textLower = text.toLowerCase();
64
- const fields = [item.label, item.type, ...(inDescriptions ? [item.description] : [])];
65
- if (!fields.some(f => String(f ?? '').toLowerCase().includes(textLower))) {
66
- return false;
67
- }
69
+ if (!TreeViewMode.textMatches(text, [item.label, item.type, ...(inDescriptions ? [item.description] : [])])) {
70
+ return false;
68
71
  }
69
72
 
70
73
  // Type filter (elements only; 'type:business-actor' format)
@@ -6,23 +6,32 @@ const VIEW_ICONS = Object.freeze({ canvas: '🎨' });
6
6
  * keeps them: each view remembers its sub-folder path (`archiFolder`, folder
7
7
  * steps {id, name}). A folder node per sub-folder at this depth (holding the
8
8
  * views under it, opened a level deeper), then the views that sit here, each
9
- * in the model's order.
9
+ * in the model's order. A sub-folder that holds no view is there too, from
10
+ * its path (the model's `emptyViewFolders`).
10
11
  * @param {Array<Object>} views
11
12
  * @param {number} [depth]
13
+ * @param {Array<Array<{id, name}>>} [emptyFolders] - The paths of the folders with no view in them
12
14
  * @returns {Array<Object>} TreeBuilder nodes: {id, label, icon, type: 'view-folder'|'view', ...}
13
15
  */
14
- export function viewFolderNodes(views, depth = 0) {
16
+ export function viewFolderNodes(views, depth = 0, emptyFolders = []) {
15
17
  const folders = new Map();
18
+ const folderOf = (step) => {
19
+ if (!folders.has(step.id)) folders.set(step.id, { step, views: [], empty: [] });
20
+ return folders.get(step.id);
21
+ };
16
22
  const here = [];
17
23
  for (const view of views) {
18
24
  const step = view.archiFolder?.[depth];
19
25
  if (!step) { here.push(view); continue; }
20
- if (!folders.has(step.id)) folders.set(step.id, { step, views: [] });
21
- folders.get(step.id).views.push(view);
26
+ folderOf(step).views.push(view);
27
+ }
28
+ for (const path of emptyFolders) {
29
+ if (path.length > depth) folderOf(path[depth]).empty.push(path);
22
30
  }
23
31
  return [
24
- ...[...folders.values()].map(({ step, views: inside }) => ({
25
- id: `view-folder-${step.id}`, label: step.name || 'Untitled Folder', icon: '📁', type: 'view-folder', _views: inside, _depth: depth + 1,
32
+ ...[...folders.values()].map(({ step, views: inside, empty }) => ({
33
+ id: `view-folder-${step.id}`, label: step.name || 'Untitled Folder', icon: '📁', type: 'view-folder',
34
+ _views: inside, _emptyFolders: empty, _depth: depth + 1,
26
35
  })),
27
36
  ...here.map(view => ({
28
37
  id: view.id, label: view.name || view.title || 'Untitled View', icon: VIEW_ICONS[view.constructor.kind] ?? '📄', type: 'view', _view: view,
@@ -0,0 +1,55 @@
1
+ import { TrackerApi } from './TrackerApi.js';
2
+
3
+ /**
4
+ * Azure DevOps (Boards) through its REST API: a WIQL query for a project's
5
+ * work items (but its tasks — TRACKERS), then the items, 200 at a time. Signs
6
+ * in with a personal access token. Each item's link is its page in Boards.
7
+ */
8
+ export class AzureDevOpsApi extends TrackerApi {
9
+ static tracker = 'azure';
10
+
11
+ /** Most ids one work-items request takes. */
12
+ static BATCH = 200;
13
+
14
+ static API_VERSION = '7.1';
15
+
16
+ static baseUrlPlaceholder = 'https://dev.azure.com/your-organisation';
17
+
18
+ static tokenHint = 'A personal access token with Work Items (Read)';
19
+
20
+ static get ownFields() {
21
+ return [
22
+ { key: 'project', label: 'Project', required: true, placeholder: 'The project, e.g. Shop' },
23
+ { key: 'query', label: 'WIQL condition', placeholder: "[System.AreaPath] UNDER 'Shop\\Checkout' (and the project's)" },
24
+ ];
25
+ }
26
+
27
+ headers() {
28
+ return { Authorization: `Basic ${TrackerApi.base64(`:${this.connection.token}`)}` };
29
+ }
30
+
31
+ /** The WIQL: the project's work items, not its tasks, and the condition given. */
32
+ get wiql() {
33
+ const types = this.skipTypes.map(t => `'${t.replace(/'/g, "''")}'`).join(', ');
34
+ const condition = String(this.connection.query ?? '').trim();
35
+ return `SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project`
36
+ + (types ? ` AND [System.WorkItemType] NOT IN (${types})` : '')
37
+ + (condition ? ` AND (${condition})` : '')
38
+ + ' ORDER BY [System.Id]';
39
+ }
40
+
41
+ async _read() {
42
+ const project = encodeURIComponent(String(this.connection.project).trim());
43
+ const version = `api-version=${this.constructor.API_VERSION}`;
44
+ const { json } = await this.request(this.url(`/${project}/_apis/wit/wiql?${version}`), { method: 'POST', body: { query: this.wiql } });
45
+ const ids = (json.workItems ?? []).map(w => w.id);
46
+ const value = [];
47
+ for (let i = 0; i < ids.length; i += this.constructor.BATCH) {
48
+ const batch = ids.slice(i, i + this.constructor.BATCH);
49
+ const { json: items } = await this.request(this.url(`/${project}/_apis/wit/workitems?ids=${batch.join(',')}&${version}`));
50
+ const got = (items.value ?? []).map(w => ({ ...w, url: this.url(`/${project}/_workitems/edit/${w.id}`) }));
51
+ if (this.keep(value, got)) { this.truncated ||= i + batch.length < ids.length; break; }
52
+ }
53
+ return { value };
54
+ }
55
+ }
@@ -0,0 +1,43 @@
1
+ import { TrackerApi } from './TrackerApi.js';
2
+
3
+ /**
4
+ * GitLab through its REST API: a project's issues, open and closed, 100 a
5
+ * page; filtered as its issues API filters (`labels=…&milestone=…`). Signs
6
+ * in with a personal, project or group access token. An issue's epic
7
+ * (GitLab Premium) is its epic.
8
+ */
9
+ export class GitLabApi extends TrackerApi {
10
+ static tracker = 'gitlab';
11
+
12
+ static PAGE = 100;
13
+
14
+ static baseUrlPlaceholder = 'https://gitlab.com';
15
+
16
+ static tokenHint = 'An access token with read_api';
17
+
18
+ static get ownFields() {
19
+ return [
20
+ { key: 'project', label: 'Project', required: true, placeholder: 'group/project, or its id' },
21
+ { key: 'query', label: 'Filter', placeholder: 'labels=requirements&milestone=Release 2 (as the issues API takes)' },
22
+ ];
23
+ }
24
+
25
+ headers() {
26
+ return { 'PRIVATE-TOKEN': String(this.connection.token) };
27
+ }
28
+
29
+ async _read() {
30
+ const project = encodeURIComponent(String(this.connection.project).trim());
31
+ const issues = [];
32
+ for (let page = 1; page;) {
33
+ const params = new URLSearchParams(String(this.connection.query ?? '').trim());
34
+ for (const [k, v] of Object.entries({ state: params.get('state') ?? 'all', per_page: String(this.constructor.PAGE), page: String(page) })) params.set(k, v);
35
+ const { json, headers } = await this.request(this.url(`/api/v4/projects/${project}/issues?${params}`));
36
+ const got = Array.isArray(json) ? json : [];
37
+ const next = Number(headers?.get?.('x-next-page')) || (got.length === this.constructor.PAGE && !headers?.has?.('x-next-page') ? page + 1 : 0);
38
+ if (this.keep(issues, got)) { this.truncated ||= !!next; break; }
39
+ page = got.length ? next : 0;
40
+ }
41
+ return issues;
42
+ }
43
+ }
@@ -0,0 +1,69 @@
1
+ import { TrackerApi } from './TrackerApi.js';
2
+
3
+ /**
4
+ * Jira through its REST API: the issues a JQL query finds (a project's, by
5
+ * default), every field (custom ones too: story points, acceptance criteria).
6
+ * Jira Cloud signs in with an email and an API token and pages by token
7
+ * (`/rest/api/3/search/jql`); Jira Server and Data Center with a personal
8
+ * access token (no email), paging by offset (`/rest/api/2/search`).
9
+ */
10
+ export class JiraApi extends TrackerApi {
11
+ static tracker = 'jira';
12
+
13
+ static PAGE = 100;
14
+
15
+ static baseUrlPlaceholder = 'https://your-site.atlassian.net';
16
+
17
+ static tokenHint = 'Jira Cloud: an API token (with the email above). Server / Data Center: a personal access token (no email).';
18
+
19
+ static get ownFields() {
20
+ return [
21
+ { key: 'edition', label: 'Jira', type: 'select', choices: [['cloud', 'Cloud'], ['server', 'Server / Data Center']], value: 'cloud' },
22
+ { key: 'user', label: 'Email', placeholder: 'The account the API token is for (Cloud)' },
23
+ { key: 'project', label: 'Project key', placeholder: 'SHOP — or leave it and give a JQL query' },
24
+ { key: 'query', label: 'JQL', placeholder: 'project = SHOP AND issuetype != Sub-task (instead of the project)' },
25
+ ];
26
+ }
27
+
28
+ problems() {
29
+ const out = super.problems();
30
+ if (!String(this.connection.project ?? '').trim() && !String(this.connection.query ?? '').trim()) out.push('A project key or a JQL query is needed.');
31
+ return out;
32
+ }
33
+
34
+ /** The JQL: the one given, else the project's issues in key order. */
35
+ get jql() {
36
+ const { query, project } = this.connection;
37
+ if (String(query ?? '').trim()) return String(query).trim();
38
+ return `project = "${String(project).trim().replace(/"/g, '\\"')}" ORDER BY key`;
39
+ }
40
+
41
+ headers() {
42
+ const { user, token } = this.connection;
43
+ return { Authorization: String(user ?? '').trim() ? `Basic ${TrackerApi.base64(`${String(user).trim()}:${token}`)}` : `Bearer ${token}` };
44
+ }
45
+
46
+ async _read() {
47
+ const issues = [];
48
+ const page = this.constructor.PAGE;
49
+ const query = (extra) => new URLSearchParams({ jql: this.jql, fields: '*all', maxResults: String(page), ...extra }).toString();
50
+ if (this.connection.edition === 'server') {
51
+ for (let startAt = 0; ; startAt += page) {
52
+ const { json } = await this.request(this.url(`/rest/api/2/search?${query({ startAt: String(startAt) })}`));
53
+ const got = json.issues ?? [];
54
+ const more = got.length > 0 && startAt + got.length < (json.total ?? 0);
55
+ if (this.keep(issues, got)) { this.truncated ||= more; break; }
56
+ if (!more) break;
57
+ }
58
+ } else {
59
+ for (let token = null; ;) {
60
+ const { json } = await this.request(this.url(`/rest/api/3/search/jql?${query(token ? { nextPageToken: token } : {})}`));
61
+ const got = json.issues ?? [];
62
+ token = json.isLast ? null : json.nextPageToken ?? null;
63
+ if (this.keep(issues, got)) { this.truncated ||= !!token; break; }
64
+ if (!token || !got.length) break;
65
+ }
66
+ }
67
+ return { issues };
68
+ }
69
+ }
@@ -0,0 +1,142 @@
1
+ import { TrackerExport, TRACKERS } from './TrackerExport.js';
2
+
3
+ /** Text as base64, whatever its characters (btoa takes only Latin-1). */
4
+ function base64(text) {
5
+ const bytes = new TextEncoder().encode(String(text));
6
+ let binary = '';
7
+ for (const b of bytes) binary += String.fromCharCode(b);
8
+ return btoa(binary);
9
+ }
10
+
11
+ /**
12
+ * TrackerApi — a tracker read through its API rather than from an export: the
13
+ * issues a connection names, page by page, as the JSON `TrackerExport` reads
14
+ * (so an API's answer and a file saved from it make the same model). A
15
+ * subclass per tracker (`JiraApi`, `AzureDevOpsApi`, `GitLabApi`) says where
16
+ * its issues are, how it pages and how it is signed in to; what a connection
17
+ * asks for is `fields`, which a host builds its form from.
18
+ *
19
+ * `fetch` is given (a server's, or a test's): a tracker's token belongs where
20
+ * the request is made, which is seldom a browser. Nothing here keeps or logs
21
+ * the token; an error says what the tracker answered, not what was sent.
22
+ */
23
+ export class TrackerApi {
24
+ /** The tracker's key in TRACKERS ('jira'). */
25
+ static tracker = null;
26
+
27
+ /** Most issues read from one connection: a connection naming a whole site is stopped there (`truncated`). */
28
+ static MAX_ISSUES = 5000;
29
+
30
+ /**
31
+ * What a connection asks for, in order: `{key, label, type?, choices?,
32
+ * required?, secret?, placeholder?, hint?}` — `secret` written, never
33
+ * shown back. The tracker's own between its address and its token.
34
+ */
35
+ static get fields() {
36
+ return [
37
+ { key: 'name', label: 'Name', required: true, placeholder: 'What people pick it by, e.g. "Shop backlog"' },
38
+ { key: 'baseUrl', label: 'Address', required: true, placeholder: this.baseUrlPlaceholder },
39
+ ...this.ownFields,
40
+ { key: 'token', label: 'Token', type: 'password', required: true, secret: true, hint: this.tokenHint },
41
+ ];
42
+ }
43
+
44
+ /** @protected */
45
+ static get ownFields() { return []; }
46
+
47
+ /** @protected */
48
+ static baseUrlPlaceholder = 'https://…';
49
+
50
+ /** @protected */
51
+ static tokenHint = 'An access token that may read the issues';
52
+
53
+ /**
54
+ * @param {Object} connection - Its `fields`' values (and `columns`, a TrackerExport's own columns)
55
+ * @param {Object} [options]
56
+ * @param {Function} [options.fetch] - The fetch to read with (default: the global one)
57
+ * @param {number} [options.maxIssues] - Most issues read (default MAX_ISSUES)
58
+ */
59
+ constructor(connection, { fetch = globalThis.fetch, maxIssues = new.target.MAX_ISSUES } = {}) {
60
+ this.connection = connection ?? {};
61
+ this.fetch = fetch;
62
+ this.maxIssues = maxIssues;
63
+ /** Whether the last read stopped at maxIssues. */
64
+ this.truncated = false;
65
+ }
66
+
67
+ get tracker() { return this.constructor.tracker; }
68
+
69
+ /** What the tracker is called ('Jira'). */
70
+ get label() { return TrackerExport.label(this.tracker); }
71
+
72
+ /** What is wrong with the connection, before anything is asked of the tracker: messages, none when it will do. */
73
+ problems() {
74
+ const c = this.connection;
75
+ const out = this.constructor.fields.filter(f => f.required && !String(c[f.key] ?? '').trim()).map(f => `${f.label} is needed.`);
76
+ if (c.baseUrl && !/^https?:\/\/[^\s/]+/i.test(String(c.baseUrl).trim())) out.push(`Address must start http:// or https:// ("${c.baseUrl}").`);
77
+ return out;
78
+ }
79
+
80
+ /** The issues, as the tracker's API gives them (the JSON TrackerExport reads). */
81
+ async read() {
82
+ const problems = this.problems();
83
+ if (problems.length) throw new Error(`${this.label} connection "${this.connection.name ?? ''}": ${problems.join(' ')}`);
84
+ this.truncated = false;
85
+ return this._read();
86
+ }
87
+
88
+ /** The issues as a requirements model's spec, named as the connection is. */
89
+ async model() {
90
+ const json = await this.read();
91
+ const { tracker, ...spec } = new TrackerExport({ tracker: this.tracker, columns: this.connection.columns ?? {} }).read(json);
92
+ return { name: this.connection.name || `${this.label} issues`, ...spec };
93
+ }
94
+
95
+ /** @protected @abstract */
96
+ async _read() {
97
+ throw new Error(`${this.constructor.name} does not say how to read ${this.label}`);
98
+ }
99
+
100
+ /** The headers that sign in. @protected @abstract */
101
+ headers() {
102
+ return {};
103
+ }
104
+
105
+ /** A path on the tracker's address. @protected */
106
+ url(path) {
107
+ return `${String(this.connection.baseUrl).trim().replace(/\/+$/, '')}${path}`;
108
+ }
109
+
110
+ /**
111
+ * A request to the tracker, its answer's JSON and headers; an answer
112
+ * other than OK an error saying what the tracker said. @protected
113
+ */
114
+ async request(url, { method = 'GET', body } = {}) {
115
+ if (typeof this.fetch !== 'function') throw new Error('No fetch to read the tracker with');
116
+ const res = await this.fetch(url, {
117
+ method,
118
+ headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), ...this.headers() },
119
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
120
+ });
121
+ if (!res.ok) {
122
+ const text = await res.text().catch(() => '');
123
+ throw new Error(`${this.label} answered ${res.status}${res.statusText ? ` ${res.statusText}` : ''}${text ? `: ${text.slice(0, 300)}` : ''}`);
124
+ }
125
+ return { json: await res.json(), headers: res.headers };
126
+ }
127
+
128
+ /** Issues kept up to maxIssues; true once it is reached. @protected */
129
+ keep(into, more) {
130
+ for (const issue of more) {
131
+ if (into.length >= this.maxIssues) { this.truncated = true; return true; }
132
+ into.push(issue);
133
+ }
134
+ return into.length >= this.maxIssues;
135
+ }
136
+
137
+ /** @protected */
138
+ static base64 = base64;
139
+
140
+ /** @protected — the tracker's issue types not read (TRACKERS: its sub-tasks). */
141
+ get skipTypes() { return TRACKERS[this.tracker]?.skipTypes ?? []; }
142
+ }
@@ -167,15 +167,16 @@ export class TrackerExport {
167
167
  return found?.[0] ?? null;
168
168
  }
169
169
 
170
- /** @private */
170
+ /** @private — an API's JSON, given as text or already parsed. */
171
171
  static _json(text) {
172
+ if (text && typeof text === 'object') return text;
172
173
  const s = String(text ?? '').trim();
173
174
  if (!s.startsWith('{') && !s.startsWith('[')) return null;
174
175
  try { return JSON.parse(s); } catch { return null; }
175
176
  }
176
177
 
177
178
  /**
178
- * The export as a requirements model's spec.
179
+ * The export (its text; an API's JSON as text or parsed — TrackerApi) as a requirements model's spec.
179
180
  * @returns {{epics: Array, releases: Array, stories: Array, tracker: string|null}}
180
181
  */
181
182
  read(text) {
@@ -5,3 +5,8 @@ export { RequirementsFileFormat } from './RequirementsFileFormat.js';
5
5
  export { TrackerExportFileFormat, StoryboardFileFormat } from './RequirementsImportFormats.js';
6
6
  export { TrackerExport, TRACKERS, criteriaFrom, narrativeFrom, withoutNarrative } from './TrackerExport.js';
7
7
  export { Storyboard } from './Storyboard.js';
8
+ export { TrackerApi } from './TrackerApi.js';
9
+ export { JiraApi } from './JiraApi.js';
10
+ export { AzureDevOpsApi } from './AzureDevOpsApi.js';
11
+ export { GitLabApi } from './GitLabApi.js';
12
+ export { TRACKER_APIS, trackerApi, trackerConnectionFields } from './trackerApis.js';
@@ -0,0 +1,21 @@
1
+ import { JiraApi } from './JiraApi.js';
2
+ import { AzureDevOpsApi } from './AzureDevOpsApi.js';
3
+ import { GitLabApi } from './GitLabApi.js';
4
+
5
+ /** Each tracker's API, by its key in TRACKERS. */
6
+ export const TRACKER_APIS = Object.freeze({ jira: JiraApi, azure: AzureDevOpsApi, gitlab: GitLabApi });
7
+
8
+ /**
9
+ * The API reader for a connection (`connection.tracker`: 'jira', 'azure',
10
+ * 'gitlab'); `options` as TrackerApi takes them.
11
+ */
12
+ export function trackerApi(connection, options) {
13
+ const Api = TRACKER_APIS[connection?.tracker];
14
+ if (!Api) throw new Error(`No API reader for tracker "${connection?.tracker ?? ''}" (${Object.keys(TRACKER_APIS).join(', ')})`);
15
+ return new Api(connection, options);
16
+ }
17
+
18
+ /** What each tracker's connection asks for: `[{tracker, label, fields}]`, for a host's form. */
19
+ export function trackerConnectionFields() {
20
+ return Object.entries(TRACKER_APIS).map(([tracker, Api]) => ({ tracker, label: new Api({}).label, fields: Api.fields }));
21
+ }