@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,622 +1,765 @@
1
- import { Part } from '../Part.js';
2
- import { WorkArea } from '../workArea.js';
3
- import { el } from '../dom.js';
4
- import { ModelCompareDialog, ModelMergeDialog } from '../../charting/ModelComparisonDialog.js';
5
- import { FormDialog } from '../../charting/FormDialog.js';
6
- import { PopupMenu } from '../../charting/PopupMenu.js';
7
- import { downloadBlob } from '../../exportImage.js';
8
- import { IndexedDbStore } from '../storage/KeyValueStore.js';
9
- import { ModelLibrary } from '../storage/ModelLibrary.js';
10
- import { BackupFolder } from '../storage/BackupFolder.js';
11
- import { Benchmark } from '../../charting/model/Benchmark.js';
12
-
13
- /**
14
- * ModelsPart — several models open at once (as Archi's model tree holds
15
- * several): one editor part per model (any EditorPart type, `editorType`),
16
- * each a tab in a work area, so each keeps its own file, unsaved state and
17
- * undo history, and two can sit side by side. Copy and paste go between
18
- * them as between any two editors.
19
- *
20
- * The editor parts it hosts find it as `ctx.models` and offer, in their File
21
- * menu, New model, Open… in a new tab and Close model; a model imported from
22
- * a file opens in a tab of its own. Their Tools menu works across the open
23
- * models (toolItems): compare one with another, and merge from another into
24
- * it (ModelComparison). A tab shows its file's
25
- * name and • while it has unsaved changes, and right-clicked, the menu of
26
- * what can be done with that model (tabMenuItems); closing one with unsaved changes
27
- * asks first; the page title follows the model in front. The last model
28
- * closed leaves a new, empty one.
29
- *
30
- * Spec: `{ id, type: 'models', editorType, models?: [editor part spec], newModel?: editor part spec, bases?: [editor part spec], keep? }`:
31
- * `models` are opened at the start (one empty one without), `newModel` is what New model makes.
32
- *
33
- * `bases` are reference models kept as given (never opened, edited or kept):
34
- * File ▸ New ▸ From base makes a model as a copy of one, and Tools ▸ Compare
35
- * with and Merge from offer them beside the other open models. File ▸
36
- * Duplicate model opens a copy of the model in front, read back through its
37
- * own file format.
38
- *
39
- * With `keep`, every model is kept in this browser (ModelLibrary over an
40
- * IndexedDB store) a moment after each edit, in its editor's file format, so
41
- * work outlives the page without saving a file; those open when the page
42
- * closed open again next time. File ▸ This browser opens a kept model, stops
43
- * keeping one, backs them all up (BackupFolder: a folder chosen once, such as
44
- * one OneDrive or Google Drive syncs, else downloads) and chooses that folder.
45
- *
46
- * Where models are kept is the host's to say: a `modelLibrary` among the
47
- * workspace's services (`workspace.services`, reaching this part as
48
- * `ctx.modelLibrary`) is used in place of the browser's — anything with
49
- * ModelLibrary's methods (keep, load, list, openOnes, setOpen, remove) and a
50
- * `label` for the menu ('The engine'). An entry is the model in its editor's
51
- * file format, so a host stores it as it likes: a database row, a file, a
52
- * graph built from it. The backup folder stays in the browser.
53
- *
54
- * Proposals: a model offered as a change to the one in front — by an agent,
55
- * a colleague, an import — with why (`propose`). Tools ▸ Proposals lists them;
56
- * each is reviewed as a merge (ModelMergeDialog, its rationale shown), so what
57
- * is taken is chosen and is one undo step. Taking or dismissing one removes it.
58
- *
59
- * A host adds tools of its own for the model in front — an agent to run on
60
- * it, say — as `modelTools(part, models)` among the workspace's services: its
61
- * items end the Tools menu.
62
- */
63
- export class ModelsPart extends Part {
64
- static type = 'models';
65
-
66
- constructor(spec = {}) {
67
- super(spec);
68
- if (!spec.editorType) throw new TypeError('ModelsPart needs an editorType (the editor part type, e.g. "archimate-canvas")');
69
- this.editorType = spec.editorType;
70
- /** The ids of the editor parts open here, in the order opened. */
71
- this.modelIds = [];
72
- this.work = null;
73
- this._next = 1;
74
- /** With `keep`: the kept models and where backups go (set once the store is open). */
75
- this.library = null;
76
- this.backups = null;
77
- /** Edits waiting to be kept, by part id. @private */
78
- this._pending = new Map();
79
- /** Changes offered, not yet taken or dismissed (propose). */
80
- this.proposals = [];
81
- this._proposalSeq = 0;
82
- }
83
-
84
- /** What a copy's name ends with (copyName). */
85
- static COPY_SUFFIX = ' (copy)';
86
-
87
- /** How long after an edit a model is kept (an edit's bursts make one write). */
88
- static KEEP_DELAY = 800;
89
-
90
- get registry() {
91
- return this.ctx?.registry ?? null;
92
- }
93
-
94
- render() {
95
- Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
96
- const host = this.container.appendChild(el({ style: { flex: '1 1 auto', minHeight: '0' } }));
97
- this.work = new WorkArea({
98
- placeholder: { cls: 'ws-workarea-empty', text: 'No model open' },
99
- // A tab's × asks first when its model has unsaved changes (closeModel closes it).
100
- beforeClose: (id) => { this.closeModel(id); return false; },
101
- // A tab right-clicked: what can be done with its model.
102
- tabMenu: (id, event) => this.showTabMenu(id, event),
103
- onChange: () => this.activeModel()?.file?.showState?.(),
104
- });
105
- this.work.render(host, { ...this.ctx, models: this });
106
- this.bases(); // copied now, before an open model (which may share the spec's data) is edited
107
- const specs = this.spec.models?.length ? this.spec.models : [this.spec.newModel ?? {}];
108
- const parts = specs.map(spec => this.newModel(spec));
109
- if (parts[0]) this.work.focus(parts[0].id);
110
- /** Resolves once kept models are reopened (with `keep`). */
111
- this.ready = this.spec.keep ? this.startKeeping(parts) : Promise.resolve();
112
- }
113
-
114
- // ===== Kept in this browser =====
115
-
116
- /** The store models and the backup folder are kept in: the browser's IndexedDB. A test gives its own. */
117
- openStore() {
118
- return IndexedDbStore.open('renderengine-models');
119
- }
120
-
121
- /**
122
- * Open the store, reopen the models open when the page last closed (in
123
- * place of the starting ones, unless those were changed meanwhile), and keep
124
- * what is open now.
125
- * @param {Array<EditorPart>} starting - The models the spec opened
126
- */
127
- async startKeeping(starting = []) {
128
- const store = await this.openStore(); // the backup folder's, whoever keeps the models
129
- this.library = this.ctx?.modelLibrary ?? new ModelLibrary(store);
130
- const win = this.container?.ownerDocument?.defaultView ?? globalThis;
131
- this.backups = new BackupFolder({
132
- store, window: win,
133
- download: (name, data, mimeType) => downloadBlob(new (win.Blob ?? Blob)([data], { type: mimeType }), name),
134
- });
135
- await this.backups.restore();
136
- const kept = this.ownKept(await this.library.openOnes());
137
- for (const { id } of kept) {
138
- const entry = await this.library.load(id); // the list has no data; the entry has
139
- if (entry) this.openKept(entry);
140
- }
141
- if (kept.length) for (const part of starting) if (!part.model?.isDirty?.()) this.closeModel(part.id);
142
- for (const part of this.models()) if (part.keepId) await this.keep(part);
143
- }
144
-
145
- /** Keep a model in this browser now (its editor's keepEntry), as open. */
146
- async keep(part) {
147
- if (!this.library || part.keepOff) return null;
148
- const entry = part.keepEntry?.();
149
- if (!entry) return null;
150
- part.keepId ??= ModelLibrary.newId();
151
- return this.library.keep({ ...entry, id: part.keepId, editorType: this.editorType, open: this.modelIds.includes(part.id) });
152
- }
153
-
154
- /** A model was edited: keep it a moment later (a burst of edits, one write). */
155
- modelEdited(part) {
156
- if (!this.library) return;
157
- clearTimeout(this._pending.get(part.id));
158
- this._pending.set(part.id, setTimeout(() => { this._pending.delete(part.id); this.keep(part); }, ModelsPart.KEEP_DELAY));
159
- }
160
-
161
- /**
162
- * The kept models that are this workbench's: kept by its kind of editor
163
- * (`editorType`), or (kept before that was recorded) in one of its formats.
164
- * Pages of other editors keep theirs in the same browser; those are not read here.
165
- */
166
- ownKept(entries) {
167
- const labels = new Set(this.formatLabels());
168
- return entries.filter(e => (e.editorType ? e.editorType === this.editorType : labels.has(e.format)));
169
- }
170
-
171
- /** The labels of this workbench's editors' file formats. */
172
- formatLabels() {
173
- const sample = this.models()[0] ?? this.bases()[0];
174
- return (sample?.fileFormats?.() ?? []).map(f => f.label);
175
- }
176
-
177
- /** Open a kept model in a new tab, read in its format. Returns the part, or null when it is not one this editor reads. */
178
- openKept(entry) {
179
- if (!this.ownKept([entry]).length) {
180
- this.tell(`"${entry.name}" was kept by another editor (${entry.editorType ?? entry.format}); open it there.`);
181
- return null;
182
- }
183
- const part = this.newModel();
184
- const format = part.fileFormats().find(f => f.label === entry.format);
185
- try {
186
- part.replaceModel(format.import(entry.data));
187
- } catch (err) {
188
- this.closeModel(part.id);
189
- this.tell(`"${entry.name}" could not be read back: ${err.message}`);
190
- return null;
191
- }
192
- part.keepId = entry.id;
193
- part.file.name = entry.fileName ?? null;
194
- part.file.showState();
195
- this.work.focus(part.id);
196
- return part;
197
- }
198
-
199
- /**
200
- * File ▸ This browser, with `keep`: open a kept model, stop keeping this
201
- * one, back up every kept model, choose where backups go.
202
- */
203
- keepItems(part) {
204
- if (!this.library) return [];
205
- return [
206
- { label: 'Open a kept model…', action: () => this.askOpenKept() },
207
- { label: 'Stop keeping this model', disabled: !part.keepId || undefined, action: () => this.stopKeeping(part) },
208
- { separator: true },
209
- { label: 'Back up kept models now', action: () => this.backUpAndTell() },
210
- { label: `Backup folder: ${this.backups.location}…`, disabled: !this.backups.supported || undefined, action: () => this.chooseBackupFolder() },
211
- ...(this.backups.dir ? [{ label: 'Back up to downloads instead', action: () => this.backups.forget() }] : []),
212
- ];
213
- }
214
-
215
- /** Choose a kept model not open now, and open it. */
216
- async askOpenKept() {
217
- const open = new Set(this.models().map(p => p.keepId));
218
- const entries = this.ownKept(await this.library.list()).filter(e => !open.has(e.id));
219
- if (!entries.length) { this.tell(`Every model kept (${this.keptWhere}) is open.`); return null; }
220
- return new FormDialog({
221
- title: 'Open a kept model',
222
- fields: [{
223
- key: 'id', label: 'Model', type: 'select', value: entries[0].id,
224
- choices: entries.map(e => [e.id, `${e.name} (kept ${new Date(e.keptAt).toLocaleString()})`]),
225
- }],
226
- submitLabel: 'Open', idPrefix: 'kept-',
227
- document: this.container?.ownerDocument ?? globalThis.document,
228
- onApply: async ({ id }) => { const entry = await this.library.load(id); if (entry) this.openKept(entry); },
229
- }).open();
230
- }
231
-
232
- /** Forget a model in this browser, and keep it no more. */
233
- async stopKeeping(part) {
234
- if (part.keepId) await this.library.remove(part.keepId);
235
- part.keepId = null;
236
- part.keepOff = true;
237
- }
238
-
239
- /** Every kept model as a backup file: its name, in its format's extension. */
240
- async backupFiles() {
241
- const sample = this.models()[0];
242
- const formats = sample?.fileFormats?.() ?? [];
243
- const files = [];
244
- for (const meta of this.ownKept(await this.library.list())) {
245
- const entry = await this.library.load(meta.id);
246
- const format = formats.find(f => f.label === entry.format);
247
- const ext = (format?.accept ?? '').split(',')[0].trim() || '';
248
- const base = String(entry.fileName ?? entry.name ?? 'model').replace(/\.[^.]+$/, '').replace(/[^A-Za-z0-9._-]+/g, '-') || 'model';
249
- files.push({ name: `${base}${ext}`, data: entry.data, mimeType: 'application/octet-stream' });
250
- }
251
- return files;
252
- }
253
-
254
- /** Back up every kept model (what is open is kept first), and say where. */
255
- async backUpAndTell() {
256
- for (const part of this.models()) await this.keep(part);
257
- const files = await this.backupFiles();
258
- if (!files.length) { this.tell(`Nothing is kept (${this.keptWhere}) yet.`); return null; }
259
- const { location, names } = await this.backups.backUp(files);
260
- this.tell(`Backed up ${names.length} model${names.length === 1 ? '' : 's'} to the ${location}:\n\n${names.join('\n')}`);
261
- return { location, names };
262
- }
263
-
264
- /** Choose the backup folder (a click); one OneDrive, Google Drive or Dropbox syncs takes the backups to the cloud. */
265
- async chooseBackupFolder() {
266
- const dir = await this.backups.choose();
267
- if (dir) this.tell(`Backups go into "${dir.name}" from now on, each named with the date and time.\n\nA folder that OneDrive, Google Drive or Dropbox syncs takes them to the cloud.`);
268
- return dir;
269
- }
270
-
271
- /** Where models are kept, as the File menu names it: the library's label. */
272
- get keptWhere() {
273
- return this.library?.label ?? 'This browser';
274
- }
275
-
276
- // ===== Proposals =====
277
-
278
- /**
279
- * Offer a change: the model proposed — `spec`, a model as this workbench's
280
- * editor takes it (as a base is given), or `entry`, a model in one of the
281
- * editor's file formats (`{format, data}`, as a kept one is) — with a
282
- * `title` and why (`rationale`), from `source` (who or what proposed it).
283
- * `onSettle(outcome, {bench})` hears whether it was 'taken' or
284
- * 'dismissed' and, taken, how what was taken moved the bench. Returns the proposal.
285
- * @param {{title: string, rationale?: string, source?: string, spec?: Object, entry?: {format: string, data: *}, onSettle?: Function}} proposal
286
- */
287
- propose({ title, rationale = '', source = '', spec = null, entry = null, onSettle = null }) {
288
- if (!spec && !entry) throw new TypeError('A proposal needs a model spec');
289
- const id = `${this.id}-proposal-${++this._proposalSeq}`;
290
- const part = this.registry.create({ ...ModelsPart.copySpec(spec ?? {}), id, type: this.editorType });
291
- if (entry) {
292
- const format = part.fileFormats().find(f => f.label === entry.format);
293
- if (!format) { part.dispose?.(); throw new Error(`No ${entry.format} format here to read the proposal in`); }
294
- part.replaceModel(format.import(entry.data));
295
- }
296
- const proposal = { id, title: title || part.model?.name || 'Proposal', rationale, source, part, onSettle };
297
- this.proposals.push(proposal);
298
- return proposal;
299
- }
300
-
301
- /**
302
- * How the proposal would move the model's measures (Benchmark), were it
303
- * taken whole: `[{label, before, after, change, …}]`.
304
- */
305
- proposalBench(part, proposal) {
306
- return Benchmark.compare(Benchmark.of(part.model), Benchmark.of(proposal.part.model));
307
- }
308
-
309
- /** Forget a proposal: not wanted (`outcome` 'dismissed'), or taken. Its proposer hears which. */
310
- dismissProposal(proposal, outcome = 'dismissed', note = {}) {
311
- if (!this.proposals.includes(proposal)) return;
312
- this.proposals = this.proposals.filter(p => p !== proposal);
313
- proposal.onSettle?.(outcome, note);
314
- proposal.part?.dispose?.();
315
- }
316
-
317
- /**
318
- * Review a proposal against `part`'s model: its differences, chosen as a
319
- * merge (ModelMergeDialog), its rationale above them. Merging takes it.
320
- */
321
- reviewProposal(part, proposal) {
322
- const bench = this.proposalBench(part, proposal);
323
- return new ModelMergeDialog({
324
- ...this._comparing(part, proposal.part),
325
- theirsName: proposal.title,
326
- note: [proposal.source && `Proposed by ${proposal.source}.`, proposal.rationale, bench.length && `Taken whole: ${Benchmark.text(bench)}.`].filter(Boolean).join(' '),
327
- onMerge: (command) => {
328
- // What was taken (the parts chosen), measured: the model before the merge and after.
329
- const before = Benchmark.of(part.model);
330
- part.runModelEdit(command);
331
- this.dismissProposal(proposal, 'taken', { bench: Benchmark.text(Benchmark.compare(before, Benchmark.of(part.model))) });
332
- },
333
- }).open();
334
- }
335
-
336
- /** Tools ▸ Proposals: each, to review or dismiss. */
337
- proposalItems(part) {
338
- if (!this.proposals.length) return [{ label: 'None waiting', disabled: true }];
339
- return this.proposals.map(p => ({
340
- label: p.source ? `${p.title} — ${p.source}` : p.title,
341
- items: [
342
- { label: 'Review and merge…', action: () => this.reviewProposal(part, p) },
343
- { label: 'Dismiss', action: () => this.dismissProposal(p) },
344
- ],
345
- }));
346
- }
347
-
348
- /** Tell the user (the page's alert). @protected */
349
- tell(message) {
350
- const win = this.container?.ownerDocument?.defaultView ?? globalThis;
351
- win.alert?.(message);
352
- }
353
-
354
- /** The editor parts open here, in order. */
355
- models() {
356
- return this.modelIds.map(id => this.registry?.get(id)).filter(Boolean);
357
- }
358
-
359
- /** The editor part of the model in front (the selected tab of the active group). */
360
- activeModel() {
361
- const id = this.work?.activePart();
362
- return id ? this.registry?.get(id) ?? null : null;
363
- }
364
-
365
- /**
366
- * Open a new model in a tab: an editor part of `editorType` made from `spec`
367
- * (default the spec's `newModel`, an empty model). Returns the part.
368
- */
369
- newModel(spec = this.spec.newModel ?? {}) {
370
- const id = spec.id && !this.registry.has(spec.id) ? spec.id : this._freshId();
371
- const part = this.registry.add({ ...spec, id, type: this.editorType });
372
- this.modelIds.push(id);
373
- this.work.open(id);
374
- this.fileStateChanged(part);
375
- return part;
376
- }
377
-
378
- /**
379
- * Open a model made elsewhere (one imported from a file) in a new tab,
380
- * as it is: nothing to undo, not yet saved to a file. Returns the part.
381
- */
382
- openModel(model) {
383
- const part = this.newModel();
384
- part.replaceModel(model);
385
- this.work.focus(part.id);
386
- this.fileStateChanged(part);
387
- this.keep(part); // an import is kept straight away
388
- return part;
389
- }
390
-
391
- // ===== Bases, and copies =====
392
-
393
- /**
394
- * The base models (spec `bases`): a part each, made from a copy of its spec
395
- * and never added to the workbench, so nothing done to an open model reaches it.
396
- */
397
- bases() {
398
- return (this._bases ??= (this.spec.bases ?? []).map((spec, i) =>
399
- this.registry.create({ ...ModelsPart.copySpec(spec), id: `${this.id}-base-${i + 1}`, type: this.editorType })));
400
- }
401
-
402
- /**
403
- * Replace the bases: a host whose reference models change (a live
404
- * database after its schema changed) gives them afresh. The parts made
405
- * for the old ones are disposed; nothing open is touched.
406
- * @param {Array<Object>} specs - Editor part specs, as `bases` in the spec
407
- */
408
- setBases(specs = []) {
409
- for (const base of this._bases ?? []) base.dispose?.();
410
- this._bases = null;
411
- this.spec.bases = specs;
412
- return this.bases();
413
- }
414
-
415
- /** What a copy of a model is called: " (copy)" after its name, once (a copy of a copy is not "… (copy) (copy)"). */
416
- static copyName(name) {
417
- const plain = String(name || 'Untitled');
418
- return plain.endsWith(ModelsPart.COPY_SUFFIX) ? plain : `${plain}${ModelsPart.COPY_SUFFIX}`;
419
- }
420
-
421
- /** A spec's own copy: its data apart from the spec's (a spec is JSON). */
422
- static copySpec(spec) {
423
- return JSON.parse(JSON.stringify(spec ?? {}));
424
- }
425
-
426
- /** What a base is called in menus. */
427
- baseLabel(base, i) {
428
- return base.model?.name || base.spec.name || `Base ${i + 1}`;
429
- }
430
-
431
- /** Open a new model as a copy of a base (by its place in `bases`), named as a copy (copyName). Returns the part. */
432
- newFromBase(index) {
433
- const spec = this.bases()[index]?.spec; // the base's own copy, as given
434
- if (!spec) return null;
435
- const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
436
- const part = this.newModel({ ...copy, name: ModelsPart.copyName(copy.name ?? this.baseLabel(this.bases()[index], index)) });
437
- this.work.focus(part.id);
438
- return part;
439
- }
440
-
441
- /**
442
- * Open a base to look at (by its place in `bases`): a tab of its own copy,
443
- * named as the base is with " (view)", that can be laid out and moved
444
- * about but is never kept — a live database seen as it is, without making a
445
- * design of it. Returns the part.
446
- */
447
- viewBase(index) {
448
- const spec = this.bases()[index]?.spec;
449
- if (!spec) return null;
450
- const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
451
- const part = this.newModel({ ...copy, name: `${copy.name ?? this.baseLabel(this.bases()[index], index)}${ModelsPart.VIEW_SUFFIX}` });
452
- part.keepOff = true;
453
- this.work.focus(part.id);
454
- return part;
455
- }
456
-
457
- /** What a base viewed is called: " (view)" after its name. */
458
- static VIEW_SUFFIX = ' (view)';
459
-
460
- /** File ▸ New ▸'s items from bases: From base: <name>, a design from one; View base: <name>, one to look at. */
461
- baseItems() {
462
- const bases = this.bases();
463
- return [
464
- ...bases.map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) })),
465
- ...(bases.length ? [{ separator: true }] : []),
466
- ...bases.map((base, i) => ({ label: `View base: ${this.baseLabel(base, i)}`, action: () => this.viewBase(i) })),
467
- ];
468
- }
469
-
470
- /**
471
- * Open a copy of a model in a new tab: written and read back in its own
472
- * file format (keepEntry), named as a copy (copyName). Null when it has no format.
473
- */
474
- duplicate(part) {
475
- const entry = part.keepEntry?.();
476
- if (!entry) return null;
477
- const format = part.fileFormats().find(f => f.label === entry.format);
478
- const model = format.import(entry.data);
479
- model.name = ModelsPart.copyName(model.name || entry.name);
480
- return this.openModel(model);
481
- }
482
-
483
- /**
484
- * The Tools menu's items for one open model (`part`): Compare with and
485
- * Merge from, each a submenu of the other open models, then the bases.
486
- */
487
- toolItems(part) {
488
- const others = this.models().filter(m => m !== part);
489
- const bases = this.bases().map((base, i) => ({ base, label: `${this.baseLabel(base, i)} (base)` }));
490
- const each = (act) => (others.length || bases.length
491
- ? [
492
- ...others.map(other => ({ label: this.labelOf(other), action: () => act(part, other) })),
493
- ...(others.length && bases.length ? [{ separator: true }] : []),
494
- ...bases.map(({ base, label }) => ({ label, action: () => act(part, base) })),
495
- ]
496
- : [{ label: 'Open another model first', disabled: true }]);
497
- return [
498
- { label: 'Compare with', items: each((a, b) => this.compare(a, b)) },
499
- { label: 'Merge from', items: each((a, b) => this.merge(a, b)) },
500
- { separator: true },
501
- { label: `Proposals (${this.proposals.length})`, items: this.proposalItems(part) },
502
- // The host's own tools for the model in front (`services.modelTools(part, models)`: agents, say).
503
- ...(typeof this.ctx?.modelTools === 'function' ? [{ separator: true }, ...this.ctx.modelTools(part, this)] : []),
504
- ];
505
- }
506
-
507
- /** How `part`'s model differs from `other`'s, shown (ModelCompareDialog); its Merge… goes on to merge. */
508
- compare(part, other) {
509
- return new ModelCompareDialog({
510
- ...this._comparing(part, other),
511
- onMerge: () => this.merge(part, other),
512
- }).open();
513
- }
514
-
515
- /** Choose what to bring from `other`'s model into `part`'s, then bring it: one undo step there. */
516
- merge(part, other) {
517
- return new ModelMergeDialog({
518
- ...this._comparing(part, other),
519
- onMerge: (command) => part.runModelEdit(command),
520
- }).open();
521
- }
522
-
523
- /** @private */
524
- _comparing(part, other) {
525
- return {
526
- comparison: part.model.compareWith(other.model),
527
- mineName: part.model.name || this.labelOf(part),
528
- theirsName: other.model.name || this.labelOf(other),
529
- document: this.container?.ownerDocument ?? globalThis.document,
530
- };
531
- }
532
-
533
- /**
534
- * Ask for a file (as `from`'s Open… does) and open it in a new tab,
535
- * leaving `from`'s model as it is. Returns the new part, or null.
536
- */
537
- async openInNewTab(from) {
538
- const chosen = await from.file.choose();
539
- if (!chosen) return null;
540
- const part = this.newModel();
541
- part.file.adopt(chosen);
542
- return part;
543
- }
544
-
545
- /**
546
- * Close a model's tab; with unsaved changes, only once confirmed. The last
547
- * one closed leaves a new, empty model. Returns whether it closed.
548
- */
549
- closeModel(id) {
550
- const part = this.registry?.get(id);
551
- if (!part || !this.modelIds.includes(id)) return false;
552
- if (part.model?.isDirty?.() && !this.confirm(`"${this.labelOf(part)}" has unsaved changes. Close it anyway?`)) return false;
553
- this.work.close(id);
554
- this.modelIds = this.modelIds.filter(m => m !== id);
555
- if (part.keepId) this.library?.setOpen(part.keepId, false); // kept, not reopened next time
556
- this.registry.remove(id);
557
- if (!this.modelIds.length) this.newModel();
558
- else this.activeModel()?.file?.showState?.();
559
- return true;
560
- }
561
-
562
- /** A model's tab label: what its toolbar and the page title call it (ModelFile#label). */
563
- labelOf(part) {
564
- return part.fileLabel;
565
- }
566
-
567
- /** An editor part's file or saved state changed: its tab says so. */
568
- fileStateChanged(part) {
569
- part.title = this.labelOf(part);
570
- this.work?.refreshLabels();
571
- }
572
-
573
- /**
574
- * What can be done with one open model, for its tab's right-click menu:
575
- * Save and Save As… (when it saves files), Duplicate model, Compare with ▸
576
- * and Merge from ▸ (the other open models, then the bases), This browser ▸
577
- * (with `keep`), Close model and Close other models.
578
- */
579
- tabMenuItems(part) {
580
- const saves = !!part.fileFormats?.().length;
581
- const [, save, saveAs] = saves ? part.file.menuItems() : [];
582
- const [compare, merge] = part.model?.compareWith ? this.toolItems(part) : [];
583
- const keeping = this.keepItems(part);
584
- const others = this.modelIds.filter(id => id !== part.id);
585
- return [
586
- ...(saves ? [save, saveAs, { separator: true }, { label: 'Duplicate model', action: () => this.duplicate(part) }] : []),
587
- ...(compare ? [compare, merge] : []),
588
- ...(keeping.length ? [{ label: this.keptWhere, items: keeping }] : []),
589
- { separator: true },
590
- { label: 'Close model', action: () => this.closeModel(part.id) },
591
- { label: 'Close other models', disabled: !others.length || undefined, action: () => { for (const id of others) this.closeModel(id); } },
592
- ];
593
- }
594
-
595
- /** Show a model's tab menu at a right-click; true when there was a model to show it for. */
596
- showTabMenu(id, event) {
597
- const part = this.modelIds.includes(id) ? this.registry?.get(id) : null;
598
- if (!part) return false;
599
- PopupMenu.show(event?.clientX ?? 0, event?.clientY ?? 0, this.tabMenuItems(part), this.container?.ownerDocument ?? globalThis.document);
600
- return true;
601
- }
602
-
603
- /** Ask the user (the page's confirm). @protected */
604
- confirm(message) {
605
- const win = this.container?.ownerDocument?.defaultView ?? globalThis;
606
- return typeof win.confirm === 'function' ? win.confirm(message) : true;
607
- }
608
-
609
- /** @private */
610
- _freshId() {
611
- let id;
612
- do { id = `${this.id}-model-${this._next++}`; } while (this.registry.has(id));
613
- return id;
614
- }
615
-
616
- dispose() {
617
- this.work?.dispose();
618
- for (const id of this.modelIds) this.registry?.remove(id);
619
- this.modelIds = [];
620
- super.dispose();
621
- }
622
- }
1
+ import { Part } from '../Part.js';
2
+ import { WorkArea } from '../workArea.js';
3
+ import { el } from '../dom.js';
4
+ import { ModelCompareDialog, ModelMergeDialog } from '../../charting/ModelComparisonDialog.js';
5
+ import { FormDialog } from '../../charting/FormDialog.js';
6
+ import { PopupMenu } from '../../charting/PopupMenu.js';
7
+ import { downloadBlob } from '../../exportImage.js';
8
+ import { IndexedDbStore } from '../storage/KeyValueStore.js';
9
+ import { ModelLibrary } from '../storage/ModelLibrary.js';
10
+ import { BackupFolder } from '../storage/BackupFolder.js';
11
+ import { Benchmark } from '../../charting/model/Benchmark.js';
12
+ import { ContentSource } from '../../charting/content/ContentSource.js';
13
+ import { ContentSources } from '../../charting/content/ContentSources.js';
14
+
15
+ /**
16
+ * ModelsPart — several models open at once (as Archi's model tree holds
17
+ * several): one editor part per model (any EditorPart type, `editorType`),
18
+ * each a tab in a work area, so each keeps its own file, unsaved state and
19
+ * undo history, and two can sit side by side. Copy and paste go between
20
+ * them as between any two editors.
21
+ *
22
+ * The editor parts it hosts find it as `ctx.models` and offer, in their File
23
+ * menu, New model, Open… in a new tab and Close model; a model imported from
24
+ * a file opens in a tab of its own. Their Tools menu works across the open
25
+ * models (toolItems): compare one with another, and merge from another into
26
+ * it (ModelComparison). A tab shows its file's
27
+ * name and • while it has unsaved changes, and right-clicked, the menu of
28
+ * what can be done with that model (tabMenuItems); closing one with unsaved changes
29
+ * asks first; the page title follows the model in front. The last model
30
+ * closed leaves a new, empty one.
31
+ *
32
+ * Spec: `{ id, type: 'models', editorType, models?: [editor part spec], newModel?: editor part spec, bases?: [editor part spec], keep?, content?: [{label?, url}], openContent? }`:
33
+ * `models` are opened at the start (one empty one without), `newModel` is what New model makes.
34
+ *
35
+ * `bases` are reference models kept as given (never opened, edited or kept):
36
+ * File ▸ New ▸ From base makes a model as a copy of one, and Tools ▸ Compare
37
+ * with and Merge from offer them beside the other open models. File ▸
38
+ * Duplicate model opens a copy of the model in front, read back through its
39
+ * own file format.
40
+ *
41
+ * With `keep`, every model is kept in this browser (ModelLibrary over an
42
+ * IndexedDB store) a moment after each edit, in its editor's file format, so
43
+ * work outlives the page without saving a file; those open when the page
44
+ * closed open again next time. File ▸ This browser opens a kept model, stops
45
+ * keeping one, backs them all up (BackupFolder: a folder chosen once, such as
46
+ * one OneDrive or Google Drive syncs, else downloads) and chooses that folder.
47
+ *
48
+ * Where models are kept is the host's to say: a `modelLibrary` among the
49
+ * workspace's services (`workspace.services`, reaching this part as
50
+ * `ctx.modelLibrary`) is used in place of the browser's — anything with
51
+ * ModelLibrary's methods (keep, load, list, openOnes, setOpen, remove) and a
52
+ * `label` for the menu ('The engine'). An entry is the model in its editor's
53
+ * file format, so a host stores it as it likes: a database row, a file, a
54
+ * graph built from it. The backup folder stays in the browser.
55
+ *
56
+ * Proposals: a model offered as a change to the one in front — by an agent,
57
+ * a colleague, an import — with why (`propose`). Tools ▸ Proposals lists them;
58
+ * each is reviewed as a merge (ModelMergeDialog, its rationale shown), so what
59
+ * is taken is chosen and is one undo step. Taking or dismissing one removes it.
60
+ *
61
+ * A host adds tools of its own for the model in front — an agent to run on
62
+ * it, say — as `modelTools(part, models)` among the workspace's services: its
63
+ * items end the Tools menu.
64
+ *
65
+ * Content: models kept apart from the tools, by domain (modelengine, or any
66
+ * collection with its manifest; ContentSource). The sources are the bundle
67
+ * the page loaded (dist/modelengine.js), the spec's `content` (`[{label?, url}]`),
68
+ * the host's `contentSources` service, and those added from the menu (kept
69
+ * in this browser). File ▸ Open from ▸ lists, by source then domain, the items
70
+ * this editor's file formats read, worked out from their files; one chosen
71
+ * opens in a tab of its own, remembering where it came from, so its tab menu
72
+ * and Tools offer **Update from …**: the item read again, its changes
73
+ * reviewed as a merge (one undo step), so what was changed here is kept.
74
+ * With `openContent` ('first' or an item's id) a first visit opens that item.
75
+ */
76
+ export class ModelsPart extends Part {
77
+ static type = 'models';
78
+
79
+ constructor(spec = {}) {
80
+ super(spec);
81
+ if (!spec.editorType) throw new TypeError('ModelsPart needs an editorType (the editor part type, e.g. "archimate-canvas")');
82
+ this.editorType = spec.editorType;
83
+ /** The ids of the editor parts open here, in the order opened. */
84
+ this.modelIds = [];
85
+ this.work = null;
86
+ this._next = 1;
87
+ /** With `keep`: the kept models and where backups go (set once the store is open). */
88
+ this.library = null;
89
+ this.backups = null;
90
+ /** Edits waiting to be kept, by part id. @private */
91
+ this._pending = new Map();
92
+ /** Changes offered, not yet taken or dismissed (propose). */
93
+ this.proposals = [];
94
+ this._proposalSeq = 0;
95
+ /** Where content comes from (made on render, when the host's services are known). */
96
+ this.content = null;
97
+ }
98
+
99
+ /** What a copy's name ends with (copyName). */
100
+ static COPY_SUFFIX = ' (copy)';
101
+
102
+ /** How long after an edit a model is kept (an edit's bursts make one write). */
103
+ static KEEP_DELAY = 800;
104
+
105
+ get registry() {
106
+ return this.ctx?.registry ?? null;
107
+ }
108
+
109
+ render() {
110
+ Object.assign(this.container.style, { display: 'flex', flexDirection: 'column', height: '100%' });
111
+ const host = this.container.appendChild(el({ style: { flex: '1 1 auto', minHeight: '0' } }));
112
+ this.work = new WorkArea({
113
+ placeholder: { cls: 'ws-workarea-empty', text: 'No model open' },
114
+ // A tab's × asks first when its model has unsaved changes (closeModel closes it).
115
+ beforeClose: (id) => { this.closeModel(id); return false; },
116
+ // A tab right-clicked: what can be done with its model.
117
+ tabMenu: (id, event) => this.showTabMenu(id, event),
118
+ onChange: () => this.activeModel()?.file?.showState?.(),
119
+ });
120
+ this.work.render(host, { ...this.ctx, models: this });
121
+ this.bases(); // copied now, before an open model (which may share the spec's data) is edited
122
+ const specs = this.spec.models?.length ? this.spec.models : [this.spec.newModel ?? {}];
123
+ const parts = specs.map(spec => this.newModel(spec));
124
+ if (parts[0]) this.work.focus(parts[0].id);
125
+ /** Resolves once kept models are reopened (with `keep`). */
126
+ this.ready = this.spec.keep ? this.startKeeping(parts) : Promise.resolve();
127
+ this.content ??= new ContentSources({ sources: [...(this.spec.content ?? []), ...(this.ctx?.contentSources ?? [])] });
128
+ /** Resolves once the content sources' manifests are read. */
129
+ this.contentReady = this.content.load();
130
+ /** Resolves once the spec's `openContent` item is open (or there was none to open). */
131
+ this.started = this.openStartContent();
132
+ }
133
+
134
+ /**
135
+ * The spec's `openContent` ('first', or an item's id): on a first visit
136
+ * (only the starting, untouched model open, nothing kept reopened) that
137
+ * item, from the first source with one this editor reads, opens in the
138
+ * empty model's place. Resolves to its part, or null.
139
+ */
140
+ async openStartContent() {
141
+ const wanted = this.spec.openContent;
142
+ if (!wanted) return null;
143
+ await this.ready;
144
+ await this.contentReady;
145
+ const [only, ...more] = this.models();
146
+ if (!only || more.length || only.keepId || only.contentOrigin || only.model?.isDirty?.()) return null;
147
+ const formats = this.readers();
148
+ for (const { source, items } of this.content.catalog) {
149
+ const item = items.find(i => (wanted === 'first' || i.id === wanted) && ContentSource.formatFor(i, formats));
150
+ if (!item) continue;
151
+ const part = await this.openContent(source, item);
152
+ if (part) this.closeModel(only.id);
153
+ return part;
154
+ }
155
+ return null;
156
+ }
157
+
158
+ // ===== Content: models kept apart from the tools =====
159
+
160
+ /** The file formats this workbench's editors read (a model's, else a base's). */
161
+ readers() {
162
+ const sample = this.models()[0] ?? this.bases()[0];
163
+ return sample?.fileFormats?.() ?? [];
164
+ }
165
+
166
+ /**
167
+ * File ▸ Open from ▸: each source, its items this editor reads by domain
168
+ * (a source that could not be read says why), Read again, then Add a
169
+ * source… and Remove ▸ for those added here.
170
+ */
171
+ contentItems() {
172
+ const formats = this.readers();
173
+ const sources = (this.content?.catalog ?? []).map(({ source, items, error }) => {
174
+ if (error) return { label: source.label, items: [{ label: `Could not read it: ${error}`, disabled: true }] };
175
+ const readable = items.filter(item => ContentSource.formatFor(item, formats));
176
+ const domains = [...new Set(readable.map(i => i.domainName))];
177
+ const entry = (item) => ({ label: item.name || item.id, title: item.description, action: () => this.openContent(source, item) });
178
+ return {
179
+ label: source.label,
180
+ items: readable.length
181
+ ? domains.map(d => ({ label: d || 'Other', items: readable.filter(i => i.domainName === d).map(entry) }))
182
+ : [{ label: 'Nothing this editor reads', disabled: true }],
183
+ };
184
+ });
185
+ const added = this.content?.added() ?? [];
186
+ return [
187
+ ...sources,
188
+ ...(sources.length ? [{ separator: true }, { label: 'Read them again', action: () => this.content.load() }] : []),
189
+ { label: 'Add a source…', action: () => this.askContentSource() },
190
+ ...(added.length ? [{ label: 'Remove a source', items: added.map(s => ({ label: s.label || s.url, action: () => this.content.remove(s.url) })) }] : []),
191
+ ];
192
+ }
193
+
194
+ /** Open a content item in a tab of its own, remembering where it came from. Resolves to the part, or null. */
195
+ async openContent(source, item) {
196
+ try {
197
+ const { model } = await source.open(item, this.readers());
198
+ const part = this.openModel(model);
199
+ part.contentOrigin = { source, itemId: item.id, version: item.version ?? null, name: item.name || item.id };
200
+ return part;
201
+ } catch (err) {
202
+ this.tell(`Could not open "${item.name || item.id}" from ${source.label}: ${err.message}`);
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Update from …: the item this model was opened from, read again, its
209
+ * changes reviewed as a merge into this one (ModelMergeDialog; one undo
210
+ * step), so what was changed here stays. Resolves to the dialog, or null.
211
+ */
212
+ async updateFromContent(part) {
213
+ const origin = part.contentOrigin;
214
+ if (!origin) return null;
215
+ try {
216
+ await origin.source.manifest({ refresh: true });
217
+ const item = await origin.source.item(origin.itemId);
218
+ if (!item) throw new Error(`it no longer has "${origin.name}"`);
219
+ const { model } = await origin.source.open(item, this.readers());
220
+ const label = `${item.name || item.id} (${origin.source.label}${item.version ? `, version ${item.version}` : ''})`;
221
+ return this.merge(part, { model, fileLabel: label, compareLabel: label });
222
+ } catch (err) {
223
+ this.tell(`Could not update from ${origin.source.label}: ${err.message}`);
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /** The model's Update from … item, when it was opened from content. */
229
+ contentUpdateItems(part) {
230
+ return part?.contentOrigin ? [{ label: `Update from ${part.contentOrigin.source.label}…`, action: () => this.updateFromContent(part) }] : [];
231
+ }
232
+
233
+ /** Add a source… : a manifest's url (a folder holding manifest.json, or the file) and what to call it. */
234
+ askContentSource() {
235
+ return new FormDialog({
236
+ title: 'Add a content source',
237
+ description: 'A collection of models with a manifest.json (modelengine, or one laid out like it): a folder\'s address, or the manifest\'s.',
238
+ fields: [
239
+ { key: 'url', label: 'Address', type: 'text', value: '', placeholder: 'https://…/modelengine/' },
240
+ { key: 'label', label: 'Called', type: 'text', value: '', placeholder: 'its name, from its manifest' },
241
+ ],
242
+ submitLabel: 'Add',
243
+ validate: ({ url }) => (String(url ?? '').trim() ? null : 'Give its address'),
244
+ document: this.container?.ownerDocument ?? globalThis.document,
245
+ onApply: ({ url, label }) => this.content.add({ url, label }),
246
+ }).open();
247
+ }
248
+
249
+ // ===== Kept in this browser =====
250
+
251
+ /** The store models and the backup folder are kept in: the browser's IndexedDB. A test gives its own. */
252
+ openStore() {
253
+ return IndexedDbStore.open('renderengine-models');
254
+ }
255
+
256
+ /**
257
+ * Open the store, reopen the models open when the page last closed (in
258
+ * place of the starting ones, unless those were changed meanwhile), and keep
259
+ * what is open now.
260
+ * @param {Array<EditorPart>} starting - The models the spec opened
261
+ */
262
+ async startKeeping(starting = []) {
263
+ const store = await this.openStore(); // the backup folder's, whoever keeps the models
264
+ this.library = this.ctx?.modelLibrary ?? new ModelLibrary(store);
265
+ const win = this.container?.ownerDocument?.defaultView ?? globalThis;
266
+ this.backups = new BackupFolder({
267
+ store, window: win,
268
+ download: (name, data, mimeType) => downloadBlob(new (win.Blob ?? Blob)([data], { type: mimeType }), name),
269
+ });
270
+ await this.backups.restore();
271
+ const kept = this.ownKept(await this.library.openOnes());
272
+ for (const { id } of kept) {
273
+ const entry = await this.library.load(id); // the list has no data; the entry has
274
+ if (entry) this.openKept(entry);
275
+ }
276
+ if (kept.length) for (const part of starting) if (!part.model?.isDirty?.()) this.closeModel(part.id);
277
+ for (const part of this.models()) if (part.keepId) await this.keep(part);
278
+ }
279
+
280
+ /** Keep a model in this browser now (its editor's keepEntry), as open. */
281
+ async keep(part) {
282
+ if (!this.library || part.keepOff) return null;
283
+ const entry = part.keepEntry?.();
284
+ if (!entry) return null;
285
+ part.keepId ??= ModelLibrary.newId();
286
+ return this.library.keep({ ...entry, id: part.keepId, editorType: this.editorType, open: this.modelIds.includes(part.id) });
287
+ }
288
+
289
+ /** A model was edited: keep it a moment later (a burst of edits, one write). */
290
+ modelEdited(part) {
291
+ if (!this.library) return;
292
+ clearTimeout(this._pending.get(part.id));
293
+ this._pending.set(part.id, setTimeout(() => { this._pending.delete(part.id); this.keep(part); }, ModelsPart.KEEP_DELAY));
294
+ }
295
+
296
+ /**
297
+ * The kept models that are this workbench's: kept by its kind of editor
298
+ * (`editorType`), or (kept before that was recorded) in one of its formats.
299
+ * Pages of other editors keep theirs in the same browser; those are not read here.
300
+ */
301
+ ownKept(entries) {
302
+ const labels = new Set(this.formatLabels());
303
+ return entries.filter(e => (e.editorType ? e.editorType === this.editorType : labels.has(e.format)));
304
+ }
305
+
306
+ /** The labels of this workbench's editors' file formats. */
307
+ formatLabels() {
308
+ const sample = this.models()[0] ?? this.bases()[0];
309
+ return (sample?.fileFormats?.() ?? []).map(f => f.label);
310
+ }
311
+
312
+ /** Open a kept model in a new tab, read in its format. Returns the part, or null when it is not one this editor reads. */
313
+ openKept(entry) {
314
+ if (!this.ownKept([entry]).length) {
315
+ this.tell(`"${entry.name}" was kept by another editor (${entry.editorType ?? entry.format}); open it there.`);
316
+ return null;
317
+ }
318
+ const part = this.newModel();
319
+ const format = part.fileFormats().find(f => f.label === entry.format);
320
+ try {
321
+ part.replaceModel(format.import(entry.data));
322
+ } catch (err) {
323
+ this.closeModel(part.id);
324
+ this.tell(`"${entry.name}" could not be read back: ${err.message}`);
325
+ return null;
326
+ }
327
+ part.keepId = entry.id;
328
+ part.file.name = entry.fileName ?? null;
329
+ part.file.showState();
330
+ this.work.focus(part.id);
331
+ return part;
332
+ }
333
+
334
+ /**
335
+ * File ▸ This browser, with `keep`: open a kept model, stop keeping this
336
+ * one, back up every kept model, choose where backups go.
337
+ */
338
+ keepItems(part) {
339
+ if (!this.library) return [];
340
+ return [
341
+ { label: 'Open a kept model…', action: () => this.askOpenKept() },
342
+ { label: 'Stop keeping this model', disabled: !part.keepId || undefined, action: () => this.stopKeeping(part) },
343
+ { separator: true },
344
+ { label: 'Back up kept models now', action: () => this.backUpAndTell() },
345
+ { label: `Backup folder: ${this.backups.location}…`, disabled: !this.backups.supported || undefined, action: () => this.chooseBackupFolder() },
346
+ ...(this.backups.dir ? [{ label: 'Back up to downloads instead', action: () => this.backups.forget() }] : []),
347
+ ];
348
+ }
349
+
350
+ /** Choose a kept model not open now, and open it. */
351
+ async askOpenKept() {
352
+ const open = new Set(this.models().map(p => p.keepId));
353
+ const entries = this.ownKept(await this.library.list()).filter(e => !open.has(e.id));
354
+ if (!entries.length) { this.tell(`Every model kept (${this.keptWhere}) is open.`); return null; }
355
+ return new FormDialog({
356
+ title: 'Open a kept model',
357
+ fields: [{
358
+ key: 'id', label: 'Model', type: 'select', value: entries[0].id,
359
+ choices: entries.map(e => [e.id, `${e.name} (kept ${new Date(e.keptAt).toLocaleString()})`]),
360
+ }],
361
+ submitLabel: 'Open', idPrefix: 'kept-',
362
+ document: this.container?.ownerDocument ?? globalThis.document,
363
+ onApply: async ({ id }) => { const entry = await this.library.load(id); if (entry) this.openKept(entry); },
364
+ }).open();
365
+ }
366
+
367
+ /** Forget a model in this browser, and keep it no more. */
368
+ async stopKeeping(part) {
369
+ if (part.keepId) await this.library.remove(part.keepId);
370
+ part.keepId = null;
371
+ part.keepOff = true;
372
+ }
373
+
374
+ /** Every kept model as a backup file: its name, in its format's extension. */
375
+ async backupFiles() {
376
+ const sample = this.models()[0];
377
+ const formats = sample?.fileFormats?.() ?? [];
378
+ const files = [];
379
+ for (const meta of this.ownKept(await this.library.list())) {
380
+ const entry = await this.library.load(meta.id);
381
+ const format = formats.find(f => f.label === entry.format);
382
+ const ext = (format?.accept ?? '').split(',')[0].trim() || '';
383
+ const base = String(entry.fileName ?? entry.name ?? 'model').replace(/\.[^.]+$/, '').replace(/[^A-Za-z0-9._-]+/g, '-') || 'model';
384
+ files.push({ name: `${base}${ext}`, data: entry.data, mimeType: 'application/octet-stream' });
385
+ }
386
+ return files;
387
+ }
388
+
389
+ /** Back up every kept model (what is open is kept first), and say where. */
390
+ async backUpAndTell() {
391
+ for (const part of this.models()) await this.keep(part);
392
+ const files = await this.backupFiles();
393
+ if (!files.length) { this.tell(`Nothing is kept (${this.keptWhere}) yet.`); return null; }
394
+ const { location, names } = await this.backups.backUp(files);
395
+ this.tell(`Backed up ${names.length} model${names.length === 1 ? '' : 's'} to the ${location}:\n\n${names.join('\n')}`);
396
+ return { location, names };
397
+ }
398
+
399
+ /** Choose the backup folder (a click); one OneDrive, Google Drive or Dropbox syncs takes the backups to the cloud. */
400
+ async chooseBackupFolder() {
401
+ const dir = await this.backups.choose();
402
+ if (dir) this.tell(`Backups go into "${dir.name}" from now on, each named with the date and time.\n\nA folder that OneDrive, Google Drive or Dropbox syncs takes them to the cloud.`);
403
+ return dir;
404
+ }
405
+
406
+ /** Where models are kept, as the File menu names it: the library's label. */
407
+ get keptWhere() {
408
+ return this.library?.label ?? 'This browser';
409
+ }
410
+
411
+ // ===== Proposals =====
412
+
413
+ /**
414
+ * Offer a change: the model proposed — `spec`, a model as this workbench's
415
+ * editor takes it (as a base is given), or `entry`, a model in one of the
416
+ * editor's file formats (`{format, data}`, as a kept one is) — with a
417
+ * `title` and why (`rationale`), from `source` (who or what proposed it).
418
+ * `onSettle(outcome, {bench})` hears whether it was 'taken' or
419
+ * 'dismissed' and, taken, how what was taken moved the bench. Returns the proposal.
420
+ * @param {{title: string, rationale?: string, source?: string, spec?: Object, entry?: {format: string, data: *}, onSettle?: Function}} proposal
421
+ */
422
+ propose({ title, rationale = '', source = '', spec = null, entry = null, onSettle = null }) {
423
+ if (!spec && !entry) throw new TypeError('A proposal needs a model spec');
424
+ const id = `${this.id}-proposal-${++this._proposalSeq}`;
425
+ const part = this.registry.create({ ...ModelsPart.copySpec(spec ?? {}), id, type: this.editorType });
426
+ if (entry) {
427
+ const format = part.fileFormats().find(f => f.label === entry.format);
428
+ if (!format) { part.dispose?.(); throw new Error(`No ${entry.format} format here to read the proposal in`); }
429
+ part.replaceModel(format.import(entry.data));
430
+ }
431
+ const proposal = { id, title: title || part.model?.name || 'Proposal', rationale, source, part, onSettle };
432
+ this.proposals.push(proposal);
433
+ return proposal;
434
+ }
435
+
436
+ /**
437
+ * How the proposal would move the model's measures (Benchmark), were it
438
+ * taken whole: `[{label, before, after, change, …}]`.
439
+ */
440
+ proposalBench(part, proposal) {
441
+ return Benchmark.compare(Benchmark.of(part.model), Benchmark.of(proposal.part.model));
442
+ }
443
+
444
+ /** Forget a proposal: not wanted (`outcome` 'dismissed'), or taken. Its proposer hears which. */
445
+ dismissProposal(proposal, outcome = 'dismissed', note = {}) {
446
+ if (!this.proposals.includes(proposal)) return;
447
+ this.proposals = this.proposals.filter(p => p !== proposal);
448
+ proposal.onSettle?.(outcome, note);
449
+ proposal.part?.dispose?.();
450
+ }
451
+
452
+ /**
453
+ * Review a proposal against `part`'s model: its differences, chosen as a
454
+ * merge (ModelMergeDialog), its rationale above them. Merging takes it.
455
+ */
456
+ reviewProposal(part, proposal) {
457
+ const bench = this.proposalBench(part, proposal);
458
+ return new ModelMergeDialog({
459
+ ...this._comparing(part, proposal.part),
460
+ theirsName: proposal.title,
461
+ note: [proposal.source && `Proposed by ${proposal.source}.`, proposal.rationale, bench.length && `Taken whole: ${Benchmark.text(bench)}.`].filter(Boolean).join(' '),
462
+ onMerge: (command) => {
463
+ // What was taken (the parts chosen), measured: the model before the merge and after.
464
+ const before = Benchmark.of(part.model);
465
+ part.runModelEdit(command);
466
+ this.dismissProposal(proposal, 'taken', { bench: Benchmark.text(Benchmark.compare(before, Benchmark.of(part.model))) });
467
+ },
468
+ }).open();
469
+ }
470
+
471
+ /** Tools ▸ Proposals: each, to review or dismiss. */
472
+ proposalItems(part) {
473
+ if (!this.proposals.length) return [{ label: 'None waiting', disabled: true }];
474
+ return this.proposals.map(p => ({
475
+ label: p.source ? `${p.title} — ${p.source}` : p.title,
476
+ items: [
477
+ { label: 'Review and merge…', action: () => this.reviewProposal(part, p) },
478
+ { label: 'Dismiss', action: () => this.dismissProposal(p) },
479
+ ],
480
+ }));
481
+ }
482
+
483
+ /** Tell the user (the page's alert). @protected */
484
+ tell(message) {
485
+ const win = this.container?.ownerDocument?.defaultView ?? globalThis;
486
+ win.alert?.(message);
487
+ }
488
+
489
+ /** The editor parts open here, in order. */
490
+ models() {
491
+ return this.modelIds.map(id => this.registry?.get(id)).filter(Boolean);
492
+ }
493
+
494
+ /** The editor part of the model in front (the selected tab of the active group). */
495
+ activeModel() {
496
+ const id = this.work?.activePart();
497
+ return id ? this.registry?.get(id) ?? null : null;
498
+ }
499
+
500
+ /**
501
+ * Open a new model in a tab: an editor part of `editorType` made from `spec`
502
+ * (default the spec's `newModel`, an empty model). Returns the part.
503
+ */
504
+ newModel(spec = this.spec.newModel ?? {}) {
505
+ const id = spec.id && !this.registry.has(spec.id) ? spec.id : this._freshId();
506
+ const part = this.registry.add({ ...spec, id, type: this.editorType });
507
+ this.modelIds.push(id);
508
+ this.work.open(id);
509
+ this.fileStateChanged(part);
510
+ return part;
511
+ }
512
+
513
+ /**
514
+ * Open a model made elsewhere (one imported from a file) in a new tab,
515
+ * as it is: nothing to undo, not yet saved to a file. Returns the part.
516
+ */
517
+ openModel(model) {
518
+ const part = this.newModel();
519
+ part.replaceModel(model);
520
+ this.work.focus(part.id);
521
+ this.fileStateChanged(part);
522
+ this.keep(part); // an import is kept straight away
523
+ return part;
524
+ }
525
+
526
+ // ===== Bases, and copies =====
527
+
528
+ /**
529
+ * The base models (spec `bases`): a part each, made from a copy of its spec
530
+ * and never added to the workbench, so nothing done to an open model reaches it.
531
+ */
532
+ bases() {
533
+ return (this._bases ??= (this.spec.bases ?? []).map((spec, i) =>
534
+ this.registry.create({ ...ModelsPart.copySpec(spec), id: `${this.id}-base-${i + 1}`, type: this.editorType })));
535
+ }
536
+
537
+ /**
538
+ * Replace the bases: a host whose reference models change (a live
539
+ * database after its schema changed) gives them afresh. The parts made
540
+ * for the old ones are disposed; nothing open is touched.
541
+ * @param {Array<Object>} specs - Editor part specs, as `bases` in the spec
542
+ */
543
+ setBases(specs = []) {
544
+ for (const base of this._bases ?? []) base.dispose?.();
545
+ this._bases = null;
546
+ this.spec.bases = specs;
547
+ return this.bases();
548
+ }
549
+
550
+ /** What a copy of a model is called: " (copy)" after its name, once (a copy of a copy is not "… (copy) (copy)"). */
551
+ static copyName(name) {
552
+ const plain = String(name || 'Untitled');
553
+ return plain.endsWith(ModelsPart.COPY_SUFFIX) ? plain : `${plain}${ModelsPart.COPY_SUFFIX}`;
554
+ }
555
+
556
+ /** A spec's own copy: its data apart from the spec's (a spec is JSON). */
557
+ static copySpec(spec) {
558
+ return JSON.parse(JSON.stringify(spec ?? {}));
559
+ }
560
+
561
+ /** What a base is called in menus. */
562
+ baseLabel(base, i) {
563
+ return base.model?.name || base.spec.name || `Base ${i + 1}`;
564
+ }
565
+
566
+ /** Open a new model as a copy of a base (by its place in `bases`), named as a copy (copyName). Returns the part. */
567
+ newFromBase(index) {
568
+ const spec = this.bases()[index]?.spec; // the base's own copy, as given
569
+ if (!spec) return null;
570
+ const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
571
+ const part = this.newModel({ ...copy, name: ModelsPart.copyName(copy.name ?? this.baseLabel(this.bases()[index], index)) });
572
+ this.work.focus(part.id);
573
+ return part;
574
+ }
575
+
576
+ /**
577
+ * Open a base to look at (by its place in `bases`): a tab of its own copy,
578
+ * named as the base is with " (view)", that can be laid out and moved
579
+ * about but is never kept — a live database seen as it is, without making a
580
+ * design of it. Returns the part.
581
+ */
582
+ viewBase(index) {
583
+ const spec = this.bases()[index]?.spec;
584
+ if (!spec) return null;
585
+ const { id, keepId, ...copy } = ModelsPart.copySpec(spec);
586
+ const part = this.newModel({ ...copy, name: `${copy.name ?? this.baseLabel(this.bases()[index], index)}${ModelsPart.VIEW_SUFFIX}` });
587
+ part.keepOff = true;
588
+ this.work.focus(part.id);
589
+ return part;
590
+ }
591
+
592
+ /** What a base viewed is called: " (view)" after its name. */
593
+ static VIEW_SUFFIX = ' (view)';
594
+
595
+ /** File ▸ New ▸'s items from bases: From base: <name>, a design from one; View base: <name>, one to look at. */
596
+ baseItems() {
597
+ const bases = this.bases();
598
+ return [
599
+ ...bases.map((base, i) => ({ label: `From base: ${this.baseLabel(base, i)}`, action: () => this.newFromBase(i) })),
600
+ ...(bases.length ? [{ separator: true }] : []),
601
+ ...bases.map((base, i) => ({ label: `View base: ${this.baseLabel(base, i)}`, action: () => this.viewBase(i) })),
602
+ ];
603
+ }
604
+
605
+ /**
606
+ * Open a copy of a model in a new tab: written and read back in its own
607
+ * file format (keepEntry), named as a copy (copyName). Null when it has no format.
608
+ */
609
+ duplicate(part) {
610
+ const entry = part.keepEntry?.();
611
+ if (!entry) return null;
612
+ const format = part.fileFormats().find(f => f.label === entry.format);
613
+ const model = format.import(entry.data);
614
+ model.name = ModelsPart.copyName(model.name || entry.name);
615
+ return this.openModel(model);
616
+ }
617
+
618
+ /**
619
+ * The Tools menu's items for one open model (`part`): Compare with and
620
+ * Merge from, each a submenu of the other open models, then the bases.
621
+ */
622
+ toolItems(part) {
623
+ const others = this.models().filter(m => m !== part);
624
+ const bases = this.bases().map((base, i) => ({ base, label: `${this.baseLabel(base, i)} (base)` }));
625
+ const each = (act) => (others.length || bases.length
626
+ ? [
627
+ ...others.map(other => ({ label: this.labelOf(other), action: () => act(part, other) })),
628
+ ...(others.length && bases.length ? [{ separator: true }] : []),
629
+ ...bases.map(({ base, label }) => ({ label, action: () => act(part, base) })),
630
+ ]
631
+ : [{ label: 'Open another model first', disabled: true }]);
632
+ const own = part.modelMenuItems?.() ?? [];
633
+ return [
634
+ { label: 'Compare with', items: each((a, b) => this.compare(a, b)) },
635
+ { label: 'Merge from', items: each((a, b) => this.merge(a, b)) },
636
+ ...this.contentUpdateItems(part),
637
+ // The model's own tools (Find likely duplicates…).
638
+ ...(own.length ? [{ separator: true }, ...own] : []),
639
+ { separator: true },
640
+ { label: `Proposals (${this.proposals.length})`, items: this.proposalItems(part) },
641
+ // The host's own tools for the model in front (`services.modelTools(part, models)`: agents, say).
642
+ ...(typeof this.ctx?.modelTools === 'function' ? [{ separator: true }, ...this.ctx.modelTools(part, this)] : []),
643
+ ];
644
+ }
645
+
646
+ /** How `part`'s model differs from `other`'s, shown (ModelCompareDialog); its Merge… goes on to merge. */
647
+ compare(part, other) {
648
+ return new ModelCompareDialog({
649
+ ...this._comparing(part, other),
650
+ onMerge: () => this.merge(part, other),
651
+ }).open();
652
+ }
653
+
654
+ /** Choose what to bring from `other`'s model into `part`'s, then bring it: one undo step there. */
655
+ merge(part, other) {
656
+ return new ModelMergeDialog({
657
+ ...this._comparing(part, other),
658
+ onMerge: (command) => part.runModelEdit(command),
659
+ }).open();
660
+ }
661
+
662
+ /** @private */
663
+ _comparing(part, other) {
664
+ return {
665
+ comparison: part.model.compareWith(other.model),
666
+ mineName: part.model.name || this.labelOf(part),
667
+ theirsName: other.compareLabel ?? (other.model.name || this.labelOf(other)),
668
+ document: this.container?.ownerDocument ?? globalThis.document,
669
+ };
670
+ }
671
+
672
+ /**
673
+ * Ask for a file (as `from`'s Open… does) and open it in a new tab,
674
+ * leaving `from`'s model as it is. Returns the new part, or null.
675
+ */
676
+ async openInNewTab(from) {
677
+ const chosen = await from.file.choose();
678
+ if (!chosen) return null;
679
+ const part = this.newModel();
680
+ part.file.adopt(chosen);
681
+ return part;
682
+ }
683
+
684
+ /**
685
+ * Close a model's tab; with unsaved changes, only once confirmed. The last
686
+ * one closed leaves a new, empty model. Returns whether it closed.
687
+ */
688
+ closeModel(id) {
689
+ const part = this.registry?.get(id);
690
+ if (!part || !this.modelIds.includes(id)) return false;
691
+ if (part.model?.isDirty?.() && !this.confirm(`"${this.labelOf(part)}" has unsaved changes. Close it anyway?`)) return false;
692
+ this.work.close(id);
693
+ this.modelIds = this.modelIds.filter(m => m !== id);
694
+ if (part.keepId) this.library?.setOpen(part.keepId, false); // kept, not reopened next time
695
+ this.registry.remove(id);
696
+ if (!this.modelIds.length) this.newModel();
697
+ else this.activeModel()?.file?.showState?.();
698
+ return true;
699
+ }
700
+
701
+ /** A model's tab label: what its toolbar and the page title call it (ModelFile#label). */
702
+ labelOf(part) {
703
+ return part.fileLabel;
704
+ }
705
+
706
+ /** An editor part's file or saved state changed: its tab says so. */
707
+ fileStateChanged(part) {
708
+ part.title = this.labelOf(part);
709
+ this.work?.refreshLabels();
710
+ }
711
+
712
+ /**
713
+ * What can be done with one open model, for its tab's right-click menu:
714
+ * Save and Save As… (when it saves files), Duplicate model, Compare with ▸
715
+ * and Merge from ▸ (the other open models, then the bases), the model's own
716
+ * tools (Find likely duplicates…: `part.modelMenuItems()`), This browser ▸
717
+ * (with `keep`), Close model and Close other models.
718
+ */
719
+ tabMenuItems(part) {
720
+ const saves = !!part.fileFormats?.().length;
721
+ const [, save, saveAs] = saves ? part.file.menuItems() : [];
722
+ const [compare, merge] = part.model?.compareWith ? this.toolItems(part) : [];
723
+ const keeping = this.keepItems(part);
724
+ const others = this.modelIds.filter(id => id !== part.id);
725
+ const own = part.modelMenuItems?.() ?? [];
726
+ return [
727
+ ...(saves ? [save, saveAs, { separator: true }, { label: 'Duplicate model', action: () => this.duplicate(part) }] : []),
728
+ ...(compare ? [compare, merge] : []),
729
+ ...this.contentUpdateItems(part),
730
+ ...own,
731
+ ...(keeping.length ? [{ label: this.keptWhere, items: keeping }] : []),
732
+ { separator: true },
733
+ { label: 'Close model', action: () => this.closeModel(part.id) },
734
+ { label: 'Close other models', disabled: !others.length || undefined, action: () => { for (const id of others) this.closeModel(id); } },
735
+ ];
736
+ }
737
+
738
+ /** Show a model's tab menu at a right-click; true when there was a model to show it for. */
739
+ showTabMenu(id, event) {
740
+ const part = this.modelIds.includes(id) ? this.registry?.get(id) : null;
741
+ if (!part) return false;
742
+ PopupMenu.show(event?.clientX ?? 0, event?.clientY ?? 0, this.tabMenuItems(part), this.container?.ownerDocument ?? globalThis.document);
743
+ return true;
744
+ }
745
+
746
+ /** Ask the user (the page's confirm). @protected */
747
+ confirm(message) {
748
+ const win = this.container?.ownerDocument?.defaultView ?? globalThis;
749
+ return typeof win.confirm === 'function' ? win.confirm(message) : true;
750
+ }
751
+
752
+ /** @private */
753
+ _freshId() {
754
+ let id;
755
+ do { id = `${this.id}-model-${this._next++}`; } while (this.registry.has(id));
756
+ return id;
757
+ }
758
+
759
+ dispose() {
760
+ this.work?.dispose();
761
+ for (const id of this.modelIds) this.registry?.remove(id);
762
+ this.modelIds = [];
763
+ super.dispose();
764
+ }
765
+ }