@formicoidea/labre-framework-bpmn 0.32.0 → 0.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions.d.ts +202 -6
- package/dist/actions.js +421 -43
- package/dist/background.d.ts +2 -0
- package/dist/background.js +158 -0
- package/dist/commands.js +496 -5
- package/dist/consts.d.ts +157 -3
- package/dist/consts.js +192 -3
- package/dist/element-renderer.d.ts +10 -4
- package/dist/element-renderer.js +14 -55
- package/dist/element-view.d.ts +100 -8
- package/dist/element-view.js +249 -30
- package/dist/export.d.ts +277 -0
- package/dist/export.js +1802 -0
- package/dist/facts.d.ts +48 -0
- package/dist/facts.js +127 -0
- package/dist/import.d.ts +44 -0
- package/dist/import.js +1440 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +44 -0
- package/dist/interchange.d.ts +109 -0
- package/dist/interchange.js +191 -0
- package/dist/morph.d.ts +61 -0
- package/dist/morph.js +118 -0
- package/dist/node/node-renderer.d.ts +0 -9
- package/dist/node/node-renderer.js +294 -17
- package/dist/pool-hit.d.ts +98 -0
- package/dist/pool-hit.js +130 -0
- package/dist/presets.d.ts +114 -0
- package/dist/presets.js +232 -0
- package/dist/profiles.d.ts +2 -0
- package/dist/profiles.js +189 -0
- package/dist/roles.d.ts +96 -0
- package/dist/roles.js +410 -0
- package/dist/rules.d.ts +199 -0
- package/dist/rules.js +1539 -0
- package/dist/templates/index.js +116 -9
- package/dist/toolbar/bpmn-senior-button.js +8 -2
- package/dist/toolbar/config.d.ts +27 -2
- package/dist/toolbar/config.js +86 -2
- package/dist/toolbar/icons.d.ts +67 -0
- package/dist/toolbar/icons.js +141 -0
- package/dist/toolbar/senior-tool.js +1 -0
- package/dist/translations.d.ts +3 -1
- package/dist/translations.js +8 -3
- package/dist/view.d.ts +6 -2
- package/dist/view.js +68 -5
- package/package.json +2 -2
package/dist/export.js
ADDED
|
@@ -0,0 +1,1802 @@
|
|
|
1
|
+
import { backgroundInstanceZones, backgroundPlot, } from '@formicoidea/labre-core/blocks/surface';
|
|
2
|
+
import { BPMN_POOL_BACKGROUND } from './background.js';
|
|
3
|
+
import { bpmnLaneOf, bpmnPoolOf } from './facts.js';
|
|
4
|
+
import { BPMN_ROLE } from './roles.js';
|
|
5
|
+
/** The namespace the pool's instance zones report under. See `facts.ts`. */
|
|
6
|
+
const LANE_PREFIX = BPMN_POOL_BACKGROUND.instanceZones?.idPrefix ?? 'lane';
|
|
7
|
+
/**
|
|
8
|
+
* The board, as a BPMN 2.0 interchange document (clause 15) — semantic model
|
|
9
|
+
* plus BPMN DI, in one `definitions` element.
|
|
10
|
+
*
|
|
11
|
+
* ## Pure by construction
|
|
12
|
+
*
|
|
13
|
+
* Element models in, string out. No `BlockStdScope`, no surface, no DOM, no
|
|
14
|
+
* clock and no randomness — the same discipline `facts.ts` holds itself to, and
|
|
15
|
+
* for the same three reasons: a host can call it, a test can call it with plain
|
|
16
|
+
* stubs, and the same board always serializes to the same bytes. The command
|
|
17
|
+
* that downloads the file is the only thing that knows what a canvas is.
|
|
18
|
+
*
|
|
19
|
+
* ## What it says, and what it refuses to say
|
|
20
|
+
*
|
|
21
|
+
* The export speaks the author's STATEMENTS and nothing else. A connector
|
|
22
|
+
* carrying no BPMN role relates nothing — `docs/adr/0010` is explicit that the
|
|
23
|
+
* role is the statement — so it is not a sequence flow that happens to be
|
|
24
|
+
* untyped, it is not a flow at all, and it is absent. A plain rectangle drawn
|
|
25
|
+
* beside a pool is likewise not an unnamed task. The alternative — guessing —
|
|
26
|
+
* would put words in an architect's mouth in a file they are about to hand to
|
|
27
|
+
* an execution engine.
|
|
28
|
+
*
|
|
29
|
+
* ## Conformance target
|
|
30
|
+
*
|
|
31
|
+
* The **Descriptive** sub-class of BPMN 2.0 (spec Table 2.1), which is exactly
|
|
32
|
+
* the vocabulary the pack draws: the seventeen artefacts map onto the
|
|
33
|
+
* seventeen-odd element names that table lists, and nothing here needs the
|
|
34
|
+
* executable half of the metamodel. Clause 15.1 explicitly licenses a partial
|
|
35
|
+
* model — implementers "disregard missing attributes marked required" — which
|
|
36
|
+
* is what lets a picture drawn for humans round-trip through bpmn.io without
|
|
37
|
+
* inventing an `ioSpecification` nobody asked for.
|
|
38
|
+
*/
|
|
39
|
+
/* ── Namespaces ───────────────────────────────────────────────────────── */
|
|
40
|
+
/**
|
|
41
|
+
* The four namespaces an interchange file is written in, with the prefixes the
|
|
42
|
+
* spec's own schema uses (`bpmndi`, `di`, `dc`; §12.2.4 and Annex B).
|
|
43
|
+
*
|
|
44
|
+
* Prefixes are arbitrary and URIs are not — bpmn.io writes the same two DD
|
|
45
|
+
* namespaces as `omgdi` / `omgdc` — so the URIs are what is pinned by the tests
|
|
46
|
+
* and the prefixes merely have to be consistent with themselves. The MODEL
|
|
47
|
+
* namespace is given the explicit `bpmn` prefix rather than made the default,
|
|
48
|
+
* because a reader of the file should never have to work out which of two
|
|
49
|
+
* unprefixed vocabularies an element belongs to.
|
|
50
|
+
*
|
|
51
|
+
* The stale `.../BPMNDI/1.0.0` that appears in the spec's own clause 15.3.1
|
|
52
|
+
* example is a documented erratum and is NOT what the normative schema says.
|
|
53
|
+
*/
|
|
54
|
+
export const BPMN_NS = {
|
|
55
|
+
model: 'http://www.omg.org/spec/BPMN/20100524/MODEL',
|
|
56
|
+
bpmndi: 'http://www.omg.org/spec/BPMN/20100524/DI',
|
|
57
|
+
di: 'http://www.omg.org/spec/DD/20100524/DI',
|
|
58
|
+
dc: 'http://www.omg.org/spec/DD/20100524/DC',
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The four declarations this library writes, as they appear on `definitions` —
|
|
62
|
+
* the PAIR, prefix and URI together, and the pair is the point.
|
|
63
|
+
*
|
|
64
|
+
* A reader carries a file's namespace declarations because a carried fragment
|
|
65
|
+
* is stored with the prefixes the file spelled it in, and a `camunda:property`
|
|
66
|
+
* or a `bpmn2:boundaryEvent` means nothing under a declaration nobody wrote.
|
|
67
|
+
* What it must NOT carry is a declaration this writer is going to make anyway,
|
|
68
|
+
* or the payload gains four permanent entries, every Labre file reports four
|
|
69
|
+
* things carried, and the "a file we wrote comes back with an empty middle
|
|
70
|
+
* column" property — the no-slow-leak property — stops being true.
|
|
71
|
+
*
|
|
72
|
+
* Keyed by the attribute NAME rather than by the URI, because the prefix is
|
|
73
|
+
* exactly what differs: bpmn.io writes the model namespace as `bpmn2:` and this
|
|
74
|
+
* library writes it as `bpmn:`, and a fragment carrying `bpmn2:` is unreadable
|
|
75
|
+
* unless `xmlns:bpmn2` comes back with it. Same URI, different prefix,
|
|
76
|
+
* different fate — which a set of URIs cannot express.
|
|
77
|
+
*/
|
|
78
|
+
export const BPMN_OWN_DECLARATIONS = {
|
|
79
|
+
'xmlns:bpmn': BPMN_NS.model,
|
|
80
|
+
'xmlns:bpmndi': BPMN_NS.bpmndi,
|
|
81
|
+
'xmlns:di': BPMN_NS.di,
|
|
82
|
+
'xmlns:dc': BPMN_NS.dc,
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Where the ids this exporter mints live.
|
|
86
|
+
*
|
|
87
|
+
* `targetNamespace` is the ONE attribute `definitions` requires (spec §15.3.1),
|
|
88
|
+
* and it has to be a URI nobody else claims: it is what a second document
|
|
89
|
+
* IMPORTING this one would qualify its references with.
|
|
90
|
+
*
|
|
91
|
+
* It is deliberately NOT what the references inside this file resolve through.
|
|
92
|
+
* Several of them are typed `xsd:QName` — `participant/@processRef`,
|
|
93
|
+
* `group/@categoryValueRef`, `dataObjectReference/@dataObjectRef`, every DI
|
|
94
|
+
* `bpmnElement`, and `messageFlow` / `association` `sourceRef` / `targetRef` —
|
|
95
|
+
* and an unprefixed QName resolves against the DEFAULT namespace, which this
|
|
96
|
+
* document declares none of (see the note on {@link BPMN_NS} for why the MODEL
|
|
97
|
+
* namespace takes an explicit prefix instead). They are emitted as bare local
|
|
98
|
+
* names, which is byte for byte what bpmn.io and Camunda Modeler write, and
|
|
99
|
+
* every tool resolves them by id within the one file.
|
|
100
|
+
*/
|
|
101
|
+
const TARGET_NAMESPACE = 'https://labre.app/bpmn';
|
|
102
|
+
/**
|
|
103
|
+
* The interchange format's id — the key under which foreign matter from a
|
|
104
|
+
* `.bpmn` rides on an element (`interchange.bpmn`, ADR 0012 D2), and the middle
|
|
105
|
+
* term of both capability ids.
|
|
106
|
+
*
|
|
107
|
+
* Declared here, in the module both directions already depend on, so that the
|
|
108
|
+
* writer, the reader and the registry entry cannot disagree about which key
|
|
109
|
+
* they are talking about.
|
|
110
|
+
*/
|
|
111
|
+
export const BPMN_FORMAT_ID = 'bpmn';
|
|
112
|
+
const EXPORTER = 'Labre';
|
|
113
|
+
/**
|
|
114
|
+
* The whole notation, kind by kind — the table this module is really about.
|
|
115
|
+
*
|
|
116
|
+
* `Record<BpmnNodeKind, …>` and therefore COMPILE-TOTAL: a kind added to the
|
|
117
|
+
* pack without a BPMN element name to serialize it as fails the build here,
|
|
118
|
+
* which is the only place that failure is cheap. A kind that reached a
|
|
119
|
+
* document and had no mapping would be an artefact the author drew, saved, and
|
|
120
|
+
* then silently lost on export.
|
|
121
|
+
*
|
|
122
|
+
* Three of the seventeen do not map one-for-one and the reasons are the spec's:
|
|
123
|
+
*
|
|
124
|
+
* - the four TRIGGERED events are `startEvent` / `endEvent` carrying an event
|
|
125
|
+
* definition child, never elements of their own — "message start event" is a
|
|
126
|
+
* start event with a `messageEventDefinition` in it (§10.4.2);
|
|
127
|
+
* - a data object serializes as `dataObjectReference`, because DI attaches to
|
|
128
|
+
* the REFERENCE and not to the `dataObject` it points at (§10.4.1, and the
|
|
129
|
+
* spec's own rule that "Data Object Reference cannot specify item
|
|
130
|
+
* definitions, and Data Objects cannot specify states"). The `dataObject`
|
|
131
|
+
* itself is emitted alongside it;
|
|
132
|
+
* - a `group` carries no `name` at all. Its visible label is the `value` of the
|
|
133
|
+
* `categoryValue` it points at, which is a ROOT element of the document — the
|
|
134
|
+
* one place in this file where drawing a box round three tasks costs two
|
|
135
|
+
* extra elements somewhere else entirely (§10.4, Table 8.30).
|
|
136
|
+
*/
|
|
137
|
+
export const BPMN_XML_OF_KIND = {
|
|
138
|
+
startEvent: { element: 'startEvent', slot: 'flowNode' },
|
|
139
|
+
startEventMessage: {
|
|
140
|
+
element: 'startEvent',
|
|
141
|
+
slot: 'flowNode',
|
|
142
|
+
eventDefinition: 'messageEventDefinition',
|
|
143
|
+
},
|
|
144
|
+
startEventTimer: {
|
|
145
|
+
element: 'startEvent',
|
|
146
|
+
slot: 'flowNode',
|
|
147
|
+
eventDefinition: 'timerEventDefinition',
|
|
148
|
+
},
|
|
149
|
+
endEvent: { element: 'endEvent', slot: 'flowNode' },
|
|
150
|
+
endEventMessage: {
|
|
151
|
+
element: 'endEvent',
|
|
152
|
+
slot: 'flowNode',
|
|
153
|
+
eventDefinition: 'messageEventDefinition',
|
|
154
|
+
},
|
|
155
|
+
endEventTerminate: {
|
|
156
|
+
element: 'endEvent',
|
|
157
|
+
slot: 'flowNode',
|
|
158
|
+
eventDefinition: 'terminateEventDefinition',
|
|
159
|
+
},
|
|
160
|
+
task: { element: 'task', slot: 'flowNode' },
|
|
161
|
+
taskUser: { element: 'userTask', slot: 'flowNode' },
|
|
162
|
+
taskService: { element: 'serviceTask', slot: 'flowNode' },
|
|
163
|
+
subProcess: { element: 'subProcess', slot: 'flowNode' },
|
|
164
|
+
callActivity: { element: 'callActivity', slot: 'flowNode' },
|
|
165
|
+
gatewayExclusive: { element: 'exclusiveGateway', slot: 'flowNode' },
|
|
166
|
+
gatewayParallel: { element: 'parallelGateway', slot: 'flowNode' },
|
|
167
|
+
dataObject: { element: 'dataObjectReference', slot: 'data' },
|
|
168
|
+
dataStore: { element: 'dataStoreReference', slot: 'data' },
|
|
169
|
+
textAnnotation: { element: 'textAnnotation', slot: 'artifact' },
|
|
170
|
+
group: { element: 'group', slot: 'artifact' },
|
|
171
|
+
};
|
|
172
|
+
/** The three edge roles this exporter writes, and what each becomes. */
|
|
173
|
+
const EDGE_ELEMENT = {
|
|
174
|
+
[BPMN_ROLE.sequenceFlow]: 'sequenceFlow',
|
|
175
|
+
[BPMN_ROLE.messageFlow]: 'messageFlow',
|
|
176
|
+
[BPMN_ROLE.association]: 'association',
|
|
177
|
+
};
|
|
178
|
+
const el = (name, attrs = {}, children = []) => ({ name, attrs, children });
|
|
179
|
+
const textEl = (name, text, attrs = {}) => ({
|
|
180
|
+
name,
|
|
181
|
+
attrs,
|
|
182
|
+
children: [],
|
|
183
|
+
text,
|
|
184
|
+
});
|
|
185
|
+
/**
|
|
186
|
+
* Character DATA — the three characters that would otherwise start markup.
|
|
187
|
+
*
|
|
188
|
+
* A newline, a tab and a carriage return are left exactly as they are, which is
|
|
189
|
+
* what makes `<bpmn:text>` carry a multi-line annotation faithfully: inside an
|
|
190
|
+
* element, whitespace is content.
|
|
191
|
+
*/
|
|
192
|
+
export function escapeText(value) {
|
|
193
|
+
return value
|
|
194
|
+
.replaceAll('&', '&')
|
|
195
|
+
.replaceAll('<', '<')
|
|
196
|
+
.replaceAll('>', '>');
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* An attribute VALUE, which needs strictly more than character data does.
|
|
200
|
+
*
|
|
201
|
+
* The quotes are the obvious half. The other half is the one that loses data
|
|
202
|
+
* silently: XML 1.0 §3.3.3 makes every conformant parser replace a literal
|
|
203
|
+
* `#xA`, `#xD` or `#x9` in an attribute value with a SPACE before anyone sees
|
|
204
|
+
* it — attribute-value normalization, and it is not optional. Only a character
|
|
205
|
+
* reference survives it.
|
|
206
|
+
*
|
|
207
|
+
* That matters here because a multi-line label is ordinary on this canvas (it
|
|
208
|
+
* is how a task fits in its box) and `name` is where nearly all of them go:
|
|
209
|
+
* every flow node, the participant, the lane, the flows, and
|
|
210
|
+
* `categoryValue/@value`. Written raw, a two-line task name comes back as one
|
|
211
|
+
* line, with no warning and no way for the author to tell. Written as ` `
|
|
212
|
+
* it comes back as it went in.
|
|
213
|
+
*/
|
|
214
|
+
export function escapeAttr(value) {
|
|
215
|
+
return escapeText(value)
|
|
216
|
+
.replaceAll('"', '"')
|
|
217
|
+
.replaceAll("'", ''')
|
|
218
|
+
.replaceAll('\n', ' ')
|
|
219
|
+
.replaceAll('\r', ' ')
|
|
220
|
+
.replaceAll('\t', '	');
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* A coordinate, as few characters as it can honestly be.
|
|
224
|
+
*
|
|
225
|
+
* Two decimals: DI coordinates are `xsd:double`, a canvas produces fractions no
|
|
226
|
+
* eye can see, and `123.45000000000002` in a file a human reads is noise. The
|
|
227
|
+
* rounding is applied AFTER the plane translation, so it never accumulates.
|
|
228
|
+
*/
|
|
229
|
+
function num(value) {
|
|
230
|
+
if (!Number.isFinite(value))
|
|
231
|
+
return '0';
|
|
232
|
+
const rounded = Math.round(value * 100) / 100;
|
|
233
|
+
return String(Object.is(rounded, -0) ? 0 : rounded);
|
|
234
|
+
}
|
|
235
|
+
function serializeElement(node, indent) {
|
|
236
|
+
// Verbatim, and only indented: whitespace INSIDE the fragment is content and
|
|
237
|
+
// is never reflowed. See {@link XmlFragment}.
|
|
238
|
+
if ('fragment' in node)
|
|
239
|
+
return `${indent}${node.fragment}`;
|
|
240
|
+
const attrs = Object.entries(node.attrs)
|
|
241
|
+
.filter(([, value]) => value !== undefined)
|
|
242
|
+
.map(([key, value]) => ` ${key}="${escapeAttr(String(value))}"`)
|
|
243
|
+
.join('');
|
|
244
|
+
if (node.text !== undefined) {
|
|
245
|
+
return `${indent}<${node.name}${attrs}>${escapeText(node.text)}</${node.name}>`;
|
|
246
|
+
}
|
|
247
|
+
if (node.children.length === 0) {
|
|
248
|
+
return `${indent}<${node.name}${attrs} />`;
|
|
249
|
+
}
|
|
250
|
+
const inner = node.children
|
|
251
|
+
.map(child => serializeElement(child, `${indent} `))
|
|
252
|
+
.join('\n');
|
|
253
|
+
return `${indent}<${node.name}${attrs}>\n${inner}\n${indent}</${node.name}>`;
|
|
254
|
+
}
|
|
255
|
+
/* ── Ids ──────────────────────────────────────────────────────────────── */
|
|
256
|
+
/**
|
|
257
|
+
* Which characters an XML `Name` admits — a conservative but UNICODE-AWARE
|
|
258
|
+
* reading of NameStartChar / NameChar.
|
|
259
|
+
*
|
|
260
|
+
* Letters rather than `[A-Za-z]`, because NCName has always allowed them and an
|
|
261
|
+
* architect writing in French or Portuguese should not have `tâche-1` folded to
|
|
262
|
+
* `t_che-1`: two accented ids one letter apart would then differ only by the
|
|
263
|
+
* minter's `_2` suffix, in the properties panel where a human reads them. The
|
|
264
|
+
* production's exotic tail (combining marks, extenders, `·`) is deliberately
|
|
265
|
+
* not enumerated — nothing on this canvas mints one, and a character wrongly
|
|
266
|
+
* replaced by `_` is safe where a character wrongly kept is not.
|
|
267
|
+
*/
|
|
268
|
+
const NAME_START = /[\p{L}_]/u;
|
|
269
|
+
const NAME_CHAR = /[\p{L}\p{N}_.\-]/u;
|
|
270
|
+
/**
|
|
271
|
+
* A surface id, as an XML NCName.
|
|
272
|
+
*
|
|
273
|
+
* `id` is `xsd:ID` throughout BPMN, which means NCName and means
|
|
274
|
+
* DOCUMENT-unique — a `BPMNShape` may not carry the id of the `task` it
|
|
275
|
+
* describes. Surface ids are nanoid-shaped: they routinely open on a digit and
|
|
276
|
+
* may carry a `-`, both of which a validating parser refuses on an `xsd:ID`.
|
|
277
|
+
*
|
|
278
|
+
* So: every disallowed character becomes `_`, and an id that does not open on a
|
|
279
|
+
* letter or `_` is prefixed with one. The transformation is lossy on purpose —
|
|
280
|
+
* two distinct surface ids can collapse onto the same NCName — which is what
|
|
281
|
+
* {@link IdMinter} is for.
|
|
282
|
+
*/
|
|
283
|
+
export function toNcName(raw) {
|
|
284
|
+
let out = '';
|
|
285
|
+
for (const char of raw) {
|
|
286
|
+
out += NAME_CHAR.test(char) ? char : '_';
|
|
287
|
+
}
|
|
288
|
+
if (out.length === 0 || !NAME_START.test(out[0]))
|
|
289
|
+
out = `_${out}`;
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
/** Whether a string is already an NCName, and can therefore be given back. */
|
|
293
|
+
export function isNcName(value) {
|
|
294
|
+
if (value.length === 0 || !NAME_START.test(value[0]))
|
|
295
|
+
return false;
|
|
296
|
+
for (const char of value) {
|
|
297
|
+
if (!NAME_CHAR.test(char))
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The id a `.bpmn` import recorded for this element, verbatim (ADR 0012, D3).
|
|
304
|
+
*
|
|
305
|
+
* The read half of the round trip, and the whole of what this module knows
|
|
306
|
+
* about importing: `interchange.bpmn.id` is what the file called this thing,
|
|
307
|
+
* and giving it back is what makes the id map a FIXED POINT after one cycle —
|
|
308
|
+
* the first export renames (surface id → NCName), every export after an import
|
|
309
|
+
* gives back what it was given. The export writes nothing here, ever.
|
|
310
|
+
*
|
|
311
|
+
* Keyed by the FORMAT and not the framework, because a `.bpmn` and an OWM file
|
|
312
|
+
* make different promises about the same element. Read defensively: the value
|
|
313
|
+
* came out of a Y.Map and is whatever a peer wrote.
|
|
314
|
+
*/
|
|
315
|
+
function carriedBpmnId(model) {
|
|
316
|
+
const given = model.interchange?.[BPMN_FORMAT_ID]?.id;
|
|
317
|
+
return typeof given === 'string' && given.length > 0 ? given : undefined;
|
|
318
|
+
}
|
|
319
|
+
/** The source element name an import recorded, when the element was carried. */
|
|
320
|
+
function carriedBpmnElement(model) {
|
|
321
|
+
const name = model.interchange?.[BPMN_FORMAT_ID]?.element;
|
|
322
|
+
return typeof name === 'string' && name.length > 0 ? name : undefined;
|
|
323
|
+
}
|
|
324
|
+
/* ── Foreign matter, and where it goes back ───────────────────────────── */
|
|
325
|
+
/**
|
|
326
|
+
* `.bpmn`'s scope vocabulary — where a carried fragment came off, and therefore
|
|
327
|
+
* where this writer has to put it back (ADR 0012, D2 as amended in #157).
|
|
328
|
+
*
|
|
329
|
+
* Declared HERE, in the module both directions already depend on, for the same
|
|
330
|
+
* reason {@link BPMN_FORMAT_ID} is: the reader files a fragment under a scope
|
|
331
|
+
* and the writer looks it up under one, and a table written twice is a table
|
|
332
|
+
* that drifts. `import.ts` re-exports it.
|
|
333
|
+
*
|
|
334
|
+
* One Labre element stands for several source elements: a pool is a
|
|
335
|
+
* `participant` AND its `process`, plus a `laneSet`, every `lane`, the
|
|
336
|
+
* `BPMNShape` that draws it, and — on the first pool of a document — the
|
|
337
|
+
* `collaboration` and `definitions` themselves. Everything they carry lands in
|
|
338
|
+
* ONE payload, so what came off which is recorded, or two lanes with the same
|
|
339
|
+
* foreign attribute leave one value in a persisted field and a report that says
|
|
340
|
+
* two.
|
|
341
|
+
*
|
|
342
|
+
* A scope is either a source element's **id, verbatim** — every carried flow
|
|
343
|
+
* node, every lane, every carried root element — or one of the `@` keys below,
|
|
344
|
+
* for the parts of the document that have no id worth naming or whose identity
|
|
345
|
+
* is their relation to this element. `@` is not an XML NameStartChar, so no id
|
|
346
|
+
* in a conformant file can ever collide with one.
|
|
347
|
+
*
|
|
348
|
+
* The rule for a fragment is always the same: **the scope is the element it was
|
|
349
|
+
* a child of**. For an attribute it is the element that carried the attribute;
|
|
350
|
+
* for a `di` fragment, what that fragment draws.
|
|
351
|
+
*/
|
|
352
|
+
export const BPMN_SCOPE = {
|
|
353
|
+
/** The element this payload rides on: the participant, the flow node, the flow. */
|
|
354
|
+
self: '@self',
|
|
355
|
+
/** Its `BPMNShape` or `BPMNEdge`. */
|
|
356
|
+
shape: '@shape',
|
|
357
|
+
/** The `process` behind a participant — the pool's other half. */
|
|
358
|
+
process: '@process',
|
|
359
|
+
/** The pool's `laneSet`. */
|
|
360
|
+
laneSet: '@laneSet',
|
|
361
|
+
/** The `collaboration`, whose residue rides on the first pool (D6). */
|
|
362
|
+
collaboration: '@collaboration',
|
|
363
|
+
/** `definitions` itself: its foreign attributes, its declarations, its roots. */
|
|
364
|
+
definitions: '@definitions',
|
|
365
|
+
};
|
|
366
|
+
/**
|
|
367
|
+
* What a `.bpmn` import left on this element, read defensively.
|
|
368
|
+
*
|
|
369
|
+
* The value came out of a Y.Map and is whatever a peer wrote — an older build,
|
|
370
|
+
* a hand-edited document, a paste from a board that met a different importer —
|
|
371
|
+
* so every member below is checked before it is believed. A payload that is not
|
|
372
|
+
* an object at all is simply not there.
|
|
373
|
+
*/
|
|
374
|
+
function carriedOf(model) {
|
|
375
|
+
const payload = model.interchange?.[BPMN_FORMAT_ID];
|
|
376
|
+
return payload !== null && typeof payload === 'object' ? payload : undefined;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Whether a carried attribute NAME can be written back as one.
|
|
380
|
+
*
|
|
381
|
+
* The serializer escapes attribute VALUES and interpolates NAMES, which is the
|
|
382
|
+
* only asymmetry in this file that matters for the shape of the document: a
|
|
383
|
+
* value can say anything and stay inside its quotes, and a name cannot. A
|
|
384
|
+
* "name" of `x="1"><task id="INJECTED" /><y z` closes the element it was on and
|
|
385
|
+
* opens two more, so the damage is not confined to the element carrying the bad
|
|
386
|
+
* payload — it unbalances the whole file.
|
|
387
|
+
*
|
|
388
|
+
* `interchange` is ordinary collaborative Y.Map data: any peer with write
|
|
389
|
+
* access, any hand-edited document, any paste from a board that met a different
|
|
390
|
+
* importer. So a name is written only if it IS a name — an NCName, or the
|
|
391
|
+
* `prefix:local` pair of them that every foreign attribute in a `.bpmn` wears.
|
|
392
|
+
* {@link isNcName} rejects `:` itself, so "one colon" needs no separate check.
|
|
393
|
+
*
|
|
394
|
+
* This also disposes of a degenerate payload shape for free: `attrs: []` puts
|
|
395
|
+
* `Object.entries` on an array, whose keys are `"0"`, `"1"` — not NCNames,
|
|
396
|
+
* because an XML name cannot open on a digit.
|
|
397
|
+
*/
|
|
398
|
+
function isAttrName(name) {
|
|
399
|
+
const colon = name.indexOf(':');
|
|
400
|
+
if (colon < 0)
|
|
401
|
+
return isNcName(name);
|
|
402
|
+
return isNcName(name.slice(0, colon)) && isNcName(name.slice(colon + 1));
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* The `xsd:ID` a carried fragment's ROOT element claims, if it claims one.
|
|
406
|
+
*
|
|
407
|
+
* Read off the opening tag by hand rather than by parsing, because this module
|
|
408
|
+
* has no parser and is not going to grow one (it is a pure function of the
|
|
409
|
+
* board — ADR 0012 P3). That is enough for what it is for: an id is what makes
|
|
410
|
+
* two carried fragments the same fragment, and the root's is the one a second
|
|
411
|
+
* copy would duplicate.
|
|
412
|
+
*
|
|
413
|
+
* The whole scan is quote-aware — `id=` is only recognised OUTSIDE a quoted
|
|
414
|
+
* value, so an attribute whose value contains ` id='X'` (a condition string,
|
|
415
|
+
* an XPath) cannot shadow the element's real id, and a value containing `>`
|
|
416
|
+
* does not truncate the tag. `id` is required to be preceded by whitespace,
|
|
417
|
+
* so `camunda:id` and `bpmnElement` are not mistaken for it.
|
|
418
|
+
*/
|
|
419
|
+
function carriedRootId(fragment) {
|
|
420
|
+
let quote;
|
|
421
|
+
let capturing = false;
|
|
422
|
+
let valueStart = 0;
|
|
423
|
+
for (let index = 0; index < fragment.length; index++) {
|
|
424
|
+
const char = fragment[index];
|
|
425
|
+
if (quote !== undefined) {
|
|
426
|
+
if (char !== quote)
|
|
427
|
+
continue;
|
|
428
|
+
if (capturing) {
|
|
429
|
+
const value = fragment.slice(valueStart, index);
|
|
430
|
+
return value.length > 0 ? value : undefined;
|
|
431
|
+
}
|
|
432
|
+
quote = undefined;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (char === '"' || char === "'") {
|
|
436
|
+
quote = char;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (char === '>')
|
|
440
|
+
break;
|
|
441
|
+
if (char === 'i' && /\s/.test(fragment[index - 1] ?? '')) {
|
|
442
|
+
const match = /^id\s*=\s*("|')/.exec(fragment.slice(index));
|
|
443
|
+
if (match) {
|
|
444
|
+
quote = match[1];
|
|
445
|
+
capturing = true;
|
|
446
|
+
valueStart = index + match[0].length;
|
|
447
|
+
index += match[0].length - 1;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return undefined;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* The carried half of one export: what has already been written back, and what
|
|
455
|
+
* this writer refused to write.
|
|
456
|
+
*
|
|
457
|
+
* Stateful, and created per call, because both of the invariants it holds are
|
|
458
|
+
* DOCUMENT-wide and neither is checkable from one element:
|
|
459
|
+
*
|
|
460
|
+
* - **an `xsd:ID` is unique across the file.** `interchange` is declared on the
|
|
461
|
+
* base element model precisely so a payload survives a paste (PR #73), so a
|
|
462
|
+
* pool imported from a `.bpmn` and then copy-pasted holds its carried
|
|
463
|
+
* boundary event, its lane's `documentation` and the document's residue
|
|
464
|
+
* TWICE. Written twice, they are duplicate ids, and a duplicate `xsd:ID` is
|
|
465
|
+
* the one thing no BPMN tool survives. First claim wins — it is the one
|
|
466
|
+
* already written and already referenced — and a SECOND, DIFFERENT fragment
|
|
467
|
+
* claiming the same id is a conflict the export reports rather than resolves;
|
|
468
|
+
* - **an attribute name is a name.** See {@link isAttrName}.
|
|
469
|
+
*
|
|
470
|
+
* The order in which the caller asks decides who wins, so the caller is the
|
|
471
|
+
* document's own emission order, which is deterministic in the board.
|
|
472
|
+
*/
|
|
473
|
+
class Carried {
|
|
474
|
+
constructor() {
|
|
475
|
+
/** The fragment already written for each id a carried root claimed. */
|
|
476
|
+
this.#byId = new Map();
|
|
477
|
+
/** Id-less DOCUMENT-scope fragments, of which two pools carry ONE. */
|
|
478
|
+
this.#shared = new Set();
|
|
479
|
+
/** Ids claimed a second time by a DIFFERENT fragment. */
|
|
480
|
+
this.conflictingIds = [];
|
|
481
|
+
/** Attribute names refused because they are not names. */
|
|
482
|
+
this.refusedNames = [];
|
|
483
|
+
}
|
|
484
|
+
/** The fragment already written for each id a carried root claimed. */
|
|
485
|
+
#byId;
|
|
486
|
+
/** Id-less DOCUMENT-scope fragments, of which two pools carry ONE. */
|
|
487
|
+
#shared;
|
|
488
|
+
/**
|
|
489
|
+
* The carried attributes of one or more scopes, merged left to right.
|
|
490
|
+
*
|
|
491
|
+
* Merged rather than concatenated because an attribute is a NAME on an
|
|
492
|
+
* element and there is only one of each: a later scope wins, which is what
|
|
493
|
+
* lets the caller order the scopes by how specific they are.
|
|
494
|
+
*
|
|
495
|
+
* NEITHER half of a carried attribute is trusted as markup. The value is
|
|
496
|
+
* re-escaped by the serializer like any other; the name is checked against
|
|
497
|
+
* {@link isAttrName}, because the serializer interpolates it and a name is
|
|
498
|
+
* the half that can escape its own element.
|
|
499
|
+
*/
|
|
500
|
+
attrs(payload, ...scopes) {
|
|
501
|
+
const out = {};
|
|
502
|
+
for (const scope of scopes) {
|
|
503
|
+
const bag = payload?.attrs?.[scope];
|
|
504
|
+
if (bag === null || typeof bag !== 'object')
|
|
505
|
+
continue;
|
|
506
|
+
for (const [name, value] of Object.entries(bag)) {
|
|
507
|
+
if (typeof value !== 'string')
|
|
508
|
+
continue;
|
|
509
|
+
// `__proto__` is a valid NCName and still not a key this writer will
|
|
510
|
+
// carry into an object it builds — the prototype-pollution exclusion
|
|
511
|
+
// PR #73 draws round its own verbatim write, drawn here too.
|
|
512
|
+
if (name === '__proto__')
|
|
513
|
+
continue;
|
|
514
|
+
if (!isAttrName(name)) {
|
|
515
|
+
this.refusedNames.push(name);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
out[name] = value;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return out;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* The fragments of this bag that have not been written back already.
|
|
525
|
+
*
|
|
526
|
+
* @param shared for matter that belongs to the DOCUMENT rather than to the
|
|
527
|
+
* element carrying it — D6's `definitions` and `collaboration` residue, and
|
|
528
|
+
* the plane's carried diagram elements. Two pools holding the same payload
|
|
529
|
+
* hold ONE of those, so an id-less one is deduplicated on its text as well.
|
|
530
|
+
* An id-less fragment at ELEMENT scope is never deduplicated: two tasks may
|
|
531
|
+
* each carry their own `<documentation>`, and two pools each their own
|
|
532
|
+
* `<BPMNLabel />`, and those are two fragments rather than one written twice.
|
|
533
|
+
*/
|
|
534
|
+
keep(fragments, shared = false) {
|
|
535
|
+
const out = [];
|
|
536
|
+
for (const node of fragments) {
|
|
537
|
+
if (!('fragment' in node)) {
|
|
538
|
+
out.push(node);
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
const id = carriedRootId(node.fragment);
|
|
542
|
+
if (id === undefined) {
|
|
543
|
+
if (shared) {
|
|
544
|
+
if (this.#shared.has(node.fragment))
|
|
545
|
+
continue;
|
|
546
|
+
this.#shared.add(node.fragment);
|
|
547
|
+
}
|
|
548
|
+
out.push(node);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
const written = this.#byId.get(id);
|
|
552
|
+
if (written !== undefined) {
|
|
553
|
+
// The same characters twice is one thing carried twice — a paste — and
|
|
554
|
+
// writing it once is the whole job. DIFFERENT characters under one id
|
|
555
|
+
// are two things that cannot both be in a file, and that is a sentence
|
|
556
|
+
// the person exporting is entitled to hear.
|
|
557
|
+
if (written !== node.fragment)
|
|
558
|
+
this.conflictingIds.push(id);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
this.#byId.set(id, node.fragment);
|
|
562
|
+
out.push(node);
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** Whether a stored fragment is a string this writer can put back. */
|
|
568
|
+
function isFragment(value) {
|
|
569
|
+
return typeof value === 'string' && value.trimStart().startsWith('<');
|
|
570
|
+
}
|
|
571
|
+
/** The carried child fragments of one or more scopes, in the stored order. */
|
|
572
|
+
function carriedFragments(bag, ...scopes) {
|
|
573
|
+
const out = [];
|
|
574
|
+
for (const scope of scopes) {
|
|
575
|
+
const list = bag?.[scope];
|
|
576
|
+
if (!Array.isArray(list))
|
|
577
|
+
continue;
|
|
578
|
+
for (const fragment of list) {
|
|
579
|
+
if (isFragment(fragment))
|
|
580
|
+
out.push({ fragment });
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return out;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Whole diagram elements the file drew for something this board does not: the
|
|
587
|
+
* `BPMNShape` of a carried boundary event, the `BPMNEdge` of its error path, an
|
|
588
|
+
* orphan shape naming an element the file never declared.
|
|
589
|
+
*
|
|
590
|
+
* They are the `di` entries whose scope is an ID rather than a role key —
|
|
591
|
+
* `@shape` means "unmodelled parts of MY OWN diagram element" and is written by
|
|
592
|
+
* the element itself. Keys are sorted so that two documents holding the same
|
|
593
|
+
* payload write the same plane, whatever order a Y.Map happened to hand them
|
|
594
|
+
* back in.
|
|
595
|
+
*/
|
|
596
|
+
function carriedPlaneDi(payload) {
|
|
597
|
+
const bag = payload?.di;
|
|
598
|
+
if (bag === null || typeof bag !== 'object')
|
|
599
|
+
return [];
|
|
600
|
+
const ids = Object.keys(bag)
|
|
601
|
+
.filter(scope => !scope.startsWith('@'))
|
|
602
|
+
.sort();
|
|
603
|
+
return carriedFragments(bag, ...ids);
|
|
604
|
+
}
|
|
605
|
+
/* ── Where a carried fragment is XSD-legal ────────────────────────────── */
|
|
606
|
+
/**
|
|
607
|
+
* The qualified name a fragment opens on, reduced to its local part.
|
|
608
|
+
*
|
|
609
|
+
* A fragment carries the FILE's prefix (`bpmn2:`, `semantic:`, none at all) and
|
|
610
|
+
* this writer cannot resolve it — the declaration it was written under is on
|
|
611
|
+
* `definitions`, not on the fragment. The local name is what the XSD's own
|
|
612
|
+
* sequences are written in, so it is what the slot tables below read, and a
|
|
613
|
+
* name none of them knows falls to the open slot rather than to a guess.
|
|
614
|
+
*/
|
|
615
|
+
function localNameOf(fragment) {
|
|
616
|
+
const opened = /^\s*<\s*([^\s/>]+)/.exec(fragment);
|
|
617
|
+
if (!opened)
|
|
618
|
+
return '';
|
|
619
|
+
const qualified = opened[1];
|
|
620
|
+
const colon = qualified.indexOf(':');
|
|
621
|
+
return colon < 0 ? qualified : qualified.slice(colon + 1);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* `tProcess`, before its `flowElement*` slot.
|
|
625
|
+
*
|
|
626
|
+
* The XSD sequence in full is `documentation* → extensionElements? →
|
|
627
|
+
* supportedInterfaceRef* → ioSpecification? → ioBinding* → auditing? →
|
|
628
|
+
* monitoring? → processRole* → property* → laneSet* → flowElement* →
|
|
629
|
+
* artifact* → resourceRole* → correlationSubscription* → supports*` — the
|
|
630
|
+
* first five inherited from `tBaseElement` and `tCallableElement`, which in an
|
|
631
|
+
* `xsd:extension` come FIRST. A carried `<auditing>` written after the laneSet
|
|
632
|
+
* this exporter emits is a document a validating parser rejects, so the head is
|
|
633
|
+
* a table and not a comment.
|
|
634
|
+
*/
|
|
635
|
+
const PROCESS_HEAD = new Set([
|
|
636
|
+
'documentation',
|
|
637
|
+
'extensionElements',
|
|
638
|
+
'supportedInterfaceRef',
|
|
639
|
+
'ioSpecification',
|
|
640
|
+
'ioBinding',
|
|
641
|
+
'auditing',
|
|
642
|
+
'monitoring',
|
|
643
|
+
'processRole',
|
|
644
|
+
'property',
|
|
645
|
+
'laneSet',
|
|
646
|
+
]);
|
|
647
|
+
/** `tProcess` and `tCollaboration` both end their drawable half on this slot. */
|
|
648
|
+
const ARTIFACT_LOCALS = new Set(['association', 'group', 'textAnnotation']);
|
|
649
|
+
/**
|
|
650
|
+
* `tProcess`, after its `artifact*` slot.
|
|
651
|
+
*
|
|
652
|
+
* `resourceRole` is the abstract head of a substitution group, so the three
|
|
653
|
+
* names a file actually writes are here beside it (Table 10.142).
|
|
654
|
+
*
|
|
655
|
+
* Both spellings of `correlationSub(s)cription` are here because the NORMATIVE
|
|
656
|
+
* schema misspells it: `tProcess` really does declare
|
|
657
|
+
* `correlationSubcription`, without the second `s` (Table 10.136, ISO p. 311).
|
|
658
|
+
* A file written against the published XSD carries the typo and a file written
|
|
659
|
+
* against the prose carries the correction, and both belong in this slot.
|
|
660
|
+
*/
|
|
661
|
+
const PROCESS_TAIL = new Set([
|
|
662
|
+
'resourceRole',
|
|
663
|
+
'performer',
|
|
664
|
+
'humanPerformer',
|
|
665
|
+
'potentialOwner',
|
|
666
|
+
'correlationSubscription',
|
|
667
|
+
'correlationSubcription',
|
|
668
|
+
'supports',
|
|
669
|
+
]);
|
|
670
|
+
/** `tCollaboration`, before its `participant*` slot. */
|
|
671
|
+
const COLLABORATION_HEAD = new Set([
|
|
672
|
+
'documentation',
|
|
673
|
+
'extensionElements',
|
|
674
|
+
'choreography',
|
|
675
|
+
]);
|
|
676
|
+
function processSlotOf(fragment) {
|
|
677
|
+
const local = localNameOf(fragment);
|
|
678
|
+
if (PROCESS_HEAD.has(local))
|
|
679
|
+
return 'head';
|
|
680
|
+
if (ARTIFACT_LOCALS.has(local))
|
|
681
|
+
return 'artifact';
|
|
682
|
+
if (PROCESS_TAIL.has(local))
|
|
683
|
+
return 'tail';
|
|
684
|
+
return 'flowElement';
|
|
685
|
+
}
|
|
686
|
+
function collaborationSlotOf(fragment) {
|
|
687
|
+
const local = localNameOf(fragment);
|
|
688
|
+
if (COLLABORATION_HEAD.has(local))
|
|
689
|
+
return 'head';
|
|
690
|
+
if (local === 'messageFlow')
|
|
691
|
+
return 'messageFlow';
|
|
692
|
+
if (ARTIFACT_LOCALS.has(local))
|
|
693
|
+
return 'artifact';
|
|
694
|
+
return 'tail';
|
|
695
|
+
}
|
|
696
|
+
function definitionsSlotOf(fragment) {
|
|
697
|
+
const local = localNameOf(fragment);
|
|
698
|
+
if (local === 'extension')
|
|
699
|
+
return 'extension';
|
|
700
|
+
if (local === 'relationship')
|
|
701
|
+
return 'relationship';
|
|
702
|
+
return 'root';
|
|
703
|
+
}
|
|
704
|
+
/** Fragments sorted into their slots, keeping the stored order inside each. */
|
|
705
|
+
function bySlot(fragments, slotOf) {
|
|
706
|
+
const out = {};
|
|
707
|
+
for (const node of fragments) {
|
|
708
|
+
if (!('fragment' in node))
|
|
709
|
+
continue;
|
|
710
|
+
const slot = slotOf(node.fragment);
|
|
711
|
+
(out[slot] ??= []).push(node);
|
|
712
|
+
}
|
|
713
|
+
return out;
|
|
714
|
+
}
|
|
715
|
+
/** One slot of a sorted bag, which is empty far more often than not. */
|
|
716
|
+
function slot(bag, name) {
|
|
717
|
+
return bag[name] ?? [];
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Mints document-unique NCNames, and remembers what it minted.
|
|
721
|
+
*
|
|
722
|
+
* Uniqueness is settled by a counting suffix rather than by a hash: `Task_x`
|
|
723
|
+
* and `Task_x_2` are both readable in bpmn.io's properties panel, which is
|
|
724
|
+
* where a human will actually meet them during the recette.
|
|
725
|
+
*/
|
|
726
|
+
class IdMinter {
|
|
727
|
+
constructor() {
|
|
728
|
+
this.#taken = new Set();
|
|
729
|
+
/** How many ids an import gave us that could not be given back (D3). */
|
|
730
|
+
this.substituted = 0;
|
|
731
|
+
}
|
|
732
|
+
#taken;
|
|
733
|
+
/**
|
|
734
|
+
* The id the FILE gave this element, when it can still be given back —
|
|
735
|
+
* otherwise a freshly minted one, and the substitution is counted.
|
|
736
|
+
*
|
|
737
|
+
* Two things can make a recorded id unusable, and neither is recoverable by
|
|
738
|
+
* guessing: it may not be an NCName (a hand-edited file, another format's
|
|
739
|
+
* id), or the document being written may already have claimed it. The
|
|
740
|
+
* alternative — inverting {@link toNcName} to reconstruct what we think we
|
|
741
|
+
* sent — is exactly what D3 rejects: `_7abc` has two preimages.
|
|
742
|
+
*/
|
|
743
|
+
given(given, prefix, raw) {
|
|
744
|
+
if (given !== undefined && isNcName(given) && !this.#taken.has(given)) {
|
|
745
|
+
this.#taken.add(given);
|
|
746
|
+
return given;
|
|
747
|
+
}
|
|
748
|
+
if (given !== undefined)
|
|
749
|
+
this.substituted++;
|
|
750
|
+
return this.mint(prefix, raw);
|
|
751
|
+
}
|
|
752
|
+
mint(prefix, raw) {
|
|
753
|
+
const base = toNcName(prefix ? `${prefix}_${raw}` : raw);
|
|
754
|
+
if (!this.#taken.has(base)) {
|
|
755
|
+
this.#taken.add(base);
|
|
756
|
+
return base;
|
|
757
|
+
}
|
|
758
|
+
let n = 2;
|
|
759
|
+
while (this.#taken.has(`${base}_${n}`))
|
|
760
|
+
n++;
|
|
761
|
+
const unique = `${base}_${n}`;
|
|
762
|
+
this.#taken.add(unique);
|
|
763
|
+
return unique;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/* ── The plan ─────────────────────────────────────────────────────────── */
|
|
767
|
+
/**
|
|
768
|
+
* Which element an artefact is written INSIDE.
|
|
769
|
+
*
|
|
770
|
+
* A number indexes `processes`; {@link COLLABORATION} is the collaboration
|
|
771
|
+
* itself, which `tCollaboration` allows to carry artifacts directly
|
|
772
|
+
* (`participant* → messageFlow* → artifact*`).
|
|
773
|
+
*
|
|
774
|
+
* The distinction is not cosmetic and the live recette is what found it. A
|
|
775
|
+
* process with no participant cannot be DRAWN on a collaboration plane — there
|
|
776
|
+
* is no shape for it — so bpmn-js imports its contents and then renders
|
|
777
|
+
* nothing at all. An annotation dropped beside the pools used to disappear on
|
|
778
|
+
* import; as a child of the collaboration it is drawn where it was put.
|
|
779
|
+
*/
|
|
780
|
+
const COLLABORATION = -1;
|
|
781
|
+
/**
|
|
782
|
+
* Each lane of a pool as an ABSOLUTE rectangle, in the order they are painted.
|
|
783
|
+
*
|
|
784
|
+
* Read off `backgroundInstanceZones` rather than off `pool.lanes` directly, for
|
|
785
|
+
* the reason {@link bpmnLaneOf} gives and one more:
|
|
786
|
+
*
|
|
787
|
+
* - the primitive is what NORMALISES the weights into rectangles and DROPS the
|
|
788
|
+
* rows a user's typo made unusable, so a second reading of the raw prop would
|
|
789
|
+
* place a `BPMNShape` on a band the pool does not paint;
|
|
790
|
+
* - and it makes the `laneSet` and the DI agree by construction — the lanes
|
|
791
|
+
* this returns are the lanes written to both, so a dropped row is absent from
|
|
792
|
+
* the file rather than present as a lane with no shape and no members.
|
|
793
|
+
*
|
|
794
|
+
* ## Where the band starts, and the 30-unit convention
|
|
795
|
+
*
|
|
796
|
+
* The x origin is the PLOT's, which is the pool's frame plus its participant
|
|
797
|
+
* name band (`POOL_BAND_WIDTH`, 28) — literally where the lane is drawn on the
|
|
798
|
+
* canvas. bpmn-js lays its own lanes out 30 units right of the participant, so
|
|
799
|
+
* the two conventions agree to within two units and a file written here reopens
|
|
800
|
+
* looking like a file bpmn.io wrote. Deriving it from the declaration rather
|
|
801
|
+
* than hard-coding the foreign 30 is deliberate: the DI must describe the
|
|
802
|
+
* picture the author is looking at, and if the band width ever changes the
|
|
803
|
+
* export follows it without anybody remembering to.
|
|
804
|
+
*/
|
|
805
|
+
function poolLaneBands(pool) {
|
|
806
|
+
const rows = Array.isArray(pool.lanes) ? pool.lanes : [];
|
|
807
|
+
if (rows.length === 0)
|
|
808
|
+
return [];
|
|
809
|
+
const frame = pool.elementBound;
|
|
810
|
+
const plot = backgroundPlot(BPMN_POOL_BACKGROUND, frame.w, frame.h);
|
|
811
|
+
if (!(plot.width > 0) || !(plot.height > 0))
|
|
812
|
+
return [];
|
|
813
|
+
const zones = backgroundInstanceZones(BPMN_POOL_BACKGROUND, pool);
|
|
814
|
+
const bands = [];
|
|
815
|
+
for (const zone of zones) {
|
|
816
|
+
const row = rows.find(lane => zone.id === `${LANE_PREFIX}:${lane.id}`);
|
|
817
|
+
if (!row)
|
|
818
|
+
continue;
|
|
819
|
+
bands.push({
|
|
820
|
+
lane: row,
|
|
821
|
+
// Minted by the caller, which owns the document-wide id space.
|
|
822
|
+
id: '',
|
|
823
|
+
bound: {
|
|
824
|
+
x: frame.x + plot.x0 + zone.rect.x * plot.width,
|
|
825
|
+
y: frame.y + plot.y0 + zone.rect.y * plot.height,
|
|
826
|
+
w: zone.rect.w * plot.width,
|
|
827
|
+
h: zone.rect.h * plot.height,
|
|
828
|
+
},
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
return bands;
|
|
832
|
+
}
|
|
833
|
+
/** The text an element carries, as a plain trimmed string. */
|
|
834
|
+
function labelOf(value) {
|
|
835
|
+
if (value === null || value === undefined)
|
|
836
|
+
return '';
|
|
837
|
+
return String(value).trim();
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Both ends of a connector, when both are attached to something.
|
|
841
|
+
*
|
|
842
|
+
* A dangling endpoint is the one case the spec cannot express at all:
|
|
843
|
+
* `sourceRef` and `targetRef` are REQUIRED on every flow, so an arrow with a
|
|
844
|
+
* free end has nothing to be written as. It is dropped, silently and on
|
|
845
|
+
* purpose — the picture keeps it, the interchange file cannot carry it, and
|
|
846
|
+
* inventing an anchor would be the export asserting a link the author never
|
|
847
|
+
* drew.
|
|
848
|
+
*/
|
|
849
|
+
function endsOf(connector) {
|
|
850
|
+
const source = connector.source?.id;
|
|
851
|
+
const target = connector.target?.id;
|
|
852
|
+
if (!source || !target)
|
|
853
|
+
return null;
|
|
854
|
+
return { source, target };
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Where an edge is drawn, in surface coordinates.
|
|
858
|
+
*
|
|
859
|
+
* `absolutePath` is `@local()` — it is computed by the connector manager while
|
|
860
|
+
* the board is on screen — so a document that has been loaded but never
|
|
861
|
+
* rendered has none. When it is there it is the truth (it is the polyline the
|
|
862
|
+
* user is looking at, elbows and all); when it is not, the straight line
|
|
863
|
+
* between the two centres is the honest fallback, and it is what every tool
|
|
864
|
+
* draws for a flow it has no routing for. Two waypoints is also the minimum
|
|
865
|
+
* `di:Edge` accepts.
|
|
866
|
+
*/
|
|
867
|
+
function waypointsOf(connector, source, target) {
|
|
868
|
+
const path = connector.absolutePath;
|
|
869
|
+
if (Array.isArray(path) && path.length >= 2) {
|
|
870
|
+
return path.map(point => [point[0], point[1]]);
|
|
871
|
+
}
|
|
872
|
+
return [
|
|
873
|
+
[source.bound.x + source.bound.w / 2, source.bound.y + source.bound.h / 2],
|
|
874
|
+
[target.bound.x + target.bound.w / 2, target.bound.y + target.bound.h / 2],
|
|
875
|
+
];
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Which element a flow is written INSIDE.
|
|
879
|
+
*
|
|
880
|
+
* Three rules, one per kind of edge, and the middle one is the whole of what
|
|
881
|
+
* the live recette taught:
|
|
882
|
+
*
|
|
883
|
+
* - a **message flow** is the collaboration's by definition (`tCollaboration`),
|
|
884
|
+
* and there is no message flow without one — the caller drops it first;
|
|
885
|
+
* - an **association** goes wherever BOTH its ends are, when they agree. When
|
|
886
|
+
* they do not — an annotation beside the pools tied to a task inside one —
|
|
887
|
+
* it belongs to neither scope, and the collaboration is the common ancestor
|
|
888
|
+
* that can legally hold it. Filing it with its source instead would put it in
|
|
889
|
+
* a process that cannot draw it, or in a pool the other end is not in;
|
|
890
|
+
* - a **sequence flow** is a `flowElement` and can only ever be a process's, so
|
|
891
|
+
* it is filed with its SOURCE. A flow that crosses two pools is invalid BPMN
|
|
892
|
+
* and a picture the author nevertheless drew: the file says so, and the
|
|
893
|
+
* validation rules are what tell them about it. Should its source somehow be
|
|
894
|
+
* an artifact on the collaboration — an arrow drawn out of an annotation —
|
|
895
|
+
* it falls back to the participant-less process, because there is nowhere
|
|
896
|
+
* else in the format for it.
|
|
897
|
+
*/
|
|
898
|
+
function edgeScope(element, source, target, ctx) {
|
|
899
|
+
if (element === 'messageFlow')
|
|
900
|
+
return COLLABORATION;
|
|
901
|
+
if (element === 'association') {
|
|
902
|
+
if (source.scope === target.scope)
|
|
903
|
+
return source.scope;
|
|
904
|
+
return ctx.hasCollaboration ? COLLABORATION : source.scope;
|
|
905
|
+
}
|
|
906
|
+
return source.scope === COLLABORATION
|
|
907
|
+
? ctx.orphanProcessIndex()
|
|
908
|
+
: source.scope;
|
|
909
|
+
}
|
|
910
|
+
/* ── The serializer ───────────────────────────────────────────────────── */
|
|
911
|
+
/**
|
|
912
|
+
* Serialize a board as a BPMN 2.0 XML interchange document.
|
|
913
|
+
*
|
|
914
|
+
* ## The shape of the document, and what decides it
|
|
915
|
+
*
|
|
916
|
+
* One `definitions`, always. Then:
|
|
917
|
+
*
|
|
918
|
+
* - **at least one pool** — a `collaboration` holding one `participant` per
|
|
919
|
+
* pool, one `process` per pool, and the message flows (which are the
|
|
920
|
+
* collaboration's, never a process's). Things drawn OUTSIDE every pool split
|
|
921
|
+
* in two: ARTIFACTS (annotation, group, and the associations that tie them to
|
|
922
|
+
* anything) become children of the collaboration itself, where
|
|
923
|
+
* `tCollaboration` allows them and where bpmn-js draws them; FLOW OBJECTS get
|
|
924
|
+
* ONE extra participant-less process, and only if there are any. See
|
|
925
|
+
* {@link COLLABORATION} for what the live recette found out about the
|
|
926
|
+
* difference, and the note on the orphan process for what it still cannot fix;
|
|
927
|
+
* - **no pool at all** — a single `process` and no collaboration, which is what
|
|
928
|
+
* a process drawn without swimlanes IS. The `BPMNPlane` then points at that
|
|
929
|
+
* process; with a collaboration it must point at the collaboration, or most
|
|
930
|
+
* tools draw the flow and none of the pools (spec §12.3.2).
|
|
931
|
+
*
|
|
932
|
+
* Attribution is {@link bpmnPoolOf} and {@link bpmnLaneOf} — the CENTRE against
|
|
933
|
+
* the pool's PLOT, containment only, no nearest-pool fallback. Deliberately the
|
|
934
|
+
* same arithmetic the audit and the validation rules read, so a task the audit
|
|
935
|
+
* reports in "Back office" is in the `lane` named "Back office" here.
|
|
936
|
+
*/
|
|
937
|
+
export function exportBpmnXml(board, options = {}) {
|
|
938
|
+
return exportBpmnXmlWithWarnings(board, options).text;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* The same serialization, with the loss channel attached — see
|
|
942
|
+
* {@link BpmnExportOutcome}.
|
|
943
|
+
*
|
|
944
|
+
* {@link exportBpmnXml} is the thin wrapper over it, kept because a caller that
|
|
945
|
+
* only wants the bytes should not have to reach past a report to get them, and
|
|
946
|
+
* because #149's forty-six tests and the live integration spec pin that
|
|
947
|
+
* signature. The interchange capability calls THIS one.
|
|
948
|
+
*/
|
|
949
|
+
export function exportBpmnXmlWithWarnings(board, options = {}) {
|
|
950
|
+
const minter = new IdMinter();
|
|
951
|
+
const pools = board.pools;
|
|
952
|
+
/* ── Processes and participants ──────────────────────────────────── */
|
|
953
|
+
/**
|
|
954
|
+
* Is this board still the poolless one an import minted a pool for?
|
|
955
|
+
*
|
|
956
|
+
* A pool that stands for a bare `process` (`interchange.bpmn.element =
|
|
957
|
+
* 'process'`, ADR 0012 D6) is what tells this writer to give the poolless
|
|
958
|
+
* form back rather than invent a collaboration the author never drew — but
|
|
959
|
+
* ONLY while it is the whole board. Draw a second pool beside it and the
|
|
960
|
+
* author has made a collaboration: from then on it is a participant like any
|
|
961
|
+
* other, because the alternative is a pool they can see and drag that has no
|
|
962
|
+
* shape in the file and is drawn by nothing that opens it.
|
|
963
|
+
*/
|
|
964
|
+
const givesBackPoollessForm = pools.length === 1 && carriedBpmnElement(pools[0]) === 'process';
|
|
965
|
+
const processes = pools.map(pool => {
|
|
966
|
+
const wasBareProcess = givesBackPoollessForm && carriedBpmnElement(pool) === 'process';
|
|
967
|
+
const given = carriedBpmnId(pool);
|
|
968
|
+
// The pool's OWN element is the participant — it is what a `BPMNShape`
|
|
969
|
+
// points at and what a message flow can reference — so that is the id the
|
|
970
|
+
// file gets to keep. Everything the pool drags along is minted FROM it, so
|
|
971
|
+
// that an export after an import lands on the same ids as the export
|
|
972
|
+
// before it: the process is derived from the participant either way.
|
|
973
|
+
const participantId = wasBareProcess
|
|
974
|
+
? undefined
|
|
975
|
+
: minter.given(given, 'Participant', pool.id);
|
|
976
|
+
const id = wasBareProcess
|
|
977
|
+
? minter.given(given, 'Process', pool.id)
|
|
978
|
+
: minter.mint('Process', participantId);
|
|
979
|
+
// The bands the pool actually PAINTS, not the raw prop — see
|
|
980
|
+
// `poolLaneBands`. A lane's stored id is what the file called it (an import
|
|
981
|
+
// records it verbatim), so it goes in unprefixed: `id` is document-unique
|
|
982
|
+
// across the WHOLE file and the minter is what keeps it so, but a lane that
|
|
983
|
+
// arrived as `Lane_3` must leave as `Lane_3` and not as `Lane_Lane_3`.
|
|
984
|
+
const lanes = poolLaneBands(pool).map(band => ({
|
|
985
|
+
...band,
|
|
986
|
+
id: minter.mint('', band.lane.id),
|
|
987
|
+
}));
|
|
988
|
+
return {
|
|
989
|
+
pool,
|
|
990
|
+
id,
|
|
991
|
+
participantId,
|
|
992
|
+
name: labelOf(pool.name),
|
|
993
|
+
laneSetId: lanes.length > 0
|
|
994
|
+
? minter.mint('LaneSet', participantId ?? id)
|
|
995
|
+
: undefined,
|
|
996
|
+
lanes,
|
|
997
|
+
payload: carriedOf(pool),
|
|
998
|
+
selfIsProcess: carriedBpmnElement(pool) === 'process',
|
|
999
|
+
};
|
|
1000
|
+
});
|
|
1001
|
+
const hasCollaboration = processes.some(process => process.participantId !== undefined);
|
|
1002
|
+
/**
|
|
1003
|
+
* The participant-less process.
|
|
1004
|
+
*
|
|
1005
|
+
* Minted on first use where there ARE pools — an empty extra process in a
|
|
1006
|
+
* collaboration is a participant a reader will look for on the canvas and not
|
|
1007
|
+
* find. Minted eagerly where there are none, because then it is not an extra
|
|
1008
|
+
* anything: it is the process, and a `definitions` with no process at all is
|
|
1009
|
+
* a document about nothing (a process with zero flow elements is legal, spec
|
|
1010
|
+
* `tProcess`; a board that is genuinely empty exports as exactly that).
|
|
1011
|
+
*
|
|
1012
|
+
* ## What it cannot fix, stated rather than hidden
|
|
1013
|
+
*
|
|
1014
|
+
* Inside a collaboration this process has no `participant`, so the plane has
|
|
1015
|
+
* no shape to draw it in and bpmn-js imports its flow objects without
|
|
1016
|
+
* rendering them. That is a real limit and it is deliberate: the alternatives
|
|
1017
|
+
* are to invent a pool the author never drew, or to drop the elements
|
|
1018
|
+
* outright, and both of them are the export saying something the board does
|
|
1019
|
+
* not. The elements are in the file, correctly, for any tool that reads the
|
|
1020
|
+
* model; the fix on the canvas is to draw them in a pool.
|
|
1021
|
+
*
|
|
1022
|
+
* Artifacts do NOT come here when there is a collaboration — they have a
|
|
1023
|
+
* legal home on the collaboration itself, and they are drawn.
|
|
1024
|
+
*/
|
|
1025
|
+
// …unless one is already there: a pool an import minted for a bare `process`
|
|
1026
|
+
// IS the participant-less process, and minting a second one beside it would
|
|
1027
|
+
// write a `definitions` with two processes where the file had one.
|
|
1028
|
+
let orphanProcess = processes.findIndex(process => process.pool !== null && process.participantId === undefined);
|
|
1029
|
+
const orphanProcessIndex = () => {
|
|
1030
|
+
if (orphanProcess < 0) {
|
|
1031
|
+
orphanProcess = processes.length;
|
|
1032
|
+
processes.push({
|
|
1033
|
+
pool: null,
|
|
1034
|
+
id: minter.mint('Process', hasCollaboration ? 'unassigned' : 'board'),
|
|
1035
|
+
name: '',
|
|
1036
|
+
lanes: [],
|
|
1037
|
+
selfIsProcess: false,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
return orphanProcess;
|
|
1041
|
+
};
|
|
1042
|
+
if (!hasCollaboration)
|
|
1043
|
+
orphanProcessIndex();
|
|
1044
|
+
/* ── Nodes ───────────────────────────────────────────────────────── */
|
|
1045
|
+
const planned = [];
|
|
1046
|
+
const byModelId = new Map();
|
|
1047
|
+
for (const model of board.nodes) {
|
|
1048
|
+
const mapping = BPMN_XML_OF_KIND[model.kind];
|
|
1049
|
+
// A kind the pack does not know: impossible by the type, and a document
|
|
1050
|
+
// written by a newer build could still carry one. Silence beats a crash.
|
|
1051
|
+
if (!mapping)
|
|
1052
|
+
continue;
|
|
1053
|
+
const bound = model.elementBound;
|
|
1054
|
+
const pool = bpmnPoolOf(pools, bound);
|
|
1055
|
+
// In a pool: that pool's process. Outside every pool: an ARTIFACT goes on
|
|
1056
|
+
// the collaboration, where it is both legal and drawable; anything else
|
|
1057
|
+
// goes to the participant-less process, which on a poolless board is
|
|
1058
|
+
// simply THE process.
|
|
1059
|
+
const scope = pool !== null
|
|
1060
|
+
? processes.findIndex(entry => entry.pool === pool)
|
|
1061
|
+
: hasCollaboration && mapping.slot === 'artifact'
|
|
1062
|
+
? COLLABORATION
|
|
1063
|
+
: orphanProcessIndex();
|
|
1064
|
+
const name = labelOf(model.text);
|
|
1065
|
+
const node = {
|
|
1066
|
+
model,
|
|
1067
|
+
mapping,
|
|
1068
|
+
id: minter.given(carriedBpmnId(model), '', model.id),
|
|
1069
|
+
name,
|
|
1070
|
+
bound,
|
|
1071
|
+
scope,
|
|
1072
|
+
lane: pool ? bpmnLaneOf(pool, bound) : null,
|
|
1073
|
+
payload: carriedOf(model),
|
|
1074
|
+
};
|
|
1075
|
+
// A data object needs the `dataObject` its reference points at, and a
|
|
1076
|
+
// labelled group needs somewhere for its label to live. Both are minted
|
|
1077
|
+
// from the id this artefact SETTLED on rather than from its surface id, so
|
|
1078
|
+
// that an artefact whose id came out of a file drags the same satellites
|
|
1079
|
+
// whichever export writes it — see {@link IdMinter.given}.
|
|
1080
|
+
if (model.kind === 'dataObject') {
|
|
1081
|
+
node.dataObjectId = minter.mint('DataObject', node.id);
|
|
1082
|
+
}
|
|
1083
|
+
if (model.kind === 'group' && name) {
|
|
1084
|
+
node.categoryId = minter.mint('Category', node.id);
|
|
1085
|
+
node.categoryValueId = minter.mint('CategoryValue', node.id);
|
|
1086
|
+
}
|
|
1087
|
+
planned.push(node);
|
|
1088
|
+
byModelId.set(model.id, node);
|
|
1089
|
+
}
|
|
1090
|
+
/* ── Edges ───────────────────────────────────────────────────────── */
|
|
1091
|
+
const edges = [];
|
|
1092
|
+
/** Typed arrows the format had no way to write down. See below for each. */
|
|
1093
|
+
let unwritableEdges = 0;
|
|
1094
|
+
/** Message flows dropped for want of a collaboration. */
|
|
1095
|
+
let droppedMessageFlows = 0;
|
|
1096
|
+
for (const connector of board.connectors) {
|
|
1097
|
+
const element = EDGE_ELEMENT[String(connector.role ?? '')];
|
|
1098
|
+
// A NEUTRAL connector states nothing (`docs/adr/0010`): not a flow. NOT
|
|
1099
|
+
// counted as a loss — there was nothing to lose, which is the whole point
|
|
1100
|
+
// of the neutral state.
|
|
1101
|
+
if (!element)
|
|
1102
|
+
continue;
|
|
1103
|
+
const ends = endsOf(connector);
|
|
1104
|
+
// A free end. `sourceRef` and `targetRef` are required on every flow, so
|
|
1105
|
+
// there is no such thing as half an arrow in this format.
|
|
1106
|
+
if (!ends) {
|
|
1107
|
+
unwritableEdges++;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const source = byModelId.get(ends.source);
|
|
1111
|
+
const target = byModelId.get(ends.target);
|
|
1112
|
+
// An end attached to something that is not a BPMN artefact — a sticky note,
|
|
1113
|
+
// a plain rectangle — has no id in this document to point at.
|
|
1114
|
+
if (!source || !target) {
|
|
1115
|
+
unwritableEdges++;
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
// A message flow belongs to the collaboration, and there is no
|
|
1119
|
+
// collaboration without a pool. On a poolless board it has nowhere in the
|
|
1120
|
+
// interchange format to go, so it is dropped rather than demoted to a
|
|
1121
|
+
// sequence flow, which would say something else entirely.
|
|
1122
|
+
if (element === 'messageFlow' && !hasCollaboration) {
|
|
1123
|
+
droppedMessageFlows++;
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
edges.push({
|
|
1127
|
+
model: connector,
|
|
1128
|
+
element,
|
|
1129
|
+
id: minter.given(carriedBpmnId(connector), 'Flow', connector.id),
|
|
1130
|
+
name: labelOf(connector.text),
|
|
1131
|
+
source,
|
|
1132
|
+
target,
|
|
1133
|
+
scope: edgeScope(element, source, target, {
|
|
1134
|
+
hasCollaboration,
|
|
1135
|
+
orphanProcessIndex,
|
|
1136
|
+
}),
|
|
1137
|
+
waypoints: waypointsOf(connector, source, target),
|
|
1138
|
+
payload: carriedOf(connector),
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
/* ── The plane origin ────────────────────────────────────────────── */
|
|
1142
|
+
// Spec §12.3: DI coordinates are relative to the plane's origin, and "the
|
|
1143
|
+
// union of all the nested elements' bounds is deemed to be located at the
|
|
1144
|
+
// plane's origin point" — which a canvas that lets a user drag left of zero
|
|
1145
|
+
// routinely violates. Everything is translated so the top-left of the whole
|
|
1146
|
+
// drawing sits at (0, 0); the shape of the picture is untouched, and a tool
|
|
1147
|
+
// that clamps at zero no longer folds half the process onto its own edge.
|
|
1148
|
+
let minX = Number.POSITIVE_INFINITY;
|
|
1149
|
+
let minY = Number.POSITIVE_INFINITY;
|
|
1150
|
+
const observe = (x, y) => {
|
|
1151
|
+
if (Number.isFinite(x) && x < minX)
|
|
1152
|
+
minX = x;
|
|
1153
|
+
if (Number.isFinite(y) && y < minY)
|
|
1154
|
+
minY = y;
|
|
1155
|
+
};
|
|
1156
|
+
for (const process of processes) {
|
|
1157
|
+
if (!process.pool)
|
|
1158
|
+
continue;
|
|
1159
|
+
const bound = process.pool.elementBound;
|
|
1160
|
+
observe(bound.x, bound.y);
|
|
1161
|
+
}
|
|
1162
|
+
for (const node of planned)
|
|
1163
|
+
observe(node.bound.x, node.bound.y);
|
|
1164
|
+
for (const edge of edges) {
|
|
1165
|
+
for (const [x, y] of edge.waypoints)
|
|
1166
|
+
observe(x, y);
|
|
1167
|
+
}
|
|
1168
|
+
const dx = Number.isFinite(minX) ? -minX : 0;
|
|
1169
|
+
const dy = Number.isFinite(minY) ? -minY : 0;
|
|
1170
|
+
/* ── What the file gave us back ──────────────────────────────────── */
|
|
1171
|
+
/**
|
|
1172
|
+
* The carried half of this export: what has been written back already, and
|
|
1173
|
+
* what was refused. See {@link Carried} for the two invariants it holds.
|
|
1174
|
+
*
|
|
1175
|
+
* One per call, consulted by every re-emission site in the order the document
|
|
1176
|
+
* is built — which is why it is created here and threaded down rather than
|
|
1177
|
+
* applied at each site independently: a pool duplicated by a copy-paste puts
|
|
1178
|
+
* the SAME carried element in two different processes, and no site can see
|
|
1179
|
+
* that on its own.
|
|
1180
|
+
*/
|
|
1181
|
+
const carried = new Carried();
|
|
1182
|
+
/**
|
|
1183
|
+
* The document's own residue, gathered from every pool rather than from one.
|
|
1184
|
+
*
|
|
1185
|
+
* D6 has an import file `definitions`- and `collaboration`-scope matter on
|
|
1186
|
+
* the FIRST pool, but "first" is the reader's document order and this writer
|
|
1187
|
+
* is handed the board's — and a pool can be copy-pasted, deleted, or drawn
|
|
1188
|
+
* before the imported one. So every pool is asked, and {@link Carried.keep}
|
|
1189
|
+
* is what keeps a duplicated payload from writing the file's roots twice.
|
|
1190
|
+
*/
|
|
1191
|
+
const poolPayloads = processes
|
|
1192
|
+
.filter(process => process.pool !== null)
|
|
1193
|
+
.map(process => process.payload);
|
|
1194
|
+
/**
|
|
1195
|
+
* One document-scope bag of attributes, merged across the pools that carry
|
|
1196
|
+
* it, and the conflicts that merge resolved.
|
|
1197
|
+
*
|
|
1198
|
+
* Computed ONCE per scope rather than on each read, because the merge is
|
|
1199
|
+
* last-wins and a silent last-wins across two pools imported from two
|
|
1200
|
+
* different files is one of them being rebound with nobody told. Last-wins
|
|
1201
|
+
* stays — there is one attribute of each name on `definitions` and something
|
|
1202
|
+
* has to be written — and the disagreement goes in the report.
|
|
1203
|
+
*/
|
|
1204
|
+
const documentAttrsOf = (scope) => {
|
|
1205
|
+
const value = {};
|
|
1206
|
+
const disagreed = [];
|
|
1207
|
+
for (const payload of poolPayloads) {
|
|
1208
|
+
for (const [name, carriedValue] of Object.entries(carried.attrs(payload, scope))) {
|
|
1209
|
+
const seen = value[name];
|
|
1210
|
+
if (seen !== undefined && seen !== carriedValue)
|
|
1211
|
+
disagreed.push(name);
|
|
1212
|
+
value[name] = carriedValue;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
return { value, disagreed };
|
|
1216
|
+
};
|
|
1217
|
+
const definitionsAttrs = documentAttrsOf(BPMN_SCOPE.definitions);
|
|
1218
|
+
const collaborationAttrs = documentAttrsOf(BPMN_SCOPE.collaboration);
|
|
1219
|
+
const disagreeingDeclarations = [
|
|
1220
|
+
...new Set([
|
|
1221
|
+
...definitionsAttrs.disagreed,
|
|
1222
|
+
...collaborationAttrs.disagreed,
|
|
1223
|
+
]),
|
|
1224
|
+
];
|
|
1225
|
+
/**
|
|
1226
|
+
* A carried declaration that would contradict one this writer makes.
|
|
1227
|
+
*
|
|
1228
|
+
* The reader carries a namespace declaration whenever the (prefix, URI) pair
|
|
1229
|
+
* is not one `export.ts` writes for itself — which correctly keeps
|
|
1230
|
+
* `xmlns:bpmn2`, and which also keeps a file that binds one of THIS
|
|
1231
|
+
* library's four prefixes to something else (`xmlns:dc` as Dublin Core, say).
|
|
1232
|
+
* Writing that back would rebind the prefix every `dc:Bounds` in this
|
|
1233
|
+
* document is written under, so it is refused: the declaration stays in the
|
|
1234
|
+
* document, out of the file, and the person exporting is told — including
|
|
1235
|
+
* what it means for the fragments that were written under it.
|
|
1236
|
+
*/
|
|
1237
|
+
const contradictingDeclarations = Object.keys(definitionsAttrs.value).filter(name => name in BPMN_OWN_DECLARATIONS);
|
|
1238
|
+
const documentChildren = (scope) => carried.keep(poolPayloads.flatMap(payload => carriedFragments(payload?.children, scope)), true);
|
|
1239
|
+
/* ── The semantic half ───────────────────────────────────────────── */
|
|
1240
|
+
const roots = [];
|
|
1241
|
+
// Categories first: a `group` points at a `categoryValue`, and a root element
|
|
1242
|
+
// declared after the thing that references it reads badly even though QName
|
|
1243
|
+
// resolution does not care.
|
|
1244
|
+
for (const node of planned) {
|
|
1245
|
+
if (!node.categoryId || !node.categoryValueId)
|
|
1246
|
+
continue;
|
|
1247
|
+
roots.push(el('bpmn:category', { id: node.categoryId }, [
|
|
1248
|
+
el('bpmn:categoryValue', {
|
|
1249
|
+
id: node.categoryValueId,
|
|
1250
|
+
value: node.name,
|
|
1251
|
+
}),
|
|
1252
|
+
]));
|
|
1253
|
+
}
|
|
1254
|
+
const collaborationId = hasCollaboration
|
|
1255
|
+
? minter.mint('Collaboration', 'board')
|
|
1256
|
+
: undefined;
|
|
1257
|
+
if (collaborationId) {
|
|
1258
|
+
const children = [];
|
|
1259
|
+
// What an import carried off the collaboration, sorted into the slots
|
|
1260
|
+
// `tCollaboration` allows each of them in: `documentation* →
|
|
1261
|
+
// extensionElements? → choreography* → participant* → messageFlow* →
|
|
1262
|
+
// artifact* → conversationNode* → …`. The scope says which ELEMENT a
|
|
1263
|
+
// fragment came out of; the XSD is what says where in it.
|
|
1264
|
+
const shared = bySlot(documentChildren(BPMN_SCOPE.collaboration), collaborationSlotOf);
|
|
1265
|
+
children.push(...slot(shared, 'head'));
|
|
1266
|
+
// `participant*` strictly before `messageFlow*` (tCollaboration's sequence).
|
|
1267
|
+
for (const process of processes) {
|
|
1268
|
+
if (!process.participantId)
|
|
1269
|
+
continue;
|
|
1270
|
+
children.push(el('bpmn:participant', {
|
|
1271
|
+
id: process.participantId,
|
|
1272
|
+
name: process.name || undefined,
|
|
1273
|
+
processRef: process.id,
|
|
1274
|
+
// The participant's own foreign attributes — unless this pool
|
|
1275
|
+
// stands for a bare `process`, whose `@self` belongs on the process
|
|
1276
|
+
// tag and is applied there instead.
|
|
1277
|
+
...(process.selfIsProcess
|
|
1278
|
+
? {}
|
|
1279
|
+
: carried.attrs(process.payload, BPMN_SCOPE.self)),
|
|
1280
|
+
}, process.selfIsProcess
|
|
1281
|
+
? []
|
|
1282
|
+
: carried.keep(carriedFragments(process.payload?.children, BPMN_SCOPE.self))));
|
|
1283
|
+
}
|
|
1284
|
+
for (const edge of edges) {
|
|
1285
|
+
if (edge.element !== 'messageFlow')
|
|
1286
|
+
continue;
|
|
1287
|
+
children.push(semanticEdge(edge, carried));
|
|
1288
|
+
}
|
|
1289
|
+
children.push(...slot(shared, 'messageFlow'));
|
|
1290
|
+
// `artifact*` last, and only what fell outside every pool: an annotation
|
|
1291
|
+
// drawn ON a pool is that process's, and belongs with the work it is about.
|
|
1292
|
+
children.push(...artifacts(COLLABORATION, planned, edges, carried));
|
|
1293
|
+
children.push(...slot(shared, 'artifact'));
|
|
1294
|
+
children.push(...slot(shared, 'tail'));
|
|
1295
|
+
roots.push(el('bpmn:collaboration', {
|
|
1296
|
+
id: collaborationId,
|
|
1297
|
+
name: options.name || undefined,
|
|
1298
|
+
...collaborationAttrs.value,
|
|
1299
|
+
}, children));
|
|
1300
|
+
}
|
|
1301
|
+
for (const [index, process] of processes.entries()) {
|
|
1302
|
+
roots.push(el('bpmn:process', {
|
|
1303
|
+
id: process.id,
|
|
1304
|
+
// The lone process of a poolless board is the whole document, so it
|
|
1305
|
+
// takes the board's name; a pooled one is named by its participant.
|
|
1306
|
+
name: collaborationId ? undefined : options.name || undefined,
|
|
1307
|
+
isExecutable: 'false',
|
|
1308
|
+
// The process's own foreign attributes, and — for the pool an import
|
|
1309
|
+
// minted for a bare `process` — its `@self` ones too, because that
|
|
1310
|
+
// pool IS the process. Applied LAST on purpose: `isExecutable` is
|
|
1311
|
+
// carried by the reader precisely when the file said something other
|
|
1312
|
+
// than the `false` written just above, and giving it back is the
|
|
1313
|
+
// model downgrade this half of the round trip repairs.
|
|
1314
|
+
...(process.selfIsProcess
|
|
1315
|
+
? carried.attrs(process.payload, BPMN_SCOPE.self, BPMN_SCOPE.process)
|
|
1316
|
+
: carried.attrs(process.payload, BPMN_SCOPE.process)),
|
|
1317
|
+
}, processChildren(index, process, planned, edges, carried)));
|
|
1318
|
+
}
|
|
1319
|
+
/* ── The DI half ─────────────────────────────────────────────────── */
|
|
1320
|
+
const planeElements = [];
|
|
1321
|
+
for (const process of processes) {
|
|
1322
|
+
if (!process.pool)
|
|
1323
|
+
continue;
|
|
1324
|
+
const bound = process.pool.elementBound;
|
|
1325
|
+
// A pool that stands for a bare `process` has no participant, and a
|
|
1326
|
+
// participant is the only thing a plane can draw a pool AS — so it gets no
|
|
1327
|
+
// shape of its own. Its lanes still do: they are `DiagramElement`s like any
|
|
1328
|
+
// other, and a laneSet nothing draws is the gap the recette found.
|
|
1329
|
+
if (process.participantId) {
|
|
1330
|
+
planeElements.push(el('bpmndi:BPMNShape', {
|
|
1331
|
+
id: minter.mint('Shape', process.participantId),
|
|
1332
|
+
bpmnElement: process.participantId,
|
|
1333
|
+
// `isHorizontal` is meaningful on pools and lanes ONLY (§12.3.2), and
|
|
1334
|
+
// a pool here always runs left to right: the plot is cut into
|
|
1335
|
+
// horizontal bands, which is what a horizontal pool means.
|
|
1336
|
+
isHorizontal: 'true',
|
|
1337
|
+
...carried.attrs(process.payload, BPMN_SCOPE.shape),
|
|
1338
|
+
},
|
|
1339
|
+
// `tBPMNShape` is `Bounds → BPMNLabel?`, so anything carried off this
|
|
1340
|
+
// shape — a label, a vendor's own DI child — goes after the bounds.
|
|
1341
|
+
[
|
|
1342
|
+
el('dc:Bounds', boundsAttrs(bound, dx, dy)),
|
|
1343
|
+
...carried.keep(carriedFragments(process.payload?.di, BPMN_SCOPE.shape)),
|
|
1344
|
+
]));
|
|
1345
|
+
}
|
|
1346
|
+
// …and one shape per LANE, immediately after its own pool.
|
|
1347
|
+
//
|
|
1348
|
+
// A `laneSet` with no DI is the gap the live recette found: bpmn.io read
|
|
1349
|
+
// the lanes, listed their members, and drew a pool with no subdivisions at
|
|
1350
|
+
// all, because a lane is a DiagramElement like any other and a tool draws
|
|
1351
|
+
// what the plane describes. `isHorizontal` says which way the band runs and
|
|
1352
|
+
// is meaningful on exactly two things — a pool and a lane (§12.3.2).
|
|
1353
|
+
for (const lane of process.lanes) {
|
|
1354
|
+
planeElements.push(el('bpmndi:BPMNShape', {
|
|
1355
|
+
id: minter.mint('Shape', lane.id),
|
|
1356
|
+
bpmnElement: lane.id,
|
|
1357
|
+
isHorizontal: 'true',
|
|
1358
|
+
}, [el('dc:Bounds', boundsAttrs(lane.bound, dx, dy))]));
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
for (const node of planned) {
|
|
1362
|
+
const attrs = {
|
|
1363
|
+
id: minter.mint('Shape', node.id),
|
|
1364
|
+
bpmnElement: node.id,
|
|
1365
|
+
};
|
|
1366
|
+
// The pack draws the COLLAPSED sub-process only — a task-sized box with a
|
|
1367
|
+
// `+` — and `isExpanded="false"` is how DI says exactly that. Without it a
|
|
1368
|
+
// tool is free to draw an expanded container the author never made.
|
|
1369
|
+
if (node.model.kind === 'subProcess' ||
|
|
1370
|
+
node.model.kind === 'callActivity') {
|
|
1371
|
+
attrs.isExpanded = 'false';
|
|
1372
|
+
}
|
|
1373
|
+
// Meaningful on the exclusive gateway alone (§12.3.2): it is what makes the
|
|
1374
|
+
// X appear rather than an empty diamond.
|
|
1375
|
+
if (node.model.kind === 'gatewayExclusive') {
|
|
1376
|
+
attrs.isMarkerVisible = 'true';
|
|
1377
|
+
}
|
|
1378
|
+
Object.assign(attrs, carried.attrs(node.payload, BPMN_SCOPE.shape));
|
|
1379
|
+
planeElements.push(el('bpmndi:BPMNShape', attrs, [
|
|
1380
|
+
el('dc:Bounds', boundsAttrs(node.bound, dx, dy)),
|
|
1381
|
+
...carried.keep(carriedFragments(node.payload?.di, BPMN_SCOPE.shape)),
|
|
1382
|
+
]));
|
|
1383
|
+
}
|
|
1384
|
+
for (const edge of edges) {
|
|
1385
|
+
planeElements.push(el('bpmndi:BPMNEdge', {
|
|
1386
|
+
id: minter.mint('Edge', edge.id),
|
|
1387
|
+
bpmnElement: edge.id,
|
|
1388
|
+
...carried.attrs(edge.payload, BPMN_SCOPE.shape),
|
|
1389
|
+
},
|
|
1390
|
+
// `tBPMNEdge` is `waypoint+ → BPMNLabel?`: the routing first, whatever
|
|
1391
|
+
// was carried off the edge after it.
|
|
1392
|
+
[
|
|
1393
|
+
...edge.waypoints.map(([x, y]) => el('di:waypoint', { x: num(x + dx), y: num(y + dy) })),
|
|
1394
|
+
...carried.keep(carriedFragments(edge.payload?.di, BPMN_SCOPE.shape)),
|
|
1395
|
+
]));
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* The diagram elements of everything this board does not draw.
|
|
1399
|
+
*
|
|
1400
|
+
* A carried boundary event has a `BPMNShape`, its error path has a
|
|
1401
|
+
* `BPMNEdge`, and an orphan shape naming an element the file never declared
|
|
1402
|
+
* has neither an element nor a home — all three were kept under the ID they
|
|
1403
|
+
* name (D2), and all three go back on the plane, whole, after everything
|
|
1404
|
+
* Labre drew. Last rather than interleaved because a `BPMNPlane` holds an
|
|
1405
|
+
* unordered `DiagramElement*` and because it keeps the shapes this exporter
|
|
1406
|
+
* mints in exactly the order the reader indexes them in: the fixed point
|
|
1407
|
+
* depends on that order and not on this one.
|
|
1408
|
+
*
|
|
1409
|
+
* ## They keep the FILE's coordinates, and the plane may have moved
|
|
1410
|
+
*
|
|
1411
|
+
* The one place where "verbatim" and "in the right place" are not the same
|
|
1412
|
+
* sentence. A carried fragment is given back character for character — that
|
|
1413
|
+
* is what makes the payload a fixed point, and rewriting the numbers inside
|
|
1414
|
+
* one would mean the second import stored something the first never saw — so
|
|
1415
|
+
* a carried shape keeps the `dc:Bounds` the source file wrote, while
|
|
1416
|
+
* everything Labre draws is translated to the plane origin (§12.3). Where the
|
|
1417
|
+
* two differ, the carried shape lands `(dx, dy)` away from where it belongs.
|
|
1418
|
+
* It is in the loss table and the warning below says so out loud; it is not
|
|
1419
|
+
* silent, and nothing is lost — only displaced.
|
|
1420
|
+
*/
|
|
1421
|
+
const carriedPlane = carried.keep([
|
|
1422
|
+
...processes.flatMap(process => carriedPlaneDi(process.payload)),
|
|
1423
|
+
...planned.flatMap(node => carriedPlaneDi(node.payload)),
|
|
1424
|
+
...edges.flatMap(edge => carriedPlaneDi(edge.payload)),
|
|
1425
|
+
], true);
|
|
1426
|
+
planeElements.push(...carriedPlane);
|
|
1427
|
+
const diagram = el('bpmndi:BPMNDiagram', { id: minter.mint('Diagram', 'board'), name: options.name || undefined }, [
|
|
1428
|
+
el('bpmndi:BPMNPlane', {
|
|
1429
|
+
id: minter.mint('Plane', 'board'),
|
|
1430
|
+
// The collaboration when there is one — a plane pointing at a process
|
|
1431
|
+
// draws the flow and none of the pools.
|
|
1432
|
+
bpmnElement: collaborationId ?? processes[0]?.id,
|
|
1433
|
+
}, planeElements),
|
|
1434
|
+
]);
|
|
1435
|
+
// `tDefinitions` is `import* → extension* → rootElement* → BPMNDiagram* →
|
|
1436
|
+
// relationship*`. An `<import>` never arrives here — D5 quarantines it — so
|
|
1437
|
+
// the carried residue splits three ways round the diagram.
|
|
1438
|
+
const document = bySlot(documentChildren(BPMN_SCOPE.definitions), definitionsSlotOf);
|
|
1439
|
+
const definitions = el('bpmn:definitions', {
|
|
1440
|
+
'xmlns:bpmn': BPMN_NS.model,
|
|
1441
|
+
'xmlns:bpmndi': BPMN_NS.bpmndi,
|
|
1442
|
+
'xmlns:di': BPMN_NS.di,
|
|
1443
|
+
'xmlns:dc': BPMN_NS.dc,
|
|
1444
|
+
id: minter.mint('Definitions', 'board'),
|
|
1445
|
+
targetNamespace: TARGET_NAMESPACE,
|
|
1446
|
+
exporter: EXPORTER,
|
|
1447
|
+
// The file's OTHER namespace declarations, and its foreign attributes.
|
|
1448
|
+
// A carried `camunda:property` fragment is meaningless without the
|
|
1449
|
+
// `xmlns:camunda` it was written under, and a carried
|
|
1450
|
+
// `bpmn2:boundaryEvent` is just as meaningless without `xmlns:bpmn2` —
|
|
1451
|
+
// same namespace as the `xmlns:bpmn` above, different prefix, and a
|
|
1452
|
+
// fragment stored verbatim needs the prefix it was stored under.
|
|
1453
|
+
...Object.fromEntries(Object.entries(definitionsAttrs.value).filter(([name]) => !(name in BPMN_OWN_DECLARATIONS))),
|
|
1454
|
+
},
|
|
1455
|
+
// `rootElement*` strictly before `BPMNDiagram*` (tDefinitions' sequence),
|
|
1456
|
+
// and the carried roots before the ones minted here for the same reason
|
|
1457
|
+
// `category` comes first: a root that is referenced reads better declared
|
|
1458
|
+
// before the thing referencing it, and QName resolution does not care.
|
|
1459
|
+
[
|
|
1460
|
+
...slot(document, 'extension'),
|
|
1461
|
+
...slot(document, 'root'),
|
|
1462
|
+
...roots,
|
|
1463
|
+
diagram,
|
|
1464
|
+
...slot(document, 'relationship'),
|
|
1465
|
+
]);
|
|
1466
|
+
/* ── What the format refused to carry ────────────────────────────── */
|
|
1467
|
+
const warnings = [];
|
|
1468
|
+
// Flow objects drawn beside the pools. They are in the file and correct for
|
|
1469
|
+
// any tool that reads the MODEL, and a collaboration plane has no shape to
|
|
1470
|
+
// draw a participant-less process in, so bpmn-js imports them and renders
|
|
1471
|
+
// nothing. Found by the live recette; silent to the user until now.
|
|
1472
|
+
//
|
|
1473
|
+
// `orphanProcess >= 0` is load-bearing and not defensive: unminted it is
|
|
1474
|
+
// `-1`, which is exactly {@link COLLABORATION}, and the artifacts filed there
|
|
1475
|
+
// are the ones that ARE drawn.
|
|
1476
|
+
const undrawn = hasCollaboration && orphanProcess >= 0
|
|
1477
|
+
? planned.filter(node => node.scope === orphanProcess).length
|
|
1478
|
+
: 0;
|
|
1479
|
+
if (undrawn > 0) {
|
|
1480
|
+
warnings.push(`${undrawn} ${undrawn === 1 ? 'artefact is' : 'artefacts are'} drawn ` +
|
|
1481
|
+
`outside every pool. ${undrawn === 1 ? 'It is' : 'They are'} in the ` +
|
|
1482
|
+
`file, but most BPMN tools will not draw ${undrawn === 1 ? 'it' : 'them'}: ` +
|
|
1483
|
+
`only a pool has a shape to hold ${undrawn === 1 ? 'it' : 'them'}. ` +
|
|
1484
|
+
`Draw ${undrawn === 1 ? 'it' : 'them'} inside a pool to make ` +
|
|
1485
|
+
`${undrawn === 1 ? 'it' : 'them'} visible.`);
|
|
1486
|
+
}
|
|
1487
|
+
if (droppedMessageFlows > 0) {
|
|
1488
|
+
warnings.push(`${droppedMessageFlows} message ${droppedMessageFlows === 1 ? 'flow was' : 'flows were'} ` +
|
|
1489
|
+
`left out: a message flow runs between participants, and this board ` +
|
|
1490
|
+
`has no pool. Draw the pools it runs between, or say "is followed by" ` +
|
|
1491
|
+
`instead.`);
|
|
1492
|
+
}
|
|
1493
|
+
// An id a file gave us that we could not give back (ADR 0012 D3). It happens
|
|
1494
|
+
// when two elements carry the same recorded id — a merged document, an
|
|
1495
|
+
// import run twice — or when what was recorded is not a valid NCName. The
|
|
1496
|
+
// file is correct either way; what the author loses is the continuity of one
|
|
1497
|
+
// name between the document they imported and the one they are exporting.
|
|
1498
|
+
if (minter.substituted > 0) {
|
|
1499
|
+
warnings.push(`${minter.substituted} ${minter.substituted === 1 ? 'element' : 'elements'} ` +
|
|
1500
|
+
`imported from a BPMN file could not keep ` +
|
|
1501
|
+
`${minter.substituted === 1 ? 'its' : 'their'} original id: ` +
|
|
1502
|
+
`${minter.substituted === 1 ? 'it was' : 'they were'} already taken in ` +
|
|
1503
|
+
`this file. A new id was written instead; nothing else changed.`);
|
|
1504
|
+
}
|
|
1505
|
+
// A carried diagram element keeps the source file's own coordinates, and the
|
|
1506
|
+
// rest of the drawing has just been translated to the plane origin (§12.3).
|
|
1507
|
+
// The two agree only while the translation is a no-op — which it is for a
|
|
1508
|
+
// board exported straight back out of the import that made it, and is not
|
|
1509
|
+
// once anything has been dragged. Nothing is lost; something is displaced,
|
|
1510
|
+
// and the person who clicked Export is the one entitled to hear about it.
|
|
1511
|
+
if (carriedPlane.length > 0 && (dx !== 0 || dy !== 0)) {
|
|
1512
|
+
const count = carriedPlane.length;
|
|
1513
|
+
warnings.push(`${count} ${count === 1 ? 'shape' : 'shapes'} imported from a BPMN file ` +
|
|
1514
|
+
`${count === 1 ? 'is' : 'are'} kept exactly as the file drew ` +
|
|
1515
|
+
`${count === 1 ? 'it' : 'them'}, and the rest of this drawing has ` +
|
|
1516
|
+
`moved since. ${count === 1 ? 'It' : 'They'} will open beside the ` +
|
|
1517
|
+
`process rather than inside it. Nothing was lost: ` +
|
|
1518
|
+
`${count === 1 ? 'it is' : 'they are'} in the file, at the position ` +
|
|
1519
|
+
`the original gave ${count === 1 ? 'it' : 'them'}.`);
|
|
1520
|
+
}
|
|
1521
|
+
if (contradictingDeclarations.length > 0) {
|
|
1522
|
+
const one = contradictingDeclarations.length === 1;
|
|
1523
|
+
warnings.push(`This board came from a BPMN file that used ` +
|
|
1524
|
+
`${contradictingDeclarations.join(' and ')} for something other than ` +
|
|
1525
|
+
`what BPMN means by ${one ? 'it' : 'them'}. ` +
|
|
1526
|
+
`Labre writes its own, so the file's ` +
|
|
1527
|
+
`${one ? 'declaration was' : 'declarations were'} ` +
|
|
1528
|
+
`left out rather than allowed to redefine the diagram's own namespaces. ` +
|
|
1529
|
+
`${one ? 'It is' : 'They are'} still in the document. ` +
|
|
1530
|
+
// The half a reader would otherwise have to work out: the DECLARATION
|
|
1531
|
+
// is what was dropped, and the matter written under it was not — so it
|
|
1532
|
+
// is now read under Labre's binding of the same prefix, which means
|
|
1533
|
+
// something else. That is the larger of the two changes and it was the
|
|
1534
|
+
// silent one.
|
|
1535
|
+
`Anything the file wrote under ` +
|
|
1536
|
+
`${one ? 'that prefix' : 'those prefixes'} is still in the export and ` +
|
|
1537
|
+
`will now be read under Labre's meaning of ` +
|
|
1538
|
+
`${one ? 'it' : 'them'}, which is not the meaning the original had.`);
|
|
1539
|
+
}
|
|
1540
|
+
// Two pools, two source files, one prefix bound two ways. Last one wins —
|
|
1541
|
+
// there is one attribute of each name on `definitions` and something has to
|
|
1542
|
+
// be written — but a rebinding nobody was told about is how a fragment comes
|
|
1543
|
+
// to mean something else with no trace of when.
|
|
1544
|
+
if (disagreeingDeclarations.length > 0) {
|
|
1545
|
+
warnings.push(`Two pools on this board disagree about ` +
|
|
1546
|
+
`${disagreeingDeclarations.join(' and ')}: they came from BPMN files ` +
|
|
1547
|
+
`that gave the same name two different values. The last was written ` +
|
|
1548
|
+
`and the other left out, so matter carried from the first file is now ` +
|
|
1549
|
+
`read under the second's meaning. Both are still in the document.`);
|
|
1550
|
+
}
|
|
1551
|
+
// A carried element claiming an id another has already written back. The
|
|
1552
|
+
// duplicate-by-paste case is silent on purpose — one thing carried twice is
|
|
1553
|
+
// written once and nothing is lost — so this fires only for two DIFFERENT
|
|
1554
|
+
// fragments claiming one id, which is a file that cannot hold both.
|
|
1555
|
+
const conflicting = [...new Set(carried.conflictingIds)];
|
|
1556
|
+
if (conflicting.length > 0) {
|
|
1557
|
+
const one = conflicting.length === 1;
|
|
1558
|
+
warnings.push(`${conflicting.length} ${one ? 'element' : 'elements'} imported from a ` +
|
|
1559
|
+
`BPMN file could not be written back: ` +
|
|
1560
|
+
`${conflicting.map(id => `"${id}"`).join(', ')} ` +
|
|
1561
|
+
`${one ? 'names an id' : 'name ids'} another imported element had ` +
|
|
1562
|
+
`already claimed, and a BPMN id must be unique across a document. The ` +
|
|
1563
|
+
`first was kept. ${one ? 'The other is' : 'The others are'} still in ` +
|
|
1564
|
+
`the document; ${one ? 'it is' : 'they are'} not in this file.`);
|
|
1565
|
+
}
|
|
1566
|
+
// An attribute NAME that is not a name. It cannot be written without
|
|
1567
|
+
// unbalancing the document — the serializer interpolates a name and escapes
|
|
1568
|
+
// only a value — so it is dropped rather than allowed to corrupt the file.
|
|
1569
|
+
const refusedNames = [...new Set(carried.refusedNames)];
|
|
1570
|
+
if (refusedNames.length > 0) {
|
|
1571
|
+
const one = refusedNames.length === 1;
|
|
1572
|
+
warnings.push(`${refusedNames.length} carried ${one ? 'attribute' : 'attributes'} ` +
|
|
1573
|
+
`could not be written back, because ` +
|
|
1574
|
+
`${one ? 'its name is not' : 'their names are not'} a valid XML name. ` +
|
|
1575
|
+
`${one ? 'It is' : 'They are'} still in the document. This is a sign ` +
|
|
1576
|
+
`the payload was edited by something other than a BPMN import.`);
|
|
1577
|
+
}
|
|
1578
|
+
if (unwritableEdges > 0) {
|
|
1579
|
+
warnings.push(`${unwritableEdges} ${unwritableEdges === 1 ? 'arrow was' : 'arrows were'} ` +
|
|
1580
|
+
`left out: BPMN requires both ends of a flow to be named, and ` +
|
|
1581
|
+
`${unwritableEdges === 1 ? 'this one has' : 'these have'} an end that ` +
|
|
1582
|
+
`is loose or attached to something that is not a BPMN artefact.`);
|
|
1583
|
+
}
|
|
1584
|
+
return {
|
|
1585
|
+
text: `<?xml version="1.0" encoding="UTF-8"?>\n${serializeElement(definitions, '')}\n`,
|
|
1586
|
+
warnings,
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
function boundsAttrs(bound, dx, dy) {
|
|
1590
|
+
return {
|
|
1591
|
+
x: num(bound.x + dx),
|
|
1592
|
+
y: num(bound.y + dy),
|
|
1593
|
+
width: num(bound.w),
|
|
1594
|
+
height: num(bound.h),
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
/**
|
|
1598
|
+
* One process's children, in the order `tProcess` requires them:
|
|
1599
|
+
* `laneSet* → flowElement* → artifact*`.
|
|
1600
|
+
*
|
|
1601
|
+
* The two halves of "flow element" are kept apart in the loop below only
|
|
1602
|
+
* because a LANE may reference the first and not the second: `flowNodeRef` is
|
|
1603
|
+
* an IDREF to a flow NODE, and a `dataObjectReference` is a flow element that
|
|
1604
|
+
* is not a flow node. They serialize into the same slot all the same.
|
|
1605
|
+
*/
|
|
1606
|
+
function processChildren(index, process, planned, edges, carried) {
|
|
1607
|
+
const mine = planned.filter(node => node.scope === index);
|
|
1608
|
+
const children = [];
|
|
1609
|
+
/**
|
|
1610
|
+
* What an import carried out of this process, back in its own slot.
|
|
1611
|
+
*
|
|
1612
|
+
* The scope records which ELEMENT a fragment was a child of and not which
|
|
1613
|
+
* slot of it, so placement is the writer's problem — see
|
|
1614
|
+
* {@link processSlotOf}, which derives it from the same `tProcess` sequence
|
|
1615
|
+
* the three blocks below are already written in. `@self` joins `@process`
|
|
1616
|
+
* for the pool an import minted for a bare `process`, because that pool is
|
|
1617
|
+
* the process (D6).
|
|
1618
|
+
*/
|
|
1619
|
+
const mySlots = bySlot(carried.keep(process.selfIsProcess
|
|
1620
|
+
? carriedFragments(process.payload?.children, BPMN_SCOPE.self, BPMN_SCOPE.process)
|
|
1621
|
+
: carriedFragments(process.payload?.children, BPMN_SCOPE.process)), processSlotOf);
|
|
1622
|
+
// `documentation`, `extensionElements`, `auditing`, `property`… — everything
|
|
1623
|
+
// `tProcess` puts BEFORE its lane sets, which is where the sequence starts.
|
|
1624
|
+
children.push(...slot(mySlots, 'head'));
|
|
1625
|
+
/**
|
|
1626
|
+
* `laneSet` — FLAT, and only when the pool actually PAINTS lanes.
|
|
1627
|
+
*
|
|
1628
|
+
* The rows come from `poolLaneBands`, so the lanes written here are exactly
|
|
1629
|
+
* the lanes the plane draws a `BPMNShape` for: a row a typo made unusable is
|
|
1630
|
+
* absent from both rather than present in one as a lane with no shape and no
|
|
1631
|
+
* members.
|
|
1632
|
+
*
|
|
1633
|
+
* No `childLaneSet` is ever written, because the pack draws no nested lane:
|
|
1634
|
+
* a pool's `lanes` prop is one list of bands over one plot, and there is no
|
|
1635
|
+
* gesture that puts a lane inside a lane. The element exists in the format
|
|
1636
|
+
* and is deliberately unused; the day nested lanes ship, this is where they
|
|
1637
|
+
* land.
|
|
1638
|
+
*/
|
|
1639
|
+
if (process.laneSetId && process.lanes.length > 0) {
|
|
1640
|
+
children.push(el('bpmn:laneSet', {
|
|
1641
|
+
id: process.laneSetId,
|
|
1642
|
+
...carried.attrs(process.payload, BPMN_SCOPE.laneSet),
|
|
1643
|
+
}, [
|
|
1644
|
+
// `tLaneSet` is `documentation* → extensionElements? → lane*`.
|
|
1645
|
+
...carried.keep(carriedFragments(process.payload?.children, BPMN_SCOPE.laneSet)),
|
|
1646
|
+
...process.lanes.map(band => el('bpmn:lane', {
|
|
1647
|
+
id: band.id,
|
|
1648
|
+
name: band.lane.name || undefined,
|
|
1649
|
+
// A lane's SCOPE is the id the file called it, which is what
|
|
1650
|
+
// the pool stores on the band — never the id minted just above,
|
|
1651
|
+
// which a collision could have moved.
|
|
1652
|
+
...carried.attrs(process.payload, band.lane.id),
|
|
1653
|
+
}, [
|
|
1654
|
+
// `tLane` is `documentation* → extensionElements? →
|
|
1655
|
+
// partitionElement? → flowNodeRef* → childLaneSet?`, so what
|
|
1656
|
+
// was carried off the lane goes before the references. The
|
|
1657
|
+
// `childLaneSet` never arrives: D5 case 3 quarantines it.
|
|
1658
|
+
...carried.keep(carriedFragments(process.payload?.children, band.lane.id)),
|
|
1659
|
+
...mine
|
|
1660
|
+
.filter(node => node.mapping.slot === 'flowNode' &&
|
|
1661
|
+
node.lane?.id === band.lane.id)
|
|
1662
|
+
// `flowNodeRef` is an ELEMENT whose text is the IDREF, never
|
|
1663
|
+
// an attribute — the one place in the format where a
|
|
1664
|
+
// reference is spelled that way.
|
|
1665
|
+
.map(node => textEl('bpmn:flowNodeRef', node.id)),
|
|
1666
|
+
])),
|
|
1667
|
+
]));
|
|
1668
|
+
}
|
|
1669
|
+
/* flowElement* — the flow nodes, the data references, the sequence flows. */
|
|
1670
|
+
for (const node of mine) {
|
|
1671
|
+
if (node.mapping.slot === 'artifact')
|
|
1672
|
+
continue;
|
|
1673
|
+
children.push(semanticNode(node, carried));
|
|
1674
|
+
// The `dataObject` a `dataObjectReference` points at: a flow element of
|
|
1675
|
+
// this same process, written beside the reference. The spec splits the two
|
|
1676
|
+
// on purpose — the OBJECT carries the item definition, the REFERENCE
|
|
1677
|
+
// carries the state and is what DI draws — and a reference whose
|
|
1678
|
+
// `dataObjectRef` resolves to nothing is the one thing bpmn.io's linter
|
|
1679
|
+
// complains about on an otherwise clean file.
|
|
1680
|
+
if (node.dataObjectId) {
|
|
1681
|
+
children.push(el('bpmn:dataObject', {
|
|
1682
|
+
id: node.dataObjectId,
|
|
1683
|
+
name: node.name || undefined,
|
|
1684
|
+
}));
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
for (const edge of edges) {
|
|
1688
|
+
if (edge.scope !== index || edge.element !== 'sequenceFlow')
|
|
1689
|
+
continue;
|
|
1690
|
+
children.push(semanticEdge(edge, carried));
|
|
1691
|
+
}
|
|
1692
|
+
// Carried flow elements — the Analytic vocabulary, the flows onto it — in the
|
|
1693
|
+
// same slot as the ones above, because that is the slot they came out of.
|
|
1694
|
+
children.push(...slot(mySlots, 'flowElement'));
|
|
1695
|
+
/* artifact* — annotations, groups, associations. Last, per the XSD. */
|
|
1696
|
+
children.push(...artifacts(index, planned, edges, carried));
|
|
1697
|
+
children.push(...slot(mySlots, 'artifact'));
|
|
1698
|
+
/* …and `resourceRole*` and its neighbours, which follow the artifacts. */
|
|
1699
|
+
children.push(...slot(mySlots, 'tail'));
|
|
1700
|
+
return children;
|
|
1701
|
+
}
|
|
1702
|
+
/**
|
|
1703
|
+
* The `artifact*` tail of one scope — a process, or the collaboration.
|
|
1704
|
+
*
|
|
1705
|
+
* Shared between the two because `tProcess` and `tCollaboration` both end on
|
|
1706
|
+
* the same `artifact*` slot with the same members, and the only thing that
|
|
1707
|
+
* differs is which scope is being asked about. Writing it twice is how the two
|
|
1708
|
+
* would come to disagree.
|
|
1709
|
+
*/
|
|
1710
|
+
function artifacts(scope, planned, edges, carried) {
|
|
1711
|
+
const out = [];
|
|
1712
|
+
for (const node of planned) {
|
|
1713
|
+
if (node.scope !== scope || node.mapping.slot !== 'artifact')
|
|
1714
|
+
continue;
|
|
1715
|
+
out.push(semanticNode(node, carried));
|
|
1716
|
+
}
|
|
1717
|
+
for (const edge of edges) {
|
|
1718
|
+
if (edge.scope !== scope || edge.element !== 'association')
|
|
1719
|
+
continue;
|
|
1720
|
+
out.push(semanticEdge(edge, carried));
|
|
1721
|
+
}
|
|
1722
|
+
return out;
|
|
1723
|
+
}
|
|
1724
|
+
/**
|
|
1725
|
+
* One flow, as its semantic element, carrying whatever an import kept on it.
|
|
1726
|
+
*
|
|
1727
|
+
* The three kinds share a writer because they share a shape — an id, two ends,
|
|
1728
|
+
* and everything the format says about a flow that Labre does not model (a
|
|
1729
|
+
* `conditionExpression`, a `default`, a vendor's `extensionElements`). What
|
|
1730
|
+
* differs is one attribute each way, and writing them apart is how the three
|
|
1731
|
+
* would come to disagree about the carried half.
|
|
1732
|
+
*
|
|
1733
|
+
* The carried attributes go on LAST, which is what lets an
|
|
1734
|
+
* `associationDirection` the file actually stated win over the `None` this
|
|
1735
|
+
* exporter writes for a role that declares no direction (`docs/adr/0010`).
|
|
1736
|
+
*/
|
|
1737
|
+
function semanticEdge(edge, carried) {
|
|
1738
|
+
const attrs = {
|
|
1739
|
+
id: edge.id,
|
|
1740
|
+
// `association` has no `name` in this exporter and never had one.
|
|
1741
|
+
...(edge.element === 'association' ? {} : { name: edge.name || undefined }),
|
|
1742
|
+
sourceRef: edge.source.id,
|
|
1743
|
+
targetRef: edge.target.id,
|
|
1744
|
+
// "This note is about that task" reads the same from either end, so the
|
|
1745
|
+
// association claims no direction.
|
|
1746
|
+
...(edge.element === 'association' ? { associationDirection: 'None' } : {}),
|
|
1747
|
+
...carried.attrs(edge.payload, BPMN_SCOPE.self),
|
|
1748
|
+
};
|
|
1749
|
+
return el(`bpmn:${edge.element}`, attrs,
|
|
1750
|
+
// A flow has no child this exporter writes, so there is nothing for the
|
|
1751
|
+
// carried ones to be ordered against.
|
|
1752
|
+
carried.keep(carriedFragments(edge.payload?.children, BPMN_SCOPE.self)));
|
|
1753
|
+
}
|
|
1754
|
+
/**
|
|
1755
|
+
* One artefact, as its semantic element (plus whatever it drags along).
|
|
1756
|
+
*
|
|
1757
|
+
* ## Where the carried half goes, and why it is one rule
|
|
1758
|
+
*
|
|
1759
|
+
* Everything an import kept off this element goes in FIRST, before the single
|
|
1760
|
+
* child this exporter ever writes — and that is XSD-correct for both of the
|
|
1761
|
+
* two cases rather than a convenience. `tTextAnnotation` is
|
|
1762
|
+
* `documentation* → extensionElements? → text?`, so the annotation's own text
|
|
1763
|
+
* is genuinely last. `tCatchEvent` / `tThrowEvent` reach `eventDefinition*`
|
|
1764
|
+
* only after `documentation*`, `extensionElements?`, `auditing?`,
|
|
1765
|
+
* `monitoring?`, `categoryValueRef*`, `incoming*`, `outgoing*`, `property*`,
|
|
1766
|
+
* `dataOutput*`, `dataOutputAssociation*` and `outputSet?` — which is to say,
|
|
1767
|
+
* after every child of an event an import can have carried. One rule covers
|
|
1768
|
+
* both, which is the only way it stays true.
|
|
1769
|
+
*/
|
|
1770
|
+
function semanticNode(node, written) {
|
|
1771
|
+
const { mapping, name } = node;
|
|
1772
|
+
const carried = written.keep(carriedFragments(node.payload?.children, BPMN_SCOPE.self));
|
|
1773
|
+
const foreign = written.attrs(node.payload, BPMN_SCOPE.self);
|
|
1774
|
+
if (mapping.element === 'textAnnotation') {
|
|
1775
|
+
return el('bpmn:textAnnotation', { id: node.id, textFormat: 'text/plain', ...foreign }, [...carried, textEl('bpmn:text', name)]);
|
|
1776
|
+
}
|
|
1777
|
+
if (mapping.element === 'group') {
|
|
1778
|
+
// No `name` attribute exists on `group` — the label is the categoryValue's.
|
|
1779
|
+
return el('bpmn:group', {
|
|
1780
|
+
id: node.id,
|
|
1781
|
+
categoryValueRef: node.categoryValueId,
|
|
1782
|
+
...foreign,
|
|
1783
|
+
}, carried);
|
|
1784
|
+
}
|
|
1785
|
+
if (mapping.element === 'dataObjectReference') {
|
|
1786
|
+
return el('bpmn:dataObjectReference', {
|
|
1787
|
+
id: node.id,
|
|
1788
|
+
name: name || undefined,
|
|
1789
|
+
dataObjectRef: node.dataObjectId,
|
|
1790
|
+
...foreign,
|
|
1791
|
+
}, carried);
|
|
1792
|
+
}
|
|
1793
|
+
const attrs = { id: node.id, name: name || undefined, ...foreign };
|
|
1794
|
+
const children = [...carried];
|
|
1795
|
+
if (mapping.eventDefinition) {
|
|
1796
|
+
// Last child of the event, which is where `tCatchEvent` / `tThrowEvent` put
|
|
1797
|
+
// it. No child of its own is required: a `timerEventDefinition` with no
|
|
1798
|
+
// `timeDate` is valid, and the pack does not ask the author for one.
|
|
1799
|
+
children.push(el(`bpmn:${mapping.eventDefinition}`, {}));
|
|
1800
|
+
}
|
|
1801
|
+
return el(`bpmn:${mapping.element}`, attrs, children);
|
|
1802
|
+
}
|