@uipath/maestro-builder-sdk 6.65.0 → 6.66.1

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.
@@ -21,36 +21,53 @@
21
21
  * the merge emits the element from the ORIGINAL. The original XML is the carrier for the
22
22
  * wire detail the builder does not model, so the builder never has to model it.
23
23
  *
24
+ * The same rule holds one level down, all the way: inside an element the author did
25
+ * touch, each attribute, child element and text that `edited` has as `baseline` has it
26
+ * stays the original's, and only what differs comes from `edited`. So editing a script
27
+ * changes the script, not the task's mapping rows, event definition ids or the other
28
+ * children of its sub-process. The document root is merged the same way, so a
29
+ * definition the edit needs (a new `bpmn:error`) lands beside the process.
30
+ *
24
31
  * Without a `baseline` the merge is two-way: take the edited process, re-attach the
25
32
  * original diagram by element id. Correct whenever decompile was faithful for every
26
33
  * element, and still much better than a bare recompile, which drops layout entirely.
27
34
  *
35
+ * ## Reading the code
36
+ *
37
+ * Every function of the three-way merge names the three documents the same way:
38
+ * `original` (the file on disk), `baseline` (the round trip of the original) and `edited`
39
+ * (the compiled edit). Each is passed as a `Located`, one element with the text of the
40
+ * document it was read from, since a range means nothing without its text. A map between
41
+ * two sibling lists is named for its direction: `originalToBaseline` takes an original
42
+ * child to its baseline counterpart.
43
+ *
28
44
  * ## Two things that differ from the flow merge
29
45
  *
30
46
  * 1. **Wiring lives inside the element.** A flow node's edges are separate objects, so
31
47
  * `mergeFlow` can swap a whole node. A BPMN element carries `bpmn:incoming` /
32
48
  * `bpmn:outgoing` as children, so a reorder changes an element whose payload is
33
- * untouched. The comparison here therefore ignores wiring, and a preserved element
34
- * adopts the EDITED wiring while keeping its ORIGINAL payload. Without that,
35
- * reordering two tasks either loses their payloads or restores the old order.
49
+ * untouched. The comparison here therefore ignores wiring, and wiring merges on its
50
+ * own, entry by entry (`mergedWiring`): the original's entries, less those the edit
51
+ * dropped from the baseline's, plus those it added. An element whose payload the edit
52
+ * left alone keeps its original text, with that wiring.
36
53
  *
37
- * 2. **`bpmn:extensionElements` has no id**, so the process-level block is merged by
38
- * role rather than by key: the author's `uipath:variables` wins (they may have
39
- * declared one), and every other child — `migrationVersion`, `caseManagement`, and
40
- * anything else a future platform version puts there — comes from the original
41
- * untouched. Nothing in this file needs to know what those payloads mean.
54
+ * 2. **`bpmn:extensionElements` and its rows mostly have no id**, so children are paired
55
+ * by id, then by a unique `name`, then by position among siblings of the same kind.
56
+ * A child of the original the round trip does not carry (`caseManagement`, anything
57
+ * a future platform version adds) has no baseline counterpart, so nothing can have
58
+ * removed it and it stays. Nothing in this file needs to know what those payloads
59
+ * mean.
42
60
  *
43
- * ## What it deliberately does NOT preserve
61
+ * ## What it does not repair
44
62
  *
45
- * A bare `<uipath:input name="args">`. `uip maestro bpmn validate` rejects it — the row
46
- * must declare `type="json"` and target `"bodyField"` — so an original missing them is
47
- * invalid, and the recompiled form is the correct one. Preserving that faithfully would
48
- * mean preserving invalid markup. It is not a special case in the code: such a row only
49
- * ever appears inside an element the author edited, which is taken from `edited` anyway.
50
- * Recorded here because it is the one difference a reader will expect to see handled.
63
+ * A bare `<uipath:input name="args">`, which `uip maestro bpmn validate` rejects (the row
64
+ * must declare `type="json"` and target `"bodyField"`), stays bare unless the author
65
+ * edits that row: the merge changes what the edit changed and nothing else.
51
66
  */
67
+ import { ownEntry } from '../core/own-entry.js';
52
68
  import { findByLocal, parseXmlRanges } from './xml-ranges.js';
53
69
  const WIRING = new Set(['incoming', 'outgoing']);
70
+ const isWiring = (range) => WIRING.has(range.local);
54
71
  function processOf(xml, label) {
55
72
  const process = findByLocal(parseXmlRanges(xml), 'process');
56
73
  if (!process)
@@ -61,6 +78,32 @@ function processOf(xml, label) {
61
78
  function textOf(xml, range) {
62
79
  return xml.slice(range.start, range.end);
63
80
  }
81
+ /** Where the run of whitespace that ends at `at` starts. */
82
+ function whitespaceStart(xml, at) {
83
+ let from = at;
84
+ while (from > 0 && /\s/.test(xml[from - 1]))
85
+ from--;
86
+ return from;
87
+ }
88
+ /** The spaces and tabs between the start of the line `at` is on and `at`. */
89
+ function lineIndent(xml, at) {
90
+ let from = at;
91
+ while (from > 0 && (xml[from - 1] === ' ' || xml[from - 1] === '\t'))
92
+ from--;
93
+ return xml.slice(from, at);
94
+ }
95
+ /** `xml` with each range replaced by its text; a range replaced by nothing takes the whitespace before it too. */
96
+ function replaceRanges(xml, edits) {
97
+ let text = '';
98
+ let at = 0;
99
+ for (const edit of [...edits].sort((x, y) => x.range.start - y.range.start)) {
100
+ const from = edit.text === '' ? whitespaceStart(xml, edit.range.start) : edit.range.start;
101
+ text += xml.slice(at, Math.max(at, from)) + edit.text;
102
+ at = edit.range.end;
103
+ }
104
+ return text + xml.slice(at);
105
+ }
106
+ const locate = (document, range) => ({ xml: document.xml, range });
64
107
  /**
65
108
  * The element's text with its wiring children removed and whitespace flattened — the
66
109
  * form two documents are compared in to answer "did the author touch this element?".
@@ -70,83 +113,52 @@ function textOf(xml, range) {
70
113
  * produces identical bytes. Structural canonicalisation would be strictly more code for
71
114
  * the same answer.
72
115
  */
73
- function withoutWiring(xml, range) {
74
- const cuts = range.children.filter((c) => WIRING.has(c.local));
116
+ function withoutWiring(element) {
75
117
  let text = '';
76
- let at = range.start;
77
- for (const cut of cuts) {
78
- text += xml.slice(at, cut.start);
79
- at = cut.end;
118
+ let at = element.range.start;
119
+ for (const child of element.range.children.filter(isWiring)) {
120
+ text += element.xml.slice(at, child.start);
121
+ at = child.end;
80
122
  }
81
- text += xml.slice(at, range.end);
123
+ text += element.xml.slice(at, element.range.end);
82
124
  return text.replace(/\s+/g, ' ').trim();
83
125
  }
84
126
  /**
85
- * Graft the edited element's wiring onto the original element's text.
86
- *
87
- * The edited wiring is written where the original's first wiring child sat, so the
88
- * children stay in schema order (`extensionElements` before `incoming`/`outgoing`). An
89
- * element with no wiring in either document is returned untouched.
127
+ * The wiring an element ends with, three ways by entry: the original's, less what the
128
+ * edit dropped from the baseline's, plus what it added. The round trip lists every flow
129
+ * an element is an end of, which an original may not, so a whole-list comparison would
130
+ * rewire elements nobody touched. Undefined when the answer is the original's own list.
131
+ * The schema wants every `incoming` before any `outgoing`, so an added entry goes with its kind.
90
132
  */
91
- function withWiringFrom(originalXml, original, editedXml, edited) {
92
- const originalWiring = original.children.filter((c) => WIRING.has(c.local));
93
- const editedWiring = edited.children.filter((c) => WIRING.has(c.local));
94
- // When the wiring is the same, the element is returned untouched — no reflowing. This
95
- // is not an optimisation: splicing identical content back in still rewrites the
96
- // element's whitespace, and an original that put `<bpmn:incoming/>` and
97
- // `<bpmn:outgoing/>` on one line would come back on two. That is a diff on an element
98
- // nobody edited, which is the one thing this function exists to avoid.
99
- const wiringOf = (xml, w) => w.map((c) => `${c.local}=${xml.slice(c.innerStart, c.innerEnd).trim()}`).join(',');
100
- if (wiringOf(originalXml, originalWiring) === wiringOf(editedXml, editedWiring)) {
101
- return textOf(originalXml, original);
102
- }
103
- const replacement = editedWiring.map((w) => editedXml.slice(w.start, w.end));
104
- // Indentation of the first wiring child, so the grafted lines sit where the old ones did.
105
- const anchor = originalWiring[0];
106
- const indent = anchor
107
- ? (/([ \t]*)$/.exec(originalXml.slice(original.innerStart, anchor.start))?.[1] ?? '')
108
- : '';
109
- if (originalWiring.length === 0) {
110
- // Nothing to replace: insert before the close tag.
111
- const head = originalXml.slice(original.start, original.innerEnd);
112
- const tail = originalXml.slice(original.innerEnd, original.end);
113
- const block = replacement.map((w) => `${indent} ${w}\n`).join('');
114
- return `${head}${block}${indent}${tail}`;
115
- }
116
- let text = '';
117
- let at = original.start;
118
- let first = true;
119
- for (const w of originalWiring) {
120
- text += originalXml.slice(at, w.start);
121
- if (first) {
122
- text += replacement.join(`\n${indent}`);
123
- first = false;
124
- }
125
- else {
126
- // A second or later wiring child: its own text is dropped, and so is the
127
- // separator that preceded it, which the slice above already added.
128
- text = text.replace(/\s+$/, '');
129
- }
130
- at = w.end;
131
- }
132
- text += originalXml.slice(at, original.end);
133
- return text;
133
+ function mergedWiring(original, baseline, edited) {
134
+ const entriesOf = (element) => element.range.children.filter(isWiring).map((child) => ({
135
+ key: `${child.local}=${element.xml.slice(child.innerStart, child.innerEnd).trim()}`,
136
+ text: textOf(element.xml, child),
137
+ }));
138
+ const keys = (entries) => entries.map((entry) => entry.key).join(',');
139
+ const originalEntries = entriesOf(original);
140
+ const editedEntries = entriesOf(edited);
141
+ if (keys(originalEntries) === keys(editedEntries))
142
+ return undefined;
143
+ const inEdited = new Set(editedEntries.map((entry) => entry.key));
144
+ const inBaseline = new Set(entriesOf(baseline).map((entry) => entry.key));
145
+ const kept = originalEntries.filter((entry) => inEdited.has(entry.key) || !inBaseline.has(entry.key));
146
+ const keptKeys = new Set(kept.map((entry) => entry.key));
147
+ const merged = [...kept, ...editedEntries.filter((entry) => !inBaseline.has(entry.key) && !keptKeys.has(entry.key))];
148
+ if (keys(merged) === keys(originalEntries))
149
+ return undefined;
150
+ const isOutgoing = (entry) => Number(entry.key.startsWith('outgoing='));
151
+ return merged.sort((x, y) => isOutgoing(x) - isOutgoing(y)).map((entry) => entry.text);
134
152
  }
135
153
  /**
136
- * Children of the process-level `bpmn:extensionElements` that the BUILDER can author, and
137
- * which therefore come from the EDITED document.
138
- *
139
- * This is the whole rule for that block, which has no id and so cannot be merged by key:
140
- * anything the builder models is the author's to change, and anything it does not model is
141
- * preserve-only and comes from the original untouched. Nothing here needs to know what
142
- * these payloads mean — only who owns them.
154
+ * Children of the process-level `bpmn:extensionElements` the builder can author. Used only
155
+ * by the two-way merge: with no baseline there is no telling whether the author changed
156
+ * one, so ownership decides. An authored child comes from the edited document, and
157
+ * anything the builder does not model comes from the original.
143
158
  *
144
- * It started as `variables` alone, which was too narrow and wrong in a way worth
145
- * recording: a task that sanitized a `uipath:binding` default — replacing a real
146
- * connection id with a placeholder — produced a merged file with the private value still
147
- * in it, because bindings were treated as preserve-only. The author had changed them and
148
- * the merge quietly put them back. Metadata is on the list for the same reason: `.metadata()`
149
- * has been authorable since #474, so a changed `migrationVersion` must land too.
159
+ * `bindings` is on the list for a reason worth recording: a task that sanitized a
160
+ * `uipath:binding` default, replacing a real connection id with a placeholder, produced a
161
+ * merged file with the private value still in it while bindings were preserve-only.
150
162
  */
151
163
  const AUTHORED_EXTENSIONS = new Set([
152
164
  'variables',
@@ -156,138 +168,494 @@ const AUTHORED_EXTENSIONS = new Set([
156
168
  'tags',
157
169
  'caseManagement',
158
170
  ]);
159
- /**
160
- * Merge the process-level `bpmn:extensionElements`.
161
- *
162
- * Same delta logic as the elements: an authorable child is taken from the edited document
163
- * only when the author actually CHANGED it, judged against the baseline. Ownership alone
164
- * is not enough — taking every authorable child from the edited document rewrote ones
165
- * nobody touched, and `caseManagement`'s `<![CDATA[…]]>` came back as plain text. Same
166
- * value, but a diff on a block the author never opened, which is the thing this file
167
- * exists to avoid.
168
- *
169
- * With no baseline there is nothing to compare against, so an authorable child comes from
170
- * the edited document — the same fallback the two-way element merge makes.
171
- */
172
- function mergeExtensionElements(originalXml, original, editedXml, edited, baselineXml, baseline) {
171
+ function twoWayExtensionElements(originalXml, original, editedXml, edited) {
173
172
  if (!original)
174
173
  return edited ? textOf(editedXml, edited) : undefined;
175
174
  if (!edited)
176
175
  return textOf(originalXml, original);
177
- const baselineByLocal = new Map((baseline?.children ?? []).map((c) => [c.local, c]));
178
- const flat = (xml, r) => xml.slice(r.start, r.end).replace(/\s+/g, ' ').trim();
179
- /** Did the author change this child, relative to a faithful round trip of the original? */
180
- const changed = (child) => {
181
- if (!baselineXml)
182
- return true;
183
- const base = baselineByLocal.get(child.local);
184
- if (!base)
185
- return true;
186
- return flat(editedXml, child) !== flat(baselineXml, base);
187
- };
188
- const editedByLocal = new Map(edited.children.filter((c) => changed(c)).map((c) => [c.local, c]));
189
- const seen = new Set();
190
- // Walk the ORIGINAL's children so its order and formatting lead, substituting each
191
- // authored child with the edited document's version of it.
176
+ const editedByLocal = new Map(edited.children.map((c) => [c.local, c]));
192
177
  let body = '';
193
178
  let at = original.innerStart;
194
179
  for (const child of original.children) {
195
180
  body += originalXml.slice(at, child.start);
196
- const replacement = AUTHORED_EXTENSIONS.has(child.local)
197
- ? editedByLocal.get(child.local)
198
- : undefined;
199
- if (replacement) {
200
- body += editedXml.slice(replacement.start, replacement.end);
201
- seen.add(child.local);
181
+ const replacement = AUTHORED_EXTENSIONS.has(child.local) ? editedByLocal.get(child.local) : undefined;
182
+ body += replacement ? textOf(editedXml, replacement) : textOf(originalXml, child);
183
+ at = child.end;
184
+ }
185
+ body += originalXml.slice(at, original.innerEnd);
186
+ const indent = lineIndent(originalXml, original.children[0]?.start ?? original.innerEnd);
187
+ for (const child of edited.children) {
188
+ if (AUTHORED_EXTENSIONS.has(child.local) && !original.children.some((c) => c.local === child.local)) {
189
+ body += `${indent}${textOf(editedXml, child)}\n`;
190
+ }
191
+ }
192
+ return originalXml.slice(original.start, original.innerStart) + body + originalXml.slice(original.innerEnd, original.end);
193
+ }
194
+ /** How many of `ranges` carry each `name`. */
195
+ function nameCounts(ranges) {
196
+ const counts = new Map();
197
+ for (const range of ranges) {
198
+ const name = range.attributes.name;
199
+ if (name !== undefined)
200
+ counts.set(name, (counts.get(name) ?? 0) + 1);
201
+ }
202
+ return counts;
203
+ }
204
+ /**
205
+ * Pair two sibling lists: by id, then by a `name` unique on both sides, then by position
206
+ * among siblings of the same kind. The last two never pair two elements that both have an
207
+ * id, since those are different elements; they exist for what compile writes with no id
208
+ * or a different one (event definitions, `uipath:input` rows, `extensionElements`).
209
+ */
210
+ function pair(left, right) {
211
+ const pairs = new Map();
212
+ const taken = new Set();
213
+ const take = (x, y) => {
214
+ pairs.set(x, y);
215
+ taken.add(y);
216
+ };
217
+ const rightById = new Map();
218
+ for (const y of right)
219
+ if (y.id !== undefined)
220
+ rightById.set(y.id, [...(rightById.get(y.id) ?? []), y]);
221
+ for (const x of left) {
222
+ if (x.id === undefined)
223
+ continue;
224
+ const candidates = (rightById.get(x.id) ?? []).filter((y) => !taken.has(y));
225
+ if (candidates.length === 1)
226
+ take(x, candidates[0]);
227
+ }
228
+ const free = (x, y) => !pairs.has(x) && !taken.has(y) && y.local === x.local && (x.id === undefined || y.id === undefined);
229
+ const leftNames = nameCounts(left);
230
+ const rightNames = nameCounts(right);
231
+ const rightByName = new Map();
232
+ for (const y of right)
233
+ if (y.attributes.name !== undefined && !rightByName.has(y.attributes.name))
234
+ rightByName.set(y.attributes.name, y);
235
+ for (const x of left) {
236
+ const name = x.attributes.name;
237
+ if (name === undefined || leftNames.get(name) !== 1 || rightNames.get(name) !== 1)
238
+ continue;
239
+ const y = rightByName.get(name);
240
+ if (free(x, y))
241
+ take(x, y);
242
+ }
243
+ for (const x of left) {
244
+ if (pairs.has(x))
245
+ continue;
246
+ const y = right.find((candidate) => free(x, candidate));
247
+ if (y)
248
+ take(x, y);
249
+ }
250
+ return pairs;
251
+ }
252
+ const invert = (m) => new Map([...m].map(([k, v]) => [v, k]));
253
+ const ATTRIBUTE_AT = /(\s+)([^\s=/>]+)\s*=\s*("[^"]*"|'[^']*')/g;
254
+ /** Three-way merge of one element's attributes, in the original's order with additions last. */
255
+ function mergeAttributes(original, baseline, edited) {
256
+ const merged = new Map();
257
+ const chosen = (name) => (edited[name] === baseline[name] ? original[name] : edited[name]);
258
+ for (const name of Object.keys(original)) {
259
+ const value = chosen(name);
260
+ if (value !== undefined)
261
+ merged.set(name, value);
262
+ }
263
+ for (const name of Object.keys(edited))
264
+ if (!Object.hasOwn(original, name) && edited[name] !== baseline[name])
265
+ merged.set(name, edited[name]);
266
+ return merged;
267
+ }
268
+ /** The original open tag, patched to carry `attributes`: same spelling for every attribute it keeps. */
269
+ function openTag(original, attributes, selfClosing) {
270
+ const { xml, range } = original;
271
+ const tag = xml.slice(range.start, range.innerStart);
272
+ const wasSelfClosing = range.innerStart === range.end;
273
+ const own = Object.entries(range.attributes);
274
+ if (selfClosing === wasSelfClosing && own.length === attributes.size && own.every(([k, v]) => attributes.get(k) === v))
275
+ return tag;
276
+ const quoted = (v) => `"${v.replace(/"/g, '&quot;')}"`;
277
+ const text = tag.replace(ATTRIBUTE_AT, (all, space, k) => {
278
+ if (!attributes.has(k))
279
+ return '';
280
+ const v = attributes.get(k);
281
+ return v === range.attributes[k] ? all : `${space}${k}=${quoted(v)}`;
282
+ });
283
+ const end = /\s*\/?>$/.exec(text)[0];
284
+ const adds = [...attributes].filter(([k]) => !Object.hasOwn(range.attributes, k)).map(([k, v]) => ` ${k}=${quoted(v)}`).join('');
285
+ const close = selfClosing === wasSelfClosing ? end : selfClosing ? ' />' : '>';
286
+ return `${text.slice(0, text.length - end.length)}${adds}${close}`;
287
+ }
288
+ const ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
289
+ /** Edited text in the original's spelling: a CDATA body stays CDATA. */
290
+ function asWritten(original, edited) {
291
+ const cdata = /^(\s*)<!\[CDATA\[[\s\S]*\]\]>(\s*)$/.exec(original);
292
+ if (!cdata || edited.includes('<![CDATA['))
293
+ return edited;
294
+ const text = edited.replace(/&(#x[0-9a-f]+|#\d+|\w+);/gi, (all, name) => {
295
+ if (name[0] === '#')
296
+ return String.fromCodePoint(Number(name[1]?.toLowerCase() === 'x' ? `0${name.slice(1)}` : name.slice(1)));
297
+ return ownEntry(ENTITIES, name) ?? all;
298
+ });
299
+ return text.includes(']]>') ? edited : `${cdata[1]}<![CDATA[${text}]]>${cdata[2]}`;
300
+ }
301
+ /** The element's text with some children cut (each with the whitespace before it) or replaced. */
302
+ function spliceChildren(element, edits) {
303
+ const { xml, range } = element;
304
+ let text = '';
305
+ let at = range.start;
306
+ for (const child of range.children) {
307
+ if (!edits.has(child))
308
+ continue;
309
+ const replacement = edits.get(child);
310
+ const from = replacement === '' ? whitespaceStart(xml, child.start) : child.start;
311
+ text += xml.slice(at, Math.max(at, from)) + replacement;
312
+ at = child.end;
313
+ }
314
+ return text + xml.slice(at, range.end);
315
+ }
316
+ /**
317
+ * What is left of an original child whose baseline counterpart the edit no longer writes.
318
+ * An element with an id, or a row the baseline also has, was deleted. A container is
319
+ * not, as such: compile writes none when it has nothing to put in it. What goes is what
320
+ * the baseline had in it; the children the round trip does not carry stay, and so does
321
+ * a shell the original wrote empty. A container the edit emptied goes, as compile's does.
322
+ */
323
+ function remnant(original, baseline) {
324
+ if (original.range.id !== undefined)
325
+ return '';
326
+ if (original.range.children.length === 0)
327
+ return baseline.range.children.length === 0 ? '' : textOf(original.xml, original.range);
328
+ const originalToBaseline = pair(original.range.children, baseline.range.children);
329
+ const edits = new Map();
330
+ let kept = 0;
331
+ for (const child of original.range.children) {
332
+ const counterpart = originalToBaseline.get(child);
333
+ const rest = counterpart ? remnant(locate(original, child), locate(baseline, counterpart)) : textOf(original.xml, child);
334
+ if (counterpart)
335
+ edits.set(child, rest);
336
+ if (rest)
337
+ kept += 1;
338
+ }
339
+ return kept ? spliceChildren(original, edits) : '';
340
+ }
341
+ /**
342
+ * The part of a child the original lacks that the edit changed: nothing when the edit
343
+ * left the round trip's version alone, else the edited element with every child it
344
+ * shares unchanged with the baseline cut.
345
+ */
346
+ function changedPart(baseline, edited) {
347
+ if (withoutWiring(edited) === withoutWiring(baseline))
348
+ return '';
349
+ if (edited.range.children.length === 0 || baseline.range.children.length === 0)
350
+ return textOf(edited.xml, edited.range);
351
+ const editedToBaseline = invert(pair(baseline.range.children, edited.range.children));
352
+ const edits = new Map();
353
+ for (const child of edited.range.children) {
354
+ const counterpart = editedToBaseline.get(child);
355
+ if (counterpart)
356
+ edits.set(child, changedPart(locate(baseline, counterpart), locate(edited, child)));
357
+ }
358
+ if ([...edits.values()].every((t) => t === '') && edits.size === edited.range.children.length) {
359
+ // Every edited child is the baseline's: what changed is the element's own attributes,
360
+ // or the edit only removed something, which takes nothing from an original without it.
361
+ const own = (element) => JSON.stringify(Object.entries(element.range.attributes).sort());
362
+ return own(edited) === own(baseline) ? '' : textOf(edited.xml, edited.range);
363
+ }
364
+ return spliceChildren(edited, edits);
365
+ }
366
+ /**
367
+ * One element, three ways. Untouched (the edit equals the baseline, wiring aside) with
368
+ * its wiring unchanged, it is the original's text. Otherwise it is rebuilt from the
369
+ * original one level down: each attribute, child and text the edit left as the baseline
370
+ * has it stays the original's, the wiring is merged by entry, and only what the edit
371
+ * changed comes from the edited document.
372
+ */
373
+ function mergeNode(original, baseline, edited, ctx) {
374
+ if (!baseline)
375
+ return textOf(edited.xml, edited.range);
376
+ const wiring = mergedWiring(original, baseline, edited);
377
+ if (!wiring && withoutWiring(edited) === withoutWiring(baseline)) {
378
+ const processes = original.range.local === 'process' ? [original.range] : original.range.children.filter((child) => child.local === 'process');
379
+ for (const process of processes)
380
+ for (const child of process.children)
381
+ if (child.id !== undefined && !isWiring(child))
382
+ ctx.preserved.push(child.id);
383
+ return textOf(original.xml, original.range);
384
+ }
385
+ const attributes = mergeAttributes(original.range.attributes, baseline.range.attributes, edited.range.attributes);
386
+ const leaf = (element) => element.range.children.length === 0;
387
+ const inner = (element) => element.xml.slice(element.range.innerStart, element.range.innerEnd);
388
+ const body = leaf(original) && leaf(baseline) && leaf(edited)
389
+ ? (inner(edited) === inner(baseline) ? inner(original) : asWritten(inner(original), inner(edited)))
390
+ : mergeChildren(original, baseline, edited, wiring, ctx);
391
+ if (body === '')
392
+ return openTag(original, attributes, true);
393
+ const { range } = original;
394
+ const close = range.innerStart === range.end ? `</${range.name}>` : original.xml.slice(range.innerEnd, range.end);
395
+ return `${openTag(original, attributes, false)}${body}${close}`;
396
+ }
397
+ /**
398
+ * The children of an element the merge rebuilds, from the original's: in the original's
399
+ * order and spacing, each child the edit kept merged three ways, `wiring` where the
400
+ * original's wiring sat, and what the edit added placed among its neighbours the way the
401
+ * edited document orders them.
402
+ */
403
+ function mergeChildren(original, baseline, edited, wiring, ctx) {
404
+ const originalChildren = original.range.children;
405
+ const editedChildren = edited.range.children;
406
+ const originalPayload = originalChildren.filter((child) => !isWiring(child));
407
+ const baselinePayload = baseline.range.children.filter((child) => !isWiring(child));
408
+ const editedPayload = editedChildren.filter((child) => !isWiring(child));
409
+ const originalToBaseline = pair(originalPayload, baselinePayload);
410
+ const baselineToOriginal = invert(originalToBaseline);
411
+ const editedToBaseline = invert(pair(baselinePayload, editedPayload));
412
+ // Neither side has a baseline counterpart: the round trip drops the original's, and the edit wrote one.
413
+ const editedToOriginalDirect = invert(pair(originalPayload.filter((child) => !originalToBaseline.has(child)), editedPayload.filter((child) => !editedToBaseline.has(child))));
414
+ const editedToOriginal = new Map();
415
+ for (const child of editedPayload) {
416
+ const base = editedToBaseline.get(child);
417
+ const counterpart = base ? baselineToOriginal.get(base) : editedToOriginalDirect.get(child);
418
+ if (counterpart)
419
+ editedToOriginal.set(child, counterpart);
420
+ }
421
+ const originalToEdited = invert(editedToOriginal);
422
+ const editedIndex = new Map(editedChildren.map((child, index) => [child, index]));
423
+ const reportsIds = original.range.local === 'process';
424
+ // Each piece is a child's text and the whitespace before it, with the edited child's
425
+ // position when it has one, which is where an added sibling is placed against it.
426
+ const firstEditedWiring = editedChildren.findIndex(isWiring);
427
+ const pieces = [];
428
+ let wiringPlaced = !wiring;
429
+ let at = original.range.innerStart;
430
+ for (const child of originalChildren) {
431
+ const lead = original.xml.slice(at, child.start);
432
+ at = child.end;
433
+ if (isWiring(child)) {
434
+ if (!wiring)
435
+ pieces.push({ lead, text: textOf(original.xml, child) });
436
+ else if (!wiringPlaced) {
437
+ // The merged wiring replaces all of the original's, written where its first entry sat.
438
+ wiringPlaced = true;
439
+ const next = `\n${lineIndent(original.xml, child.start)}`;
440
+ wiring.forEach((text, k) => pieces.push({ lead: k ? next : lead, text, editedAt: firstEditedWiring }));
441
+ }
442
+ continue;
202
443
  }
203
- else if (AUTHORED_EXTENSIONS.has(child.local) &&
204
- baselineByLocal.has(child.local) &&
205
- !edited.children.some((c) => c.local === child.local)) {
206
- // The author DELETED it (e.g. dropped every binding): the baseline had it and the
207
- // edit does not. Skip it, and the whitespace that preceded it, so no blank line is
208
- // left behind. Requires a baseline — without one, absence is indistinguishable from
209
- // a child the round trip never carried.
210
- body = body.replace(/\s+$/, '');
211
- seen.add(child.local);
444
+ const counterpart = originalToEdited.get(child);
445
+ if (counterpart) {
446
+ const base = editedToBaseline.get(counterpart);
447
+ if (reportsIds && child.id !== undefined) {
448
+ const untouched = base !== undefined && withoutWiring(locate(edited, counterpart)) === withoutWiring(locate(baseline, base));
449
+ (untouched ? ctx.preserved : ctx.authored).push(child.id);
450
+ }
451
+ const text = mergeNode(locate(original, child), base && locate(baseline, base), locate(edited, counterpart), ctx);
452
+ pieces.push({ lead, text, editedAt: editedIndex.get(counterpart) });
453
+ }
454
+ else if (!originalToBaseline.has(child)) {
455
+ // The round trip does not carry it, so the edit could not have removed it. Except a
456
+ // binding row: decompile drops one nothing reads, and a bindings block the edit
457
+ // changed is where that cleanup reaches the file (RULE:bpmn.brownfield.unused-bindings).
458
+ if (original.range.local !== 'bindings')
459
+ pieces.push({ lead, text: textOf(original.xml, child) });
212
460
  }
213
461
  else {
214
- body += originalXml.slice(child.start, child.end);
462
+ const rest = remnant(locate(original, child), locate(baseline, originalToBaseline.get(child)));
463
+ if (rest)
464
+ pieces.push({ lead, text: rest });
215
465
  }
216
- at = child.end;
217
466
  }
218
- body += originalXml.slice(at, original.innerEnd);
219
- // Authored children the original did not have at all — the author ADDED a block.
220
- const indent = /([ \t]*)$/.exec(originalXml.slice(original.innerStart, original.children[0]?.start ?? original.innerEnd))?.[1] ?? '';
221
- for (const child of edited.children) {
222
- if (AUTHORED_EXTENSIONS.has(child.local) && !seen.has(child.local) && !original.children.some((c) => c.local === child.local)) {
223
- body += `${indent}${editedXml.slice(child.start, child.end)}\n`;
467
+ let tail = original.xml.slice(at, original.range.innerEnd);
468
+ // What the round trip writes and the edit left alone is not the author's; only a changed part of it is.
469
+ const added = editedChildren.flatMap((child, index) => {
470
+ if (isWiring(child) || editedToOriginal.has(child))
471
+ return [];
472
+ const base = editedToBaseline.get(child);
473
+ const text = base ? changedPart(locate(baseline, base), locate(edited, child)) : textOf(edited.xml, child);
474
+ return text ? [{ child, index, text }] : [];
475
+ });
476
+ if (wiring && !wiringPlaced)
477
+ added.push(...wiring.map((text) => ({ child: editedChildren[firstEditedWiring], index: firstEditedWiring, text })));
478
+ added.sort((x, y) => x.index - y.index);
479
+ if (!added.length)
480
+ return pieces.map((piece) => piece.lead + piece.text).join('') + tail;
481
+ const parentIndent = lineIndent(original.xml, original.range.start);
482
+ const indent = originalChildren.length ? lineIndent(original.xml, originalChildren[0].start) : `${parentIndent} `;
483
+ if (!originalChildren.length)
484
+ tail = `\n${parentIndent}`;
485
+ // An added child goes after the last piece the edited document puts before it, else
486
+ // before the first it puts after it, else last; children added to one slot keep the
487
+ // edited order. `anchors` are the pieces by edited position, so one pass places them all.
488
+ const anchors = pieces.flatMap((piece, k) => (piece.editedAt === undefined ? [] : [{ editedAt: piece.editedAt, k }]))
489
+ .sort((x, y) => x.editedAt - y.editedAt);
490
+ const firstAnchorFrom = anchors.map((anchor) => anchor.k);
491
+ for (let j = firstAnchorFrom.length - 2; j >= 0; j--)
492
+ firstAnchorFrom[j] = Math.min(firstAnchorFrom[j], firstAnchorFrom[j + 1]);
493
+ const slots = new Map();
494
+ let passed = 0;
495
+ let lastBefore = -1;
496
+ for (const { child, index, text } of added) {
497
+ if (reportsIds && child.id !== undefined)
498
+ ctx.authored.push(child.id);
499
+ while (passed < anchors.length && anchors[passed].editedAt < index)
500
+ lastBefore = Math.max(lastBefore, anchors[passed++].k);
501
+ const slot = lastBefore >= 0 ? lastBefore + 1 : passed < anchors.length ? firstAnchorFrom[passed] : pieces.length;
502
+ slots.set(slot, [...(slots.get(slot) ?? []), `\n${indent}${text}`]);
503
+ }
504
+ const placed = (slot) => (slots.get(slot) ?? []).join('');
505
+ return pieces.map((piece, k) => placed(k) + piece.lead + piece.text).join('') + placed(pieces.length) + tail;
506
+ }
507
+ /** `xmlns:` declarations the edited root makes and the merged root lacks, for prefixes the merged text uses. */
508
+ function withDeclarations(merged, editedXml) {
509
+ const root = parseXmlRanges(merged)[0];
510
+ const editedRoot = parseXmlRanges(editedXml)[0];
511
+ if (!root || !editedRoot)
512
+ return merged;
513
+ const missing = Object.entries(editedRoot.attributes).filter(([k]) => {
514
+ if (!k.startsWith('xmlns:') || Object.hasOwn(root.attributes, k))
515
+ return false;
516
+ return new RegExp(`[<\\s"]${k.slice(6).replace(/[.-]/g, '\\$&')}:`).test(merged.slice(root.innerStart));
517
+ });
518
+ if (!missing.length)
519
+ return merged;
520
+ const tag = merged.slice(root.start, root.innerStart);
521
+ const end = /\s*\/?>$/.exec(tag)[0];
522
+ const decl = missing.map(([k, v]) => ` ${k}="${v}"`).join('');
523
+ return merged.slice(0, root.start) + tag.slice(0, tag.length - end.length) + decl + end + merged.slice(root.innerStart);
524
+ }
525
+ /** Every id under the document's processes and their siblings, outside the diagram. */
526
+ function modelIds(xml) {
527
+ const out = new Set();
528
+ const visit = (r) => {
529
+ if (r.local === 'BPMNDiagram')
530
+ return;
531
+ if (r.id !== undefined)
532
+ out.add(r.id);
533
+ r.children.forEach(visit);
534
+ };
535
+ for (const root of parseXmlRanges(xml))
536
+ root.children.forEach(visit);
537
+ return out;
538
+ }
539
+ /** Every element under `nodes` with the given local name. */
540
+ function allByLocal(nodes, local) {
541
+ return nodes.flatMap((n) => [...(n.local === local ? [n] : []), ...allByLocal(n.children, local)]);
542
+ }
543
+ /**
544
+ * Drop the diagram elements, in every diagram, that draw something the merge removed,
545
+ * and the edges that start or end at one of them.
546
+ */
547
+ function withoutDiagramFor(merged, kept, originalIds) {
548
+ const planes = allByLocal(parseXmlRanges(merged), 'BPMNPlane');
549
+ const all = planes.flatMap((p) => p.children);
550
+ const gone = new Set(all.filter((di) => {
551
+ const ref = di.attributes.bpmnElement;
552
+ return ref !== undefined && originalIds.has(ref) && !kept.has(ref);
553
+ }));
554
+ const goneIds = new Set([...gone].map((di) => di.id).filter((id) => id !== undefined));
555
+ for (const di of all) {
556
+ if ([di.attributes.sourceElement, di.attributes.targetElement].some((ref) => ref !== undefined && goneIds.has(ref)))
557
+ gone.add(di);
558
+ }
559
+ return replaceRanges(merged, [...gone].map((range) => ({ range, text: '' })));
560
+ }
561
+ /**
562
+ * A designer-saved file can keep the process's `uipath:variables` and `uipath:bindings` on
563
+ * its collaboration, where compile writes them on the process. Such a child is merged where
564
+ * the original keeps it, three ways against the copy the baseline and the edited document
565
+ * write on the process, and that copy is then cut from both, so the process merge neither
566
+ * adds it a second time nor reads its absence from the original's process as a delete.
567
+ */
568
+ function mergeCollaborationExtensions(documents, ctx) {
569
+ const [originalRoot] = parseXmlRanges(documents.original);
570
+ const [baselineRoot] = parseXmlRanges(documents.baseline);
571
+ const [editedRoot] = parseXmlRanges(documents.edited);
572
+ if (!originalRoot || !baselineRoot || !editedRoot)
573
+ return documents;
574
+ const extensionsOf = (range) => range?.children.find((c) => c.local === 'extensionElements');
575
+ const processIn = (root, id) => root.children.find((c) => c.local === 'process' && c.id === id);
576
+ const merges = [];
577
+ const baselineCuts = [];
578
+ const editedCuts = [];
579
+ for (const collaboration of originalRoot.children.filter((c) => c.local === 'collaboration')) {
580
+ const carrier = extensionsOf(collaboration);
581
+ if (!carrier)
582
+ continue;
583
+ for (const participant of collaboration.children.filter((c) => c.local === 'participant')) {
584
+ const processRef = participant.attributes.processRef;
585
+ const originalProcess = processIn(originalRoot, processRef);
586
+ const baselineExtensions = extensionsOf(processIn(baselineRoot, processRef));
587
+ if (!originalProcess || !baselineExtensions)
588
+ continue;
589
+ const own = extensionsOf(originalProcess);
590
+ const editedExtensions = extensionsOf(processIn(editedRoot, processRef));
591
+ const held = carrier.children.filter((child) => baselineExtensions.children.some((b) => b.local === child.local) && !own?.children.some((o) => o.local === child.local));
592
+ if (!held.length)
593
+ continue;
594
+ for (const child of held) {
595
+ const original = { xml: documents.original, range: child };
596
+ const baseline = { xml: documents.baseline, range: baselineExtensions.children.find((b) => b.local === child.local) };
597
+ const editedCopy = editedExtensions?.children.find((e) => e.local === child.local);
598
+ const text = editedCopy ? mergeNode(original, baseline, { xml: documents.edited, range: editedCopy }, ctx) : remnant(original, baseline);
599
+ merges.push({ range: child, text });
600
+ baselineCuts.push({ range: baseline.range, text: '' });
601
+ if (editedCopy)
602
+ editedCuts.push({ range: editedCopy, text: '' });
603
+ }
604
+ break;
224
605
  }
225
606
  }
226
- return (originalXml.slice(original.start, original.innerStart) +
227
- body +
228
- originalXml.slice(original.innerEnd, original.end));
607
+ if (!merges.length)
608
+ return documents;
609
+ return {
610
+ original: replaceRanges(documents.original, merges),
611
+ baseline: replaceRanges(documents.baseline, baselineCuts),
612
+ edited: replaceRanges(documents.edited, editedCuts),
613
+ };
229
614
  }
230
- export function mergeBpmn(originalXml, editedXml, options = {}) {
615
+ function twoWay(originalXml, editedXml) {
231
616
  const originalProcess = processOf(originalXml, 'original');
232
617
  const editedProcess = processOf(editedXml, 'edited');
233
- const baselineProcess = options.baseline ? processOf(options.baseline, 'baseline') : undefined;
234
- const byId = (p) => new Map(p.children.filter((c) => c.id !== undefined).map((c) => [c.id, c]));
235
- const originalById = byId(originalProcess);
236
- const baselineById = baselineProcess ? byId(baselineProcess) : new Map();
237
- const preserved = [];
618
+ const originalById = new Map(originalProcess.children.filter((c) => c.id !== undefined).map((c) => [c.id, c]));
238
619
  const authored = [];
239
620
  const kept = new Set();
240
621
  const pieces = [];
241
- const extension = mergeExtensionElements(originalXml, originalProcess.children.find((c) => c.local === 'extensionElements'), editedXml, editedProcess.children.find((c) => c.local === 'extensionElements'), options.baseline, baselineProcess?.children.find((c) => c.local === 'extensionElements'));
622
+ const extension = twoWayExtensionElements(originalXml, originalProcess.children.find((c) => c.local === 'extensionElements'), editedXml, editedProcess.children.find((c) => c.local === 'extensionElements'));
242
623
  if (extension)
243
624
  pieces.push(extension);
244
- // The EDITED process decides which elements exist and in what order; the ORIGINAL
245
- // decides the content of every element the author did not touch.
246
625
  for (const element of editedProcess.children) {
247
626
  if (element.local === 'extensionElements')
248
627
  continue;
249
- const id = element.id;
250
- const original = id !== undefined ? originalById.get(id) : undefined;
251
- const baseline = id !== undefined ? baselineById.get(id) : undefined;
252
- const untouched = original !== undefined &&
253
- baseline !== undefined &&
254
- withoutWiring(editedXml, element) === withoutWiring(options.baseline, baseline);
255
- if (untouched) {
256
- pieces.push(withWiringFrom(originalXml, original, editedXml, element));
257
- preserved.push(id);
258
- }
259
- else {
260
- pieces.push(textOf(editedXml, element));
261
- if (id !== undefined)
262
- authored.push(id);
628
+ pieces.push(textOf(editedXml, element));
629
+ if (element.id !== undefined) {
630
+ authored.push(element.id);
631
+ kept.add(element.id);
263
632
  }
264
- if (id !== undefined)
265
- kept.add(id);
266
633
  }
267
634
  const removed = [...originalById.keys()].filter((id) => !kept.has(id));
268
- // Splice the rebuilt process body into the ORIGINAL document, so the prolog,
269
- // `bpmn:definitions` attributes and namespace declarations are the original's.
270
- const indent = /([ \t]*)$/.exec(originalXml.slice(originalProcess.innerStart, originalProcess.children[0]?.start ?? originalProcess.innerEnd))?.[1] ?? ' ';
635
+ const indent = lineIndent(originalXml, originalProcess.children[0]?.start ?? originalProcess.innerEnd) || ' ';
271
636
  const body = `\n${indent}${pieces.join(`\n${indent}`)}\n${indent.slice(0, -2)}`;
272
- let merged = originalXml.slice(0, originalProcess.innerStart) +
273
- body +
274
- originalXml.slice(originalProcess.innerEnd);
275
- // The diagram: the original's layout survives, which is strictly better than the
276
- // re-layout a bare recompile forces. Shapes and edges for deleted elements go with
277
- // them; anything newly added has no DI yet, which is what `format` is for.
278
- if (removed.length > 0) {
279
- const plane = findByLocal(parseXmlRanges(merged), 'BPMNPlane');
280
- if (plane) {
281
- const gone = plane.children.filter((di) => {
282
- const ref = di.attributes.bpmnElement;
283
- return ref !== undefined && !kept.has(ref);
284
- });
285
- for (const di of [...gone].reverse()) {
286
- // Take the whitespace before it too, so no blank line is left behind.
287
- const from = /\s*$/.exec(merged.slice(0, di.start))?.index ?? di.start;
288
- merged = merged.slice(0, from) + merged.slice(di.end);
289
- }
290
- }
291
- }
292
- return { xml: merged, preserved, authored, removed };
637
+ let merged = originalXml.slice(0, originalProcess.innerStart) + body + originalXml.slice(originalProcess.innerEnd);
638
+ if (removed.length > 0)
639
+ merged = withoutDiagramFor(merged, kept, new Set(removed));
640
+ return { xml: withDeclarations(merged, editedXml), preserved: [], authored, removed };
641
+ }
642
+ export function mergeBpmn(originalXml, editedXml, options = {}) {
643
+ processOf(originalXml, 'original');
644
+ processOf(editedXml, 'edited');
645
+ if (options.baseline === undefined)
646
+ return twoWay(originalXml, editedXml);
647
+ processOf(options.baseline, 'baseline');
648
+ const ctx = { preserved: [], authored: [] };
649
+ const documents = mergeCollaborationExtensions({ original: originalXml, baseline: options.baseline, edited: editedXml }, ctx);
650
+ const [original] = parseXmlRanges(documents.original);
651
+ const [baseline] = parseXmlRanges(documents.baseline);
652
+ const [edited] = parseXmlRanges(documents.edited);
653
+ const root = mergeNode({ xml: documents.original, range: original }, { xml: documents.baseline, range: baseline }, { xml: documents.edited, range: edited }, ctx);
654
+ let merged = documents.original.slice(0, original.start) + root + documents.original.slice(original.end);
655
+ const originalIds = modelIds(originalXml);
656
+ const kept = modelIds(merged);
657
+ const removed = [...originalIds].filter((id) => !kept.has(id));
658
+ if (removed.length > 0)
659
+ merged = withoutDiagramFor(merged, kept, originalIds);
660
+ return { xml: withDeclarations(merged, editedXml), preserved: ctx.preserved, authored: ctx.authored, removed };
293
661
  }