@helping-ai-workflow/md2doc 3.3.0 → 3.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -8
- package/THIRD-PARTY-NOTICES.md +44 -0
- package/lib/drawio.js +178 -0
- package/lib/editor/blockmap.js +79 -6
- package/lib/editor/client.js +3353 -157
- package/lib/editor/inline-md.js +122 -12
- package/lib/editor/lineops.js +190 -3
- package/lib/editor/paste-md.js +5 -1
- package/lib/editor/server.js +359 -5
- package/lib/editor/toolbar-model.js +51 -14
- package/lib/editor/wave-codec.js +2631 -0
- package/lib/editor/wave-geometry.js +320 -0
- package/lib/editor/wave-store.js +950 -0
- package/lib/editor/wave-ui.js +2124 -0
- package/lib/md2doc.js +677 -14
- package/package.json +14 -6
- package/vendor/drawio/LICENSE +201 -0
- package/vendor/drawio/NOTICE +109 -0
- package/vendor/drawio/README.md +25 -0
- package/vendor/drawio/viewer-static.min.js +8596 -0
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* wave-store — the WaveJSON a GUI is editing, an undo stack over it, and the
|
|
5
|
+
* bridge from "what it says now" back to the smallest patch of the source text.
|
|
6
|
+
*
|
|
7
|
+
* The store holds three things and derives everything else from them: the
|
|
8
|
+
* source text the block came from, the parse of that text (kept because its
|
|
9
|
+
* spans are what a minimal patch is written through), and a stack of documents.
|
|
10
|
+
* Every entry on that stack is a whole WaveJSON value, not a delta — the codec's
|
|
11
|
+
* operations are pure and share every subtree they did not touch, so keeping N
|
|
12
|
+
* of them costs about what keeping one of them costs, and undo is a subtraction
|
|
13
|
+
* on an index rather than an inverse operation that has to be derived and can
|
|
14
|
+
* be derived wrongly.
|
|
15
|
+
*
|
|
16
|
+
* `toPatch` does NOT replay the stack. It compares the current entry against
|
|
17
|
+
* the parse of the original text and writes the difference; nothing about how
|
|
18
|
+
* the user got there reaches the file. That is what makes an edit and its
|
|
19
|
+
* inverse cost zero bytes instead of two edits that cancel, and it is why
|
|
20
|
+
* `isDirty` can be a question about the two documents rather than about the
|
|
21
|
+
* stack — see the note on it below.
|
|
22
|
+
*
|
|
23
|
+
* Four rules this file does not get to bend:
|
|
24
|
+
*
|
|
25
|
+
* - Lane numbers are the codec's flattened display order, and the ONLY way
|
|
26
|
+
* from one to a path is `lanePath` / `lanePaths` / `laneInsertPath` /
|
|
27
|
+
* `laneRemovePath` / `laneRemovePaths`. A path built here by hand —
|
|
28
|
+
* `['signal', k]` — names a real lane whenever a group is involved, just
|
|
29
|
+
* not the one the user pointed at, and nothing downstream refuses it.
|
|
30
|
+
* - **A plan is written out, read back and compared before it is handed
|
|
31
|
+
* over.** Everything below plans from the two documents, and a planner can
|
|
32
|
+
* be wrong in ways that still produce a well-formed patch: it did, for an
|
|
33
|
+
* ordinary one-position drag, and the file then said a different lane had
|
|
34
|
+
* moved. So the last step of `toPatch` is to parse its own output and check
|
|
35
|
+
* it says what the document says. Where it does not, this refuses. That
|
|
36
|
+
* turns every planning gap there is or will be from silent corruption into
|
|
37
|
+
* an honest refusal, which is the only failure mode this layer may have.
|
|
38
|
+
* - A refusal travels upward unchanged. When the codec answers that a change
|
|
39
|
+
* cannot be written as a local patch, `toPatch` hands that `{ok:false,
|
|
40
|
+
* reason, rewroteRange}` straight back. Deciding what the user should do
|
|
41
|
+
* about it — rewrite the block, cancel, edit by hand — belongs to the
|
|
42
|
+
* layer that can ask them, and re-serialising the block behind their back
|
|
43
|
+
* is the one answer this file may never give.
|
|
44
|
+
" PLACEHOLDER
|
|
45
|
+
*
|
|
46
|
+
* A throw inside an `apply` callback is NOT caught. The store's own state is
|
|
47
|
+
* untouched when one happens (nothing is pushed until the new document is in
|
|
48
|
+
* hand), but the callback is the caller's code, and swallowing an error in it
|
|
49
|
+
* would turn their bug into this module's silent no-op. `test/wave-store.test.js`
|
|
50
|
+
* pins both halves of that.
|
|
51
|
+
*
|
|
52
|
+
* No timers, no DOM, no code built at run time: the text being edited is the
|
|
53
|
+
* user's markdown. `test/wave-store.test.js` greps this file for all three.
|
|
54
|
+
*
|
|
55
|
+
* @typedef {Array<string|number>} DocPath the shape the codec keys spans by
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
const C = require('./wave-codec.js');
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The codec's nesting ceiling, read from the codec rather than mirrored, so the
|
|
62
|
+
* two cannot drift: every recursive walk here stops where its reader stops.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_DEPTH = C.MAX_DEPTH;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Open a store over one wavedrom block's source text.
|
|
68
|
+
*
|
|
69
|
+
* A source that cannot be read back comes back as a store that refuses rather
|
|
70
|
+
* than as a throw or a null: the GUI layer above has a block on screen either
|
|
71
|
+
* way, and `ok` plus a refusing `toPatch` is something it can show.
|
|
72
|
+
*/
|
|
73
|
+
function createStore(sourceText) {
|
|
74
|
+
const parsed = C.parseSource(sourceText);
|
|
75
|
+
if (parsed.ok !== true) return refusingStore(sourceText, parsed);
|
|
76
|
+
|
|
77
|
+
const baseDoc = deepFreeze(parsed.doc, 0);
|
|
78
|
+
const basePaths = C.lanePaths(baseDoc);
|
|
79
|
+
const history = [{ opName: null, doc: baseDoc, origins: countUp(basePaths.length) }];
|
|
80
|
+
let at = 0;
|
|
81
|
+
|
|
82
|
+
/** The span of one path as a range a caller can frame, or null. */
|
|
83
|
+
function range(path) {
|
|
84
|
+
const span = parsed.spans.get(JSON.stringify(path));
|
|
85
|
+
return span === undefined
|
|
86
|
+
? null
|
|
87
|
+
: { path: path, spanPath: path, start: span[0], end: span[1] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One range covering several paths — for a refusal about more than one lane. */
|
|
91
|
+
function spanning(paths) {
|
|
92
|
+
let out = null;
|
|
93
|
+
for (const path of paths) {
|
|
94
|
+
const one = range(path);
|
|
95
|
+
if (one === null) continue;
|
|
96
|
+
if (out === null) out = one;
|
|
97
|
+
else {
|
|
98
|
+
out = {
|
|
99
|
+
path: out.path,
|
|
100
|
+
spanPath: out.spanPath,
|
|
101
|
+
start: Math.min(out.start, one.start),
|
|
102
|
+
end: Math.max(out.end, one.end),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out === null ? range([]) : out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ctx = { baseDoc: baseDoc, basePaths: basePaths, range: range, spanning: spanning };
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Run one operation and, when it changed anything, push the result.
|
|
113
|
+
*
|
|
114
|
+
* `fn` is handed the current doc and answers the next one. The codec answers
|
|
115
|
+
* the very same object when an operation did nothing — an index out of range,
|
|
116
|
+
* a brush that means nothing where it was aimed — so that identity is the
|
|
117
|
+
* test for "did this happen", and an operation that did nothing does not cost
|
|
118
|
+
* the user an undo press that appears to do nothing either.
|
|
119
|
+
*
|
|
120
|
+
* What comes back also has to be shaped like the document that went in. `{}`
|
|
121
|
+
* and `[]` are neither null nor scalars, and a store that accepted one would
|
|
122
|
+
* spend a stack slot on it and then report `ok:true` on a patch that empties
|
|
123
|
+
* the whole block — the worst possible shape for a layer whose principle is
|
|
124
|
+
* to refuse rather than rewrite.
|
|
125
|
+
*
|
|
126
|
+
* `opName` is carried on the entry and handed back by `undoName` / `redoName`.
|
|
127
|
+
* This file does not coalesce by it; the editor's own burst rules live a layer
|
|
128
|
+
* up, and they need the name to decide.
|
|
129
|
+
*/
|
|
130
|
+
function apply(opName, fn) {
|
|
131
|
+
if (typeof fn !== 'function') return false;
|
|
132
|
+
const here = history[at];
|
|
133
|
+
const next = fn(here.doc);
|
|
134
|
+
if (next === here.doc || !isDocShape(here.doc, next)) return false;
|
|
135
|
+
const adopted = adoptDocument(next, 0, new Map());
|
|
136
|
+
const origins = remapOrigins(here.doc, adopted, here.origins);
|
|
137
|
+
history.length = at + 1;
|
|
138
|
+
history.push({
|
|
139
|
+
opName: opName === undefined ? null : opName,
|
|
140
|
+
doc: adopted,
|
|
141
|
+
origins: origins,
|
|
142
|
+
});
|
|
143
|
+
at++;
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function undo() {
|
|
148
|
+
if (at === 0) return false;
|
|
149
|
+
at--;
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function redo() {
|
|
154
|
+
if (at >= history.length - 1) return false;
|
|
155
|
+
at++;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Whether the file on disk would change if it were saved now.
|
|
161
|
+
*
|
|
162
|
+
* This compares the two DOCUMENTS — the current one against the parse of the
|
|
163
|
+
* original text — and not the stack position, and not the patched text.
|
|
164
|
+
*
|
|
165
|
+
* Not the stack: an edit and the edit that undoes it leave two entries behind
|
|
166
|
+
* and a doc identical to the one that was opened, and a save would write the
|
|
167
|
+
* same bytes back. A store that called that dirty would put a "save?" prompt
|
|
168
|
+
* in front of a user who has nothing to save.
|
|
169
|
+
*
|
|
170
|
+
* Not the patched text: producing it means running the whole write-back,
|
|
171
|
+
* which is the expensive half of this file and is entitled to REFUSE. A
|
|
172
|
+
* refusal is not an answer to "has this changed" — it means the change cannot
|
|
173
|
+
* be written locally, which is emphatically a change — so a caller asking
|
|
174
|
+
* about dirtiness would have to interpret a refusal to get an answer, and the
|
|
175
|
+
* only honest interpretation is the one the document already gives directly.
|
|
176
|
+
*
|
|
177
|
+
* The two agree by construction: the diff below emits an edit exactly where
|
|
178
|
+
* the two docs differ, so no difference means no edit means the same bytes
|
|
179
|
+
* back. `sameScalar` is the codec's own rule, which is why it is repeated
|
|
180
|
+
* here rather than `Object.is`: the codec writes nothing for two values it
|
|
181
|
+
* considers the same, so calling them different here would claim a change
|
|
182
|
+
* that the patch would not write.
|
|
183
|
+
*/
|
|
184
|
+
function isDirty() {
|
|
185
|
+
return !sameValue(baseDoc, history[at].doc, 0);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The smallest patch that turns the original source into what the doc says
|
|
190
|
+
* now — or a refusal, the codec's or this file's, never a rewrite.
|
|
191
|
+
*
|
|
192
|
+
* A plan can have more than one candidate: an adjacent pair of lanes reading
|
|
193
|
+
* in swapped order is explained equally well by "the upper one was dragged
|
|
194
|
+
* down" and "the lower one was dragged up", and those two are DIFFERENT trees
|
|
195
|
+
* as soon as a group boundary sits between them. Display order cannot choose,
|
|
196
|
+
* and neither can object identity — under `moveLane` both candidates are the
|
|
197
|
+
* same object at a new position. So the choice is not made by argument at
|
|
198
|
+
* all: each candidate is written out, read back and compared against the
|
|
199
|
+
* document, and the one that reproduces it wins. None reproducing it is a
|
|
200
|
+
* refusal, not a guess.
|
|
201
|
+
*/
|
|
202
|
+
function toPatch() {
|
|
203
|
+
const here = history[at];
|
|
204
|
+
const plan = planEdits(ctx, here.doc, here.origins);
|
|
205
|
+
if (plan.ok !== true) return plan;
|
|
206
|
+
let refused = null;
|
|
207
|
+
let mismatched = null;
|
|
208
|
+
for (const edits of plan.alternatives) {
|
|
209
|
+
const written = C.patchSource(sourceText, parsed, edits);
|
|
210
|
+
if (written.ok !== true) {
|
|
211
|
+
if (refused === null) refused = written;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const back = C.parseSource(written.text);
|
|
215
|
+
if (back.ok === true && sameValue(back.doc, here.doc, 0)) return written;
|
|
216
|
+
if (mismatched === null) mismatched = refuse(NET_REASON, range([]));
|
|
217
|
+
}
|
|
218
|
+
// Nothing reproduced the document, so this is a refusal — and WHICH refusal
|
|
219
|
+
// matters, because the candidate that refused during planning is usually the
|
|
220
|
+
// reading that was actually right. A candidate whose plan named a real
|
|
221
|
+
// structural conflict says more than "the bytes did not come back the same",
|
|
222
|
+
// so it is preferred, then the codec's own words, and only then the net.
|
|
223
|
+
//
|
|
224
|
+
// When there was more than one reading, the one being described is the first
|
|
225
|
+
// by position and not necessarily the drag the user performed, so the message
|
|
226
|
+
// says that rather than asserting a gesture nobody made. Naming the "more
|
|
227
|
+
// plausible" reading instead would be a tie-break by argument, which is the
|
|
228
|
+
// thing that shipped the wrong patch in the first place.
|
|
229
|
+
if (plan.refusals.length > 0) {
|
|
230
|
+
const first = plan.refusals[0];
|
|
231
|
+
if (plan.readings < 2) return first;
|
|
232
|
+
return refuse('the lanes were reordered in a way that has more than one reading — ' +
|
|
233
|
+
'which lane was dragged cannot be told apart from the result — and none of them ' +
|
|
234
|
+
'could be written as a local patch. One reading was refused because: ' + first.reason,
|
|
235
|
+
first.rewroteRange);
|
|
236
|
+
}
|
|
237
|
+
if (refused !== null) return refused;
|
|
238
|
+
return mismatched === null ? refuse(NET_REASON, range([])) : mismatched;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const store = {
|
|
242
|
+
ok: true,
|
|
243
|
+
error: null,
|
|
244
|
+
source: sourceText,
|
|
245
|
+
apply: apply,
|
|
246
|
+
undo: undo,
|
|
247
|
+
redo: redo,
|
|
248
|
+
canUndo: function () { return at > 0; },
|
|
249
|
+
canRedo: function () { return at < history.length - 1; },
|
|
250
|
+
undoName: function () { return at > 0 ? history[at].opName : null; },
|
|
251
|
+
redoName: function () { return at < history.length - 1 ? history[at + 1].opName : null; },
|
|
252
|
+
isDirty: isDirty,
|
|
253
|
+
toPatch: toPatch,
|
|
254
|
+
};
|
|
255
|
+
Object.defineProperty(store, 'doc', {
|
|
256
|
+
enumerable: true,
|
|
257
|
+
get: function () { return history[at].doc; },
|
|
258
|
+
});
|
|
259
|
+
return store;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A store over a source that could not be read back: it refuses, it never throws. */
|
|
263
|
+
function refusingStore(sourceText, parsed) {
|
|
264
|
+
const why = 'the source could not be read back as WaveJSON (' + parsed.message +
|
|
265
|
+
'), so there is no document to edit and no local patch to write';
|
|
266
|
+
const store = {
|
|
267
|
+
ok: false,
|
|
268
|
+
error: { message: parsed.message, offset: parsed.offset },
|
|
269
|
+
source: sourceText,
|
|
270
|
+
apply: function () { return false; },
|
|
271
|
+
undo: function () { return false; },
|
|
272
|
+
redo: function () { return false; },
|
|
273
|
+
canUndo: function () { return false; },
|
|
274
|
+
canRedo: function () { return false; },
|
|
275
|
+
undoName: function () { return null; },
|
|
276
|
+
redoName: function () { return null; },
|
|
277
|
+
isDirty: function () { return false; },
|
|
278
|
+
// No span table and no edit, so there is no range to frame: `rewroteRange`
|
|
279
|
+
// is null on purpose here, and `error.offset` is where the trouble is.
|
|
280
|
+
toPatch: function () { return refuse(why, null); },
|
|
281
|
+
};
|
|
282
|
+
Object.defineProperty(store, 'doc', { enumerable: true, get: function () { return null; } });
|
|
283
|
+
return store;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** What the read-back check says when no reading of the edit reproduces the doc. */
|
|
287
|
+
const NET_REASON =
|
|
288
|
+
'the smallest patch that could be built for this change does not read back as the ' +
|
|
289
|
+
'document the editor is showing, so it was not written; this change needs more than ' +
|
|
290
|
+
'one local edit, or a whole-block rewrite';
|
|
291
|
+
|
|
292
|
+
function refuse(reason, rewroteRange) {
|
|
293
|
+
return { ok: false, reason: reason, rewroteRange: rewroteRange };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const OK = { ok: true };
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Freeze a document and everything under it, in place.
|
|
300
|
+
*
|
|
301
|
+
* Only for the parse's own document, which nobody else has ever held. A node
|
|
302
|
+
* that is already frozen was frozen by this function, which froze its children
|
|
303
|
+
* first, so it is safe to stop there.
|
|
304
|
+
*/
|
|
305
|
+
function deepFreeze(value, depth) {
|
|
306
|
+
if (value === null || typeof value !== 'object') return value;
|
|
307
|
+
if (Object.isFrozen(value) || depth > MAX_DEPTH) return value;
|
|
308
|
+
Object.freeze(value);
|
|
309
|
+
if (Array.isArray(value)) {
|
|
310
|
+
for (const item of value) deepFreeze(item, depth + 1);
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
for (const key of Object.keys(value)) deepFreeze(value[key], depth + 1);
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Take a document an operation answered with: share every node that is already
|
|
319
|
+
* frozen, copy every node that is not, and freeze the copies.
|
|
320
|
+
*
|
|
321
|
+
* The sharing is what keeps this proportional to what the operation actually
|
|
322
|
+
* rebuilt — everything it left alone is a node from the previous document and
|
|
323
|
+
* is frozen already — and it is also what keeps lane objects comparable by
|
|
324
|
+
* identity across the edit, which is what `remapOrigins` reads.
|
|
325
|
+
*
|
|
326
|
+
* The copying is for the caller. An operation written by hand can put an object
|
|
327
|
+
* the caller still owns into the document — a lane template reused for every
|
|
328
|
+
* new lane, a row held in a clipboard — and freezing that in place would leave
|
|
329
|
+
* them holding something that throws on the next write, far from here. The
|
|
330
|
+
* codec's own operations deep-copy what they are given and would not need this;
|
|
331
|
+
* hand-written ones are exactly the ones that do.
|
|
332
|
+
*
|
|
333
|
+
* A `__proto__` member is written with `defineProperty`, the same way the codec
|
|
334
|
+
* reads one, so a key like that stays data instead of reaching the prototype.
|
|
335
|
+
*
|
|
336
|
+
* `seen` makes the copy structure-preserving rather than merely tree-shaped: a
|
|
337
|
+
* node reached twice stays one node, and a value that holds itself stays a value
|
|
338
|
+
* that holds itself. Without it a cycle would come out the other side as a
|
|
339
|
+
* MAX_DEPTH-deep tree — quietly turning the one thing the codec refuses by name
|
|
340
|
+
* into something it refuses for an unrelated reason, with a message about
|
|
341
|
+
* nesting for a document the caller wrote as a loop.
|
|
342
|
+
*/
|
|
343
|
+
function adoptDocument(value, depth, seen) {
|
|
344
|
+
if (value === null || typeof value !== 'object') return value;
|
|
345
|
+
if (Object.isFrozen(value) || depth > MAX_DEPTH) return value;
|
|
346
|
+
const already = seen.get(value);
|
|
347
|
+
if (already !== undefined) return already;
|
|
348
|
+
if (Array.isArray(value)) {
|
|
349
|
+
const out = [];
|
|
350
|
+
seen.set(value, out);
|
|
351
|
+
for (const item of value) out.push(adoptDocument(item, depth + 1, seen));
|
|
352
|
+
return Object.freeze(out);
|
|
353
|
+
}
|
|
354
|
+
const out = {};
|
|
355
|
+
seen.set(value, out);
|
|
356
|
+
for (const key of Object.keys(value)) {
|
|
357
|
+
put(out, key, adoptDocument(value[key], depth + 1, seen));
|
|
358
|
+
}
|
|
359
|
+
return Object.freeze(out);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Spelled out of its own characters so nothing in this file carries it literally. */
|
|
363
|
+
const PROTO_KEY = '__' + 'proto' + '__';
|
|
364
|
+
|
|
365
|
+
function put(obj, key, value) {
|
|
366
|
+
if (key === PROTO_KEY) {
|
|
367
|
+
Object.defineProperty(obj, key, {
|
|
368
|
+
value: value, enumerable: true, writable: true, configurable: true,
|
|
369
|
+
});
|
|
370
|
+
} else {
|
|
371
|
+
obj[key] = value;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Whether what an operation answered is still a document of the same shape.
|
|
377
|
+
*
|
|
378
|
+
* A lane list that was an array has to stay one: everything below counts lanes
|
|
379
|
+
* through the codec's walk, and a `signal` that is suddenly a string or absent
|
|
380
|
+
* is not an edit of this diagram, it is a different object that happens to be
|
|
381
|
+
* an object.
|
|
382
|
+
*/
|
|
383
|
+
function isDocShape(was, next) {
|
|
384
|
+
if (next === null || typeof next !== 'object' || Array.isArray(next)) return false;
|
|
385
|
+
if (Array.isArray(was.signal) && !Array.isArray(next.signal)) return false;
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Which lane is which, across an edit
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Where each lane of `afterDoc` came from in the base document, carried one
|
|
395
|
+
* operation at a time rather than worked out at save time.
|
|
396
|
+
*
|
|
397
|
+
* It has to be carried, because after the fact the two ends are not comparable:
|
|
398
|
+
* a lane whose wave string changed is a new object at the same position, and a
|
|
399
|
+
* lane that moved is the same object at a new position. Asked about the end
|
|
400
|
+
* state alone, a diff cannot tell "lane 1 was edited" from "lane 1 was deleted
|
|
401
|
+
* and another added in its place", and the two write completely different
|
|
402
|
+
* patches.
|
|
403
|
+
*
|
|
404
|
+
* Per operation it is decidable, because the codec's operations fall into two
|
|
405
|
+
* kinds and neither is ambiguous. A content operation (`setCell`, `insertCycles`,
|
|
406
|
+
* a paste) rebuilds the lanes it touches and leaves the list's length and order
|
|
407
|
+
* alone. A structural one (`addLane`, `removeLane`, `moveLane`) carries every
|
|
408
|
+
* lane object through untouched and changes the order. So identity answers the
|
|
409
|
+
* structural ones exactly, and position answers the content ones exactly.
|
|
410
|
+
*
|
|
411
|
+
* What identity does NOT answer is which lane the user dragged when two lanes
|
|
412
|
+
* swapped: both are the same object, both are somewhere new. That question is
|
|
413
|
+
* settled in `toPatch`, by writing each reading out and reading it back.
|
|
414
|
+
*
|
|
415
|
+
* The residue — lanes matched by neither — is paired up in display order, which
|
|
416
|
+
* is right for a content operation and is a guess only for an operation that
|
|
417
|
+
* was BOTH structural and content-bearing in one call. No codec operation is,
|
|
418
|
+
* and one built by hand out of two of them should be applied as two.
|
|
419
|
+
*/
|
|
420
|
+
function remapOrigins(beforeDoc, afterDoc, beforeOrigins) {
|
|
421
|
+
const before = laneObjects(beforeDoc);
|
|
422
|
+
const after = laneObjects(afterDoc);
|
|
423
|
+
const out = new Array(after.length).fill(-1);
|
|
424
|
+
const taken = new Array(before.length).fill(false);
|
|
425
|
+
const byObject = new Map();
|
|
426
|
+
for (let i = 0; i < before.length; i++) {
|
|
427
|
+
const seen = byObject.get(before[i]);
|
|
428
|
+
if (seen === undefined) byObject.set(before[i], [i]);
|
|
429
|
+
else seen.push(i);
|
|
430
|
+
}
|
|
431
|
+
for (let j = 0; j < after.length; j++) {
|
|
432
|
+
const queue = byObject.get(after[j]);
|
|
433
|
+
if (queue !== undefined && queue.length > 0) {
|
|
434
|
+
const i = queue.shift();
|
|
435
|
+
out[j] = i;
|
|
436
|
+
taken[i] = true;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const restBefore = [];
|
|
440
|
+
for (let i = 0; i < before.length; i++) {
|
|
441
|
+
if (!taken[i]) restBefore.push(i);
|
|
442
|
+
}
|
|
443
|
+
const restAfter = [];
|
|
444
|
+
for (let j = 0; j < after.length; j++) {
|
|
445
|
+
if (out[j] === -1) restAfter.push(j);
|
|
446
|
+
}
|
|
447
|
+
const pairs = Math.min(restBefore.length, restAfter.length);
|
|
448
|
+
for (let k = 0; k < pairs; k++) out[restAfter[k]] = restBefore[k];
|
|
449
|
+
return out.map(function (i) {
|
|
450
|
+
return i === -1 ? null : beforeOrigins[i];
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Where each lane of `afterDoc` was in `beforeDoc`, as display-order indexes:
|
|
456
|
+
* `trace[j]` is the row that lane occupied before, or `null` for a row that
|
|
457
|
+
* did not exist then.
|
|
458
|
+
*
|
|
459
|
+
* `remapOrigins` above already answers this question — it is how the patch
|
|
460
|
+
* planner knows which lane is which — and this is that same answer asked one
|
|
461
|
+
* operation wide instead of session-wide. It is exported because the EDITOR
|
|
462
|
+
* needs the identical answer for a different reason: the keyboard's cell
|
|
463
|
+
* cursor and the selection are row indexes, and every operation that reorders
|
|
464
|
+
* rows moves the lane out from under them.
|
|
465
|
+
*
|
|
466
|
+
* One implementation, deliberately. The alternative is a second belief about
|
|
467
|
+
* which lane is which living in the UI, and this batch has now paid three
|
|
468
|
+
* times for exactly that shape — the drawing recomputing levels the codec
|
|
469
|
+
* already knew, geometry walking the tree the codec already walks, two records
|
|
470
|
+
* of one comment run. A mark that disagreed with the patch about lane identity
|
|
471
|
+
* would paint one lane and write another.
|
|
472
|
+
*/
|
|
473
|
+
function laneTrace(beforeDoc, afterDoc) {
|
|
474
|
+
return remapOrigins(beforeDoc, afterDoc, countUp(laneObjects(beforeDoc).length));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Every lane object, in the codec's display order. */
|
|
478
|
+
function laneObjects(doc) {
|
|
479
|
+
return C.lanePaths(doc).map(function (p) { return valueAt(doc, p); });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function countUp(n) {
|
|
483
|
+
const out = [];
|
|
484
|
+
for (let i = 0; i < n; i++) out.push(i);
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ---------------------------------------------------------------------------
|
|
489
|
+
// From two documents to a set of edits
|
|
490
|
+
// ---------------------------------------------------------------------------
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The candidate edit lists that turn the base document into the current one.
|
|
494
|
+
*
|
|
495
|
+
* Two routes, and which one is taken is decided by the lane roster rather than
|
|
496
|
+
* by what the caller said they were doing. When every lane is still there and
|
|
497
|
+
* still in the same place, the two trees have the same shape and the whole
|
|
498
|
+
* thing is one parallel walk: every differing value becomes an edit at its own
|
|
499
|
+
* path, and group titles, `config`, `head` and anything else the author wrote
|
|
500
|
+
* are covered for free by the same walk.
|
|
501
|
+
*
|
|
502
|
+
* When lanes were added, removed or reordered the shapes no longer line up, so
|
|
503
|
+
* the structural half is written with the codec's five pinned recipes — which
|
|
504
|
+
* address lanes through the bridge, never by a path built here — and the
|
|
505
|
+
* content half is then done pairwise between lanes that survived. That half can
|
|
506
|
+
* offer more than one candidate; `toPatch` decides between them by reproduction.
|
|
507
|
+
*/
|
|
508
|
+
function planEdits(ctx, doc, origins) {
|
|
509
|
+
const common = [];
|
|
510
|
+
const laneAware = Array.isArray(signalOf(ctx.baseDoc)) && Array.isArray(signalOf(doc));
|
|
511
|
+
const skip = laneAware ? 'signal' : null;
|
|
512
|
+
const top = diffMembers(ctx, [], ctx.baseDoc, doc, skip, common);
|
|
513
|
+
if (top.ok !== true) return top;
|
|
514
|
+
if (!laneAware) return { ok: true, alternatives: [common], refusals: [], readings: 1 };
|
|
515
|
+
if (origins.length === ctx.basePaths.length && isCountUp(origins)) {
|
|
516
|
+
const lanes = diffValue(ctx, ['signal'], signalOf(ctx.baseDoc), signalOf(doc), common);
|
|
517
|
+
if (lanes.ok !== true) return lanes;
|
|
518
|
+
return { ok: true, alternatives: [common], refusals: [], readings: 1 };
|
|
519
|
+
}
|
|
520
|
+
const structural = planStructural(ctx, doc, origins);
|
|
521
|
+
if (structural.ok !== true) return structural;
|
|
522
|
+
return {
|
|
523
|
+
ok: true,
|
|
524
|
+
alternatives: structural.alternatives.map(function (extra) { return common.concat(extra); }),
|
|
525
|
+
refusals: structural.refusals,
|
|
526
|
+
readings: structural.readings,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function isCountUp(origins) {
|
|
531
|
+
for (let i = 0; i < origins.length; i++) {
|
|
532
|
+
if (origins[i] !== i) return false;
|
|
533
|
+
}
|
|
534
|
+
return true;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function signalOf(doc) {
|
|
538
|
+
return doc === null || typeof doc !== 'object' ? null : doc.signal;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The structural half: what was removed, what moved, what was added, and then
|
|
543
|
+
* the fields of the lanes that came through.
|
|
544
|
+
*
|
|
545
|
+
* Every path here is measured against the BASE document, because that is the
|
|
546
|
+
* text being patched and the spans are keyed to it.
|
|
547
|
+
*/
|
|
548
|
+
function planStructural(ctx, doc, origins) {
|
|
549
|
+
const survivors = [];
|
|
550
|
+
for (let j = 0; j < origins.length; j++) {
|
|
551
|
+
if (origins[j] !== null) survivors.push({ cur: j, base: origins[j] });
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// A group title is not a lane, so nothing above tracks one, and a structural
|
|
555
|
+
// walk would carry a renamed title silently past the patch. No codec
|
|
556
|
+
// operation renames one — only a hand-written operation reaches this — so
|
|
557
|
+
// rather than guess which title became which, anything the current doc says
|
|
558
|
+
// that the base doc did not is refused. A title that vanished is ordinary: it
|
|
559
|
+
// went with the group its last lane emptied. (A group DISSOLVED by hand, with
|
|
560
|
+
// its lanes kept, says nothing new here — that one is caught by the
|
|
561
|
+
// read-back check in `toPatch`, because the patch would still hold the group.)
|
|
562
|
+
const titles = titleCensus(ctx.baseDoc);
|
|
563
|
+
for (const title of titlesOf(doc)) {
|
|
564
|
+
const seen = titles.get(title);
|
|
565
|
+
if (seen === undefined || seen === 0) {
|
|
566
|
+
return refuse('a group title changed at the same time as the lane roster, and a ' +
|
|
567
|
+
'title is not a lane, so which one it used to be is not recoverable; apply the ' +
|
|
568
|
+
'title change and the lane change as two edits', ctx.range(['signal']));
|
|
569
|
+
}
|
|
570
|
+
titles.set(title, seen - 1);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const order = survivors.map(function (s) { return s.base; });
|
|
574
|
+
const candidates = singleMoveCandidates(order);
|
|
575
|
+
if (candidates === null) {
|
|
576
|
+
const dragged = outsideLongestRun(order).map(function (p) { return ctx.basePaths[order[p]]; });
|
|
577
|
+
return refuse('more than one lane would have to move, and every destination is ' +
|
|
578
|
+
'measured against the same source text, so which one lands first is not defined; ' +
|
|
579
|
+
'write the patch and re-read the block between the moves', ctx.spanning(dragged));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Every candidate is planned, including the ones that refuse: a candidate can
|
|
583
|
+
// only be ruled out by writing it, and a refusal from one of them is kept
|
|
584
|
+
// rather than dropped, because when none of the others reproduces the
|
|
585
|
+
// document that refusal is the answer.
|
|
586
|
+
const alternatives = [];
|
|
587
|
+
const refusals = [];
|
|
588
|
+
for (const pos of (candidates.length === 0 ? [null] : candidates)) {
|
|
589
|
+
const one = planWithMove(ctx, doc, origins, survivors, order, pos);
|
|
590
|
+
if (one.ok !== true) {
|
|
591
|
+
refusals.push(one);
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
alternatives.push(one.edits);
|
|
595
|
+
}
|
|
596
|
+
// Even a plan where every reading refused comes back as `ok:true` with no
|
|
597
|
+
// alternatives: `toPatch` is the one place that turns "nothing could be
|
|
598
|
+
// written" into a refusal, so that the message it chooses is decided the same
|
|
599
|
+
// way whether there was one reading or several.
|
|
600
|
+
return {
|
|
601
|
+
ok: true,
|
|
602
|
+
alternatives: alternatives,
|
|
603
|
+
refusals: refusals,
|
|
604
|
+
readings: candidates.length === 0 ? 1 : candidates.length,
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* One candidate plan: the whole structural half, written as though the lane at
|
|
610
|
+
* display position `movePos` of the surviving order is the one that was dragged
|
|
611
|
+
* (or nothing was, when it is null).
|
|
612
|
+
*/
|
|
613
|
+
function planWithMove(ctx, doc, origins, survivors, order, movePos) {
|
|
614
|
+
const edits = [];
|
|
615
|
+
const curPaths = C.lanePaths(doc);
|
|
616
|
+
let movePath = null;
|
|
617
|
+
let moveTo = null;
|
|
618
|
+
if (movePos !== null) {
|
|
619
|
+
movePath = ctx.basePaths[order[movePos]];
|
|
620
|
+
moveTo = destinationFor(ctx, order, movePos);
|
|
621
|
+
if (moveTo === null) {
|
|
622
|
+
return refuse('the lane that moved has no destination in the document it is ' +
|
|
623
|
+
'moving inside', ctx.range(movePath));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// What has to come out of the file is not only the lanes the user deleted: a
|
|
628
|
+
// lane that LEFT a group takes that group with it too when nothing else is
|
|
629
|
+
// staying behind, and the two halves only see each other if they are counted
|
|
630
|
+
// together. Counted apart, a group whose last two lanes went by one deletion
|
|
631
|
+
// and one drag stays in the file as an empty husk — titled, drawing nothing,
|
|
632
|
+
// and unaddressable ever after.
|
|
633
|
+
const gone = [];
|
|
634
|
+
for (let i = 0; i < ctx.basePaths.length; i++) {
|
|
635
|
+
if (!survivorsHave(survivors, i)) gone.push(i);
|
|
636
|
+
}
|
|
637
|
+
const movedOut = movePath !== null &&
|
|
638
|
+
JSON.stringify(movePath.slice(0, -1)) !== JSON.stringify(moveTo.slice(0, -1));
|
|
639
|
+
const removals = C.laneRemovePaths(ctx.baseDoc, movedOut ? gone.concat([order[movePos]]) : gone);
|
|
640
|
+
if (removals === null) {
|
|
641
|
+
return refuse('the lanes to remove could not be resolved against the source document',
|
|
642
|
+
ctx.range(['signal']));
|
|
643
|
+
}
|
|
644
|
+
// What the codec's own `move` will cut out, by the same rule: the lane, or the
|
|
645
|
+
// group it empties by leaving. Anything the joint count says goes but the move
|
|
646
|
+
// does not take is a group emptied by the deletion AND the drag together, and
|
|
647
|
+
// that has no single local patch — the group's bytes contain the lane that has
|
|
648
|
+
// to survive somewhere else, so the two edits would overlap.
|
|
649
|
+
const moveCut = movePath === null ? null : C.laneRemovePath(ctx.baseDoc, order[movePos]);
|
|
650
|
+
for (const path of removals) {
|
|
651
|
+
if (moveCut !== null && JSON.stringify(path) === JSON.stringify(moveCut)) continue;
|
|
652
|
+
if (moveCut !== null && isPrefixPath(path, moveCut)) {
|
|
653
|
+
return refuse('a group is emptied by a deletion and a drag together, and the bytes ' +
|
|
654
|
+
'that would have to be deleted are the same bytes the drag has to carry somewhere ' +
|
|
655
|
+
'else; save the deletion and the drag as two edits', ctx.range(path));
|
|
656
|
+
}
|
|
657
|
+
edits.push({ op: 'remove', path: path });
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (movePath !== null) edits.push({ op: 'move', path: movePath, to: moveTo });
|
|
661
|
+
|
|
662
|
+
for (let j = 0; j < origins.length; j++) {
|
|
663
|
+
if (origins[j] !== null) continue;
|
|
664
|
+
let before = null;
|
|
665
|
+
for (let k = j + 1; k < origins.length; k++) {
|
|
666
|
+
if (origins[k] === null) continue;
|
|
667
|
+
before = origins[k];
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
edits.push({
|
|
671
|
+
op: 'insert',
|
|
672
|
+
path: before === null
|
|
673
|
+
? C.laneInsertPath(ctx.baseDoc, ctx.basePaths.length)
|
|
674
|
+
: C.laneInsertPath(ctx.baseDoc, before),
|
|
675
|
+
value: valueAt(doc, curPaths[j]),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
for (const s of survivors) {
|
|
680
|
+
const was = valueAt(ctx.baseDoc, ctx.basePaths[s.base]);
|
|
681
|
+
const now = valueAt(doc, curPaths[s.cur]);
|
|
682
|
+
if (was === now) continue;
|
|
683
|
+
const r = diffValue(ctx, ctx.basePaths[s.base], was, now, edits);
|
|
684
|
+
if (r.ok !== true) return r;
|
|
685
|
+
}
|
|
686
|
+
return { ok: true, edits: edits };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Where a dragged lane has to land, as a path the codec's `move` resolves
|
|
691
|
+
* against the base document: in front of whichever surviving lane follows it
|
|
692
|
+
* now, or on the end of the lane list when none does. That is the same counting
|
|
693
|
+
* `moveLane`'s own recipe does — a destination measured after the lift is one
|
|
694
|
+
* place further along while the lane is still in the way.
|
|
695
|
+
*/
|
|
696
|
+
function destinationFor(ctx, order, movePos) {
|
|
697
|
+
for (let k = movePos + 1; k < order.length; k++) {
|
|
698
|
+
return C.laneInsertPath(ctx.baseDoc, order[k]);
|
|
699
|
+
}
|
|
700
|
+
return C.laneInsertPath(ctx.baseDoc, ctx.basePaths.length);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function survivorsHave(survivors, base) {
|
|
704
|
+
for (const s of survivors) {
|
|
705
|
+
if (s.base === base) return true;
|
|
706
|
+
}
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** Whether `a` is `b` itself or an ancestor of it. */
|
|
711
|
+
function isPrefixPath(a, b) {
|
|
712
|
+
if (a.length > b.length) return false;
|
|
713
|
+
for (let i = 0; i < a.length; i++) {
|
|
714
|
+
if (a[i] !== b[i]) return false;
|
|
715
|
+
}
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Which single lane could have been dragged to produce this order.
|
|
721
|
+
*
|
|
722
|
+
* `[]` when the surviving lanes still read in the order the file has them, so
|
|
723
|
+
* nothing moved. Otherwise every position that, lifted out, leaves the rest
|
|
724
|
+
* increasing — **all** of them, not one chosen by a tie-break. An adjacent pair
|
|
725
|
+
* reading swapped always yields two, and they are not equivalent: with a group
|
|
726
|
+
* boundary between them, "b moved up" puts b outside the group and "a moved
|
|
727
|
+
* down" pulls a inside it. Choosing by argument here is how this file shipped a
|
|
728
|
+
* patch for the wrong lane, so it does not choose; `toPatch` writes each
|
|
729
|
+
* candidate out and keeps the one that reads back as the document.
|
|
730
|
+
*
|
|
731
|
+
* `null` when no single lift can explain the order — two or more lanes moved,
|
|
732
|
+
* and one source text cannot say which lands first.
|
|
733
|
+
*/
|
|
734
|
+
function singleMoveCandidates(order) {
|
|
735
|
+
if (isIncreasing(order, -1)) return [];
|
|
736
|
+
const out = [];
|
|
737
|
+
for (let p = 0; p < order.length; p++) {
|
|
738
|
+
if (isIncreasing(order, p)) out.push(p);
|
|
739
|
+
}
|
|
740
|
+
return out.length === 0 ? null : out;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Whether `order` is increasing once the element at `skip` is left out. */
|
|
744
|
+
function isIncreasing(order, skip) {
|
|
745
|
+
let last = -Infinity;
|
|
746
|
+
for (let i = 0; i < order.length; i++) {
|
|
747
|
+
if (i === skip) continue;
|
|
748
|
+
if (!(order[i] > last)) return false;
|
|
749
|
+
last = order[i];
|
|
750
|
+
}
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Which positions of `order` are not in a longest increasing subsequence of it.
|
|
756
|
+
* Only used to NAME the lanes in the refusal when more than one has to move —
|
|
757
|
+
* the decision itself is made by reproduction, not by this.
|
|
758
|
+
*/
|
|
759
|
+
function outsideLongestRun(order) {
|
|
760
|
+
const n = order.length;
|
|
761
|
+
if (n < 2) return [];
|
|
762
|
+
const len = new Array(n).fill(1);
|
|
763
|
+
const prev = new Array(n).fill(-1);
|
|
764
|
+
let best = 0;
|
|
765
|
+
for (let i = 0; i < n; i++) {
|
|
766
|
+
for (let j = 0; j < i; j++) {
|
|
767
|
+
if (order[j] < order[i] && len[j] + 1 > len[i]) {
|
|
768
|
+
len[i] = len[j] + 1;
|
|
769
|
+
prev[i] = j;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
if (len[i] > len[best]) best = i;
|
|
773
|
+
}
|
|
774
|
+
const keep = new Set();
|
|
775
|
+
for (let i = best; i !== -1; i = prev[i]) keep.add(i);
|
|
776
|
+
const out = [];
|
|
777
|
+
for (let i = 0; i < n; i++) {
|
|
778
|
+
if (!keep.has(i)) out.push(i);
|
|
779
|
+
}
|
|
780
|
+
return out;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/** Every group title in the lane tree, in order. */
|
|
784
|
+
function titlesOf(doc) {
|
|
785
|
+
const out = [];
|
|
786
|
+
const walk = function (arr, depth) {
|
|
787
|
+
if (depth > MAX_DEPTH) return;
|
|
788
|
+
for (const item of arr) {
|
|
789
|
+
if (Array.isArray(item)) walk(item, depth + 1);
|
|
790
|
+
else if (!isLane(item)) out.push(item);
|
|
791
|
+
}
|
|
792
|
+
};
|
|
793
|
+
const lanes = signalOf(doc);
|
|
794
|
+
if (Array.isArray(lanes)) walk(lanes, 0);
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function titleCensus(doc) {
|
|
799
|
+
const out = new Map();
|
|
800
|
+
for (const title of titlesOf(doc)) {
|
|
801
|
+
const seen = out.get(title);
|
|
802
|
+
out.set(title, seen === undefined ? 1 : seen + 1);
|
|
803
|
+
}
|
|
804
|
+
return out;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function isLane(item) {
|
|
808
|
+
return item !== null && typeof item === 'object' && !Array.isArray(item);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// ---------------------------------------------------------------------------
|
|
812
|
+
// Value-level difference
|
|
813
|
+
// ---------------------------------------------------------------------------
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Emit the edits that turn `was` into `now` at `path`.
|
|
817
|
+
*
|
|
818
|
+
* Scalars that differ become a plain `set`, which is the edit that rewrites
|
|
819
|
+
* exactly one literal's bytes. Arrays of the same length are walked element by
|
|
820
|
+
* element for the same reason — a `data` list with a note beside each label
|
|
821
|
+
* keeps those notes, which a wholesale rewrite of the list cannot.
|
|
822
|
+
*
|
|
823
|
+
* Where the shape itself changed — a different type, or a different number of
|
|
824
|
+
* elements — there is no local replacement of a single literal to make, so it
|
|
825
|
+
* asks for `replace` and lets the codec decide whether it can be said without
|
|
826
|
+
* reflowing bytes nobody touched. A lane's `data` moving from the
|
|
827
|
+
* space-separated string to the array form is exactly that case and is the
|
|
828
|
+
* reason `replace` exists; a whole lane, or a `data` array written over several
|
|
829
|
+
* lines, is refused there, and that refusal travels up.
|
|
830
|
+
*
|
|
831
|
+
* There is no depth guard here, and none is needed: this walks the two documents
|
|
832
|
+
* in step and only keeps going where BOTH sides are containers, so its depth is
|
|
833
|
+
* bounded by the base document's — and that one came out of `parseSource`, which
|
|
834
|
+
* refuses past its own ceiling. Measured against this host: a value nested 61
|
|
835
|
+
* levels under a lane parses and saves, and 62 is refused by the reader before
|
|
836
|
+
* anything here sees it. The guard that used to sit here could not fire from any
|
|
837
|
+
* source this store can open, so it was removed rather than left as a branch no
|
|
838
|
+
* test could reach. `test/wave-store.test.js` pins the two measurements, so a
|
|
839
|
+
* change to that ceiling reddens rather than quietly reopening the question.
|
|
840
|
+
*/
|
|
841
|
+
function diffValue(ctx, path, was, now, edits) {
|
|
842
|
+
if (was === now) return OK;
|
|
843
|
+
const wasKind = kindOf(was);
|
|
844
|
+
const nowKind = kindOf(now);
|
|
845
|
+
if (wasKind !== nowKind) {
|
|
846
|
+
edits.push({ op: 'replace', path: path, value: now });
|
|
847
|
+
return OK;
|
|
848
|
+
}
|
|
849
|
+
if (wasKind === 'array') {
|
|
850
|
+
if (was.length !== now.length) {
|
|
851
|
+
edits.push({ op: 'replace', path: path, value: now });
|
|
852
|
+
return OK;
|
|
853
|
+
}
|
|
854
|
+
for (let i = 0; i < was.length; i++) {
|
|
855
|
+
const r = diffValue(ctx, path.concat([i]), was[i], now[i], edits);
|
|
856
|
+
if (r.ok !== true) return r;
|
|
857
|
+
}
|
|
858
|
+
return OK;
|
|
859
|
+
}
|
|
860
|
+
if (wasKind === 'object') return diffMembers(ctx, path, was, now, null, edits);
|
|
861
|
+
if (!sameScalar(was, now)) edits.push({ path: path, value: now });
|
|
862
|
+
return OK;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* The members of one object against another: a member that is gone is spliced
|
|
867
|
+
* out, a member that is new goes in after the object's last, and the rest are
|
|
868
|
+
* compared value by value.
|
|
869
|
+
*
|
|
870
|
+
* The new-member case is the second half of the rename recipe — naming a lane
|
|
871
|
+
* the author wrote without a name — and it is a different edit from a `set`,
|
|
872
|
+
* which is why it is decided here on what the base document holds rather than
|
|
873
|
+
* by the caller remembering which kind of rename they did.
|
|
874
|
+
*/
|
|
875
|
+
function diffMembers(ctx, path, was, now, skipKey, edits) {
|
|
876
|
+
for (const key of Object.keys(was)) {
|
|
877
|
+
if (key === skipKey) continue;
|
|
878
|
+
if (!has(now, key)) {
|
|
879
|
+
edits.push({ op: 'remove', path: path.concat([key]) });
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
const r = diffValue(ctx, path.concat([key]), was[key], now[key], edits);
|
|
883
|
+
if (r.ok !== true) return r;
|
|
884
|
+
}
|
|
885
|
+
for (const key of Object.keys(now)) {
|
|
886
|
+
if (key === skipKey) continue;
|
|
887
|
+
if (!has(was, key)) edits.push({ op: 'insert', path: path.concat([key]), value: now[key] });
|
|
888
|
+
}
|
|
889
|
+
return OK;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Whether two documents say the same thing.
|
|
894
|
+
*
|
|
895
|
+
* Key order is not part of it: two objects with the same members differ in no
|
|
896
|
+
* value, so the diff above writes nothing for them and the file would not
|
|
897
|
+
* change. Scalars use the codec's own rule, for the same reason.
|
|
898
|
+
*/
|
|
899
|
+
function sameValue(a, b, depth) {
|
|
900
|
+
if (a === b) return true;
|
|
901
|
+
if (depth > MAX_DEPTH) return false;
|
|
902
|
+
const ka = kindOf(a);
|
|
903
|
+
if (ka !== kindOf(b)) return false;
|
|
904
|
+
if (ka === 'array') {
|
|
905
|
+
if (a.length !== b.length) return false;
|
|
906
|
+
for (let i = 0; i < a.length; i++) {
|
|
907
|
+
if (!sameValue(a[i], b[i], depth + 1)) return false;
|
|
908
|
+
}
|
|
909
|
+
return true;
|
|
910
|
+
}
|
|
911
|
+
if (ka === 'object') {
|
|
912
|
+
const keys = Object.keys(a);
|
|
913
|
+
if (keys.length !== Object.keys(b).length) return false;
|
|
914
|
+
for (const key of keys) {
|
|
915
|
+
if (!has(b, key)) return false;
|
|
916
|
+
if (!sameValue(a[key], b[key], depth + 1)) return false;
|
|
917
|
+
}
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
return sameScalar(a, b);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** The codec's rule: NaN is itself, and negative zero is zero. */
|
|
924
|
+
function sameScalar(a, b) {
|
|
925
|
+
if (a === b) return true;
|
|
926
|
+
return Number.isNaN(a) && Number.isNaN(b);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function kindOf(value) {
|
|
930
|
+
if (value === null) return 'null';
|
|
931
|
+
if (Array.isArray(value)) return 'array';
|
|
932
|
+
const t = typeof value;
|
|
933
|
+
return t === 'object' ? 'object' : t;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function has(obj, key) {
|
|
937
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/** Walk a document down a path the codec handed out. */
|
|
941
|
+
function valueAt(node, path) {
|
|
942
|
+
let cur = node;
|
|
943
|
+
for (const seg of path) {
|
|
944
|
+
if (cur === null || typeof cur !== 'object') return undefined;
|
|
945
|
+
cur = cur[seg];
|
|
946
|
+
}
|
|
947
|
+
return cur;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
module.exports = { createStore, laneTrace };
|