canopycms 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +326 -48
- package/dist/api/admin.d.ts +4 -4
- package/dist/api/branch-review.d.ts +20 -2
- package/dist/api/branch-review.js +153 -32
- package/dist/api/branch-status.d.ts +23 -2
- package/dist/api/branch-status.js +16 -2
- package/dist/api/branch-update-from-base.d.ts +23 -0
- package/dist/api/branch-update-from-base.js +227 -0
- package/dist/api/branch.d.ts +10 -0
- package/dist/api/branch.js +28 -2
- package/dist/api/client.d.ts +15 -2
- package/dist/api/client.js +24 -2
- package/dist/api/comments.d.ts +2 -2
- package/dist/api/content.d.ts +38 -1
- package/dist/api/content.js +41 -5
- package/dist/api/merge-conflicts.d.ts +235 -0
- package/dist/api/merge-conflicts.js +709 -0
- package/dist/api/routes.js +2 -0
- package/dist/api/types.d.ts +8 -3
- package/dist/base-update.d.ts +32 -0
- package/dist/base-update.js +31 -0
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +16 -1
- package/dist/branch-schema-cache.d.ts +12 -0
- package/dist/branch-schema-cache.js +27 -2
- package/dist/cli/backfill-item-keys.d.ts +77 -0
- package/dist/cli/backfill-item-keys.js +320 -0
- package/dist/cli/cli.d.ts +1 -1
- package/dist/cli/cli.js +9798 -1509
- package/dist/cli/generate-ai-content.js +373 -249
- package/dist/cli/github-app-manifest.d.ts +4 -1
- package/dist/cli/github-app-manifest.js +5 -1
- package/dist/cli/init.js +12 -0
- package/dist/cli/site-config.d.ts +14 -2
- package/dist/cli/site-config.js +20 -4
- package/dist/comment-store.d.ts +8 -0
- package/dist/comment-store.js +28 -0
- package/dist/content-index-generation.d.ts +1 -4
- package/dist/content-index-generation.js +1 -4
- package/dist/content-store.d.ts +39 -3
- package/dist/content-store.js +48 -15
- package/dist/editor/BranchManager.d.ts +4 -1
- package/dist/editor/BranchManager.js +9 -5
- package/dist/editor/Editor.js +462 -62
- package/dist/editor/EntryNavigator.d.ts +10 -2
- package/dist/editor/EntryNavigator.js +12 -4
- package/dist/editor/FormRenderer.d.ts +8 -2
- package/dist/editor/FormRenderer.js +5 -6
- package/dist/editor/admin/ConflictsStatusAlert.d.ts +8 -0
- package/dist/editor/admin/ConflictsStatusAlert.js +22 -0
- package/dist/editor/admin/SystemHealthPanel.js +4 -3
- package/dist/editor/base-update-wait.d.ts +78 -0
- package/dist/editor/base-update-wait.js +98 -0
- package/dist/editor/branch-status.js +1 -1
- package/dist/editor/components/BaseUpdateNotice.d.ts +12 -0
- package/dist/editor/components/BaseUpdateNotice.js +24 -0
- package/dist/editor/components/BranchPill.d.ts +4 -1
- package/dist/editor/components/BranchPill.js +5 -2
- package/dist/editor/components/ConflictsGroup.d.ts +112 -0
- package/dist/editor/components/ConflictsGroup.js +404 -0
- package/dist/editor/components/EditorHeader.d.ts +18 -1
- package/dist/editor/components/EditorHeader.js +99 -33
- package/dist/editor/components/EntryChangesDrawer.d.ts +16 -2
- package/dist/editor/components/EntryChangesDrawer.js +85 -167
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -2
- package/dist/editor/components/GetLatestChangesButton.d.ts +15 -0
- package/dist/editor/components/GetLatestChangesButton.js +29 -0
- package/dist/editor/components/ReleaseHoldButton.d.ts +20 -0
- package/dist/editor/components/ReleaseHoldButton.js +36 -0
- package/dist/editor/components/RetryToastMessage.js +1 -1
- package/dist/editor/components/change-parts.d.ts +51 -0
- package/dist/editor/components/change-parts.js +171 -0
- package/dist/editor/conflict-cards.d.ts +93 -0
- package/dist/editor/conflict-cards.js +284 -0
- package/dist/editor/conflict-choices.d.ts +60 -0
- package/dist/editor/conflict-choices.js +108 -0
- package/dist/editor/conflict-notice.d.ts +25 -0
- package/dist/editor/conflict-notice.js +39 -0
- package/dist/editor/copy.d.ts +12 -0
- package/dist/editor/copy.js +12 -0
- package/dist/editor/entry-diff.d.ts +2 -1
- package/dist/editor/entry-diff.js +5 -21
- package/dist/editor/hooks/useBaseUpdate.d.ts +38 -0
- package/dist/editor/hooks/useBaseUpdate.js +279 -0
- package/dist/editor/hooks/useBranchChangesData.d.ts +7 -1
- package/dist/editor/hooks/useBranchChangesData.js +13 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -1
- package/dist/editor/hooks/useBranchManager.js +108 -13
- package/dist/editor/hooks/useBranchesData.js +3 -0
- package/dist/editor/hooks/useConflictChoices.d.ts +24 -0
- package/dist/editor/hooks/useConflictChoices.js +75 -0
- package/dist/editor/hooks/useConflictsData.d.ts +26 -0
- package/dist/editor/hooks/useConflictsData.js +89 -0
- package/dist/editor/hooks/useDraftManager.d.ts +13 -1
- package/dist/editor/hooks/useDraftManager.js +40 -9
- package/dist/editor/hooks/useEntryManager.d.ts +17 -3
- package/dist/editor/hooks/useEntryManager.js +11 -5
- package/dist/editor/release-hold.d.ts +12 -0
- package/dist/editor/release-hold.js +39 -0
- package/dist/editor/server-clock.d.ts +4 -0
- package/dist/editor/server-clock.js +15 -0
- package/dist/editor/undo-change.d.ts +7 -0
- package/dist/editor/undo-change.js +19 -4
- package/dist/git-manager.d.ts +6 -0
- package/dist/git-manager.js +38 -3
- package/dist/item-keys.d.ts +13 -0
- package/dist/item-keys.js +36 -14
- package/dist/merge/conflict-path.d.ts +40 -0
- package/dist/merge/conflict-path.js +128 -0
- package/dist/merge/diff3.d.ts +27 -0
- package/dist/merge/diff3.js +113 -0
- package/dist/merge/entry-merge.d.ts +94 -0
- package/dist/merge/entry-merge.js +876 -0
- package/dist/merge/list-alignment.d.ts +55 -0
- package/dist/merge/list-alignment.js +490 -0
- package/dist/merge/rekey.d.ts +11 -0
- package/dist/merge/rekey.js +75 -0
- package/dist/merge/text-merge.d.ts +29 -0
- package/dist/merge/text-merge.js +59 -0
- package/dist/merge/value-equality.d.ts +18 -0
- package/dist/merge/value-equality.js +50 -0
- package/dist/merge-conflicts.d.ts +232 -0
- package/dist/merge-conflicts.js +451 -0
- package/dist/operating-mode/client-safe-strategy.d.ts +2 -0
- package/dist/operating-mode/client-safe-strategy.js +6 -0
- package/dist/operating-mode/client-unsafe-strategy.js +6 -0
- package/dist/operating-mode/types.d.ts +13 -0
- package/dist/services.d.ts +24 -1
- package/dist/services.js +138 -22
- package/dist/task-queue/cms-task-queue.d.ts +3 -1
- package/dist/types.d.ts +63 -5
- package/dist/utils/content-serialize.d.ts +18 -1
- package/dist/utils/content-serialize.js +12 -0
- package/dist/utils/content-write-lock.d.ts +3 -3
- package/dist/utils/content-write-lock.js +3 -3
- package/dist/utils/git.d.ts +7 -0
- package/dist/utils/git.js +35 -0
- package/dist/utils/markdown-body-splice.d.ts +1 -1
- package/dist/utils/markdown-body-splice.js +1 -19
- package/dist/utils/markdown-parse.d.ts +9 -0
- package/dist/utils/markdown-parse.js +27 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +80 -40
- package/dist/worker/branch-merge.js +244 -58
- package/dist/worker/branch-policy.d.ts +18 -2
- package/dist/worker/branch-policy.js +36 -0
- package/dist/worker/cms-worker.d.ts +37 -3
- package/dist/worker/cms-worker.js +88 -16
- package/dist/worker/conflicts-status.d.ts +20 -0
- package/dist/worker/conflicts-status.js +204 -0
- package/dist/worker/field-merge.d.ts +80 -0
- package/dist/worker/field-merge.js +729 -0
- package/dist/worker/git-sync.d.ts +3 -2
- package/dist/worker/git-sync.js +16 -6
- package/dist/worker/github-commits.d.ts +42 -10
- package/dist/worker/github-commits.js +444 -52
- package/dist/worker/github-errors.d.ts +15 -0
- package/dist/worker/github-errors.js +47 -0
- package/dist/worker/github-gateway-http.js +24 -9
- package/dist/worker/github-gateway-wire.d.ts +49 -20
- package/dist/worker/github-gateway-wire.js +19 -3
- package/dist/worker/github-gateway.d.ts +27 -1
- package/dist/worker/github-gateway.js +55 -7
- package/dist/worker/github-transfer.d.ts +5 -3
- package/dist/worker/github-transfer.js +8 -5
- package/dist/worker/history-rewrite.d.ts +40 -8
- package/dist/worker/history-rewrite.js +51 -23
- package/dist/worker/publish-decision.d.ts +37 -0
- package/dist/worker/publish-decision.js +16 -0
- package/dist/worker/rebase.d.ts +29 -8
- package/dist/worker/rebase.js +492 -211
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +30 -41
- package/dist/worker/worker-context.d.ts +24 -2
- package/package.json +1 -1
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-way merge of one entry, field by field: the version the branch started from (ancestor),
|
|
3
|
+
* the base branch's version now (base), and the branch's own (branch). A value changed on one side
|
|
4
|
+
* takes that change; a value changed alike on both takes it once; a value changed differently on
|
|
5
|
+
* both is a conflict, and the merged entry holds the branch's value there until a person chooses.
|
|
6
|
+
*
|
|
7
|
+
* Pure and client-safe. Schema-light: the input is the RAW parsed data (`parseEntryParts`), never
|
|
8
|
+
* filtered by a schema, so a key the schema doesn't know merges like any other and a base's schema
|
|
9
|
+
* migration survives. The schema, when given, only names conflicts as the "Show changes" drawer
|
|
10
|
+
* names its changes.
|
|
11
|
+
*/
|
|
12
|
+
import { ITEM_KEY, isItemKey, itemKeysAsRead } from '../item-keys.js';
|
|
13
|
+
import { findBodyFieldName } from '../utils/body-field.js';
|
|
14
|
+
import { entryAsRead } from '../utils/calendar-day.js';
|
|
15
|
+
import { parseMarkdownBody } from '../utils/markdown-parse.js';
|
|
16
|
+
import { isPlainRecord } from '../validation/field-traversal.js';
|
|
17
|
+
import { describePath } from './conflict-path.js';
|
|
18
|
+
import { diff3Regions } from './diff3.js';
|
|
19
|
+
import { alignToAncestor, isNewIdentity, mergeOrder, pairNewItems } from './list-alignment.js';
|
|
20
|
+
import { mergeText } from './text-merge.js';
|
|
21
|
+
import { exactToken, isEmptyValue, sameValue, valueToken } from './value-equality.js';
|
|
22
|
+
const ROOT = {
|
|
23
|
+
segments: [],
|
|
24
|
+
places: { o: [], a: [], b: [] },
|
|
25
|
+
placeTrail: [],
|
|
26
|
+
trail: [],
|
|
27
|
+
branchKeys: [],
|
|
28
|
+
item: false,
|
|
29
|
+
asRead: { a: undefined, b: undefined },
|
|
30
|
+
};
|
|
31
|
+
/** `places` one step on: `step` gives each side's segment, or none where that side lacks it. */
|
|
32
|
+
const placesOn = (places, step) => {
|
|
33
|
+
const on = (side) => {
|
|
34
|
+
const segment = step(side);
|
|
35
|
+
const place = places[side];
|
|
36
|
+
return place && segment ? [...place, segment] : undefined;
|
|
37
|
+
};
|
|
38
|
+
return { o: on('o'), a: on('a'), b: on('b') };
|
|
39
|
+
};
|
|
40
|
+
const child = (at, segment, versions) => {
|
|
41
|
+
const inside = (v) => 'field' in segment && isPlainRecord(v) ? member(v, segment.field) : undefined;
|
|
42
|
+
const places = placesOn(at.places, () => segment);
|
|
43
|
+
return {
|
|
44
|
+
segments: [...at.segments, segment],
|
|
45
|
+
places,
|
|
46
|
+
placeTrail: [...at.placeTrail, places],
|
|
47
|
+
trail: [...at.trail, versions],
|
|
48
|
+
branchKeys: at.branchKeys,
|
|
49
|
+
item: false,
|
|
50
|
+
asRead: { a: inside(at.asRead.a), b: inside(at.asRead.b) },
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
/** `record`'s own member `name`: never one it inherits, and `__proto__` as a member. */
|
|
54
|
+
const member = (record, name) => Object.getOwnPropertyDescriptor(record, name)?.value;
|
|
55
|
+
/** Sets `name` as an own member, so `__proto__` is a member like any other. */
|
|
56
|
+
const setMember = (record, name, value) => {
|
|
57
|
+
Object.defineProperty(record, name, {
|
|
58
|
+
value,
|
|
59
|
+
enumerable: true,
|
|
60
|
+
writable: true,
|
|
61
|
+
configurable: true,
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
const isRecordList = (value) => Array.isArray(value) && value.every(isPlainRecord);
|
|
65
|
+
/** A list of plain values (tags): no records or lists inside, which merge only as a whole. */
|
|
66
|
+
const isPlainList = (value) => Array.isArray(value) && !value.some((v) => isPlainRecord(v) || Array.isArray(v));
|
|
67
|
+
const asList = (value) => (Array.isArray(value) ? value : []);
|
|
68
|
+
const usableKey = (item) => isPlainRecord(item) && isItemKey(item[ITEM_KEY]) ? item[ITEM_KEY] : undefined;
|
|
69
|
+
const versions = (o, a, b) => ({
|
|
70
|
+
...(o !== undefined && { ancestor: o }),
|
|
71
|
+
...(a !== undefined && { base: a }),
|
|
72
|
+
...(b !== undefined && { branch: b }),
|
|
73
|
+
});
|
|
74
|
+
/** A block item whose template one side changed: the whole item is one value. */
|
|
75
|
+
const templateChanged = (o, a, b) => {
|
|
76
|
+
const discriminator = (v, key) => (isPlainRecord(v) ? v[key] : undefined);
|
|
77
|
+
return ['template', '_type'].some((key) => discriminator(o, key) !== discriminator(a, key) ||
|
|
78
|
+
discriminator(o, key) !== discriminator(b, key));
|
|
79
|
+
};
|
|
80
|
+
const kindOf = (a, b) => isEmptyValue(a) ? 'removed-on-base' : isEmptyValue(b) ? 'removed-by-you' : 'changed';
|
|
81
|
+
function conflict(o, a, b, at, walk) {
|
|
82
|
+
walk.conflicts.push({
|
|
83
|
+
at: at.segments,
|
|
84
|
+
places: at.places,
|
|
85
|
+
placeTrail: at.placeTrail,
|
|
86
|
+
trail: at.trail,
|
|
87
|
+
kind: kindOf(a, b),
|
|
88
|
+
...(at.item && { item: true }),
|
|
89
|
+
...versions(o, a, b),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Records and lists always merge member by member and item by item, even where two sides compare
|
|
94
|
+
* equal: equality ignores item keys, and taking one side's whole record or list would drop the
|
|
95
|
+
* keys the other side's items carry, and the comment threads anchored on them.
|
|
96
|
+
*/
|
|
97
|
+
function mergeValue(o, a, b, at, walk) {
|
|
98
|
+
const absentOrEmpty = o === undefined || isEmptyValue(o);
|
|
99
|
+
if (isPlainRecord(a) && isPlainRecord(b) && (isPlainRecord(o) || absentOrEmpty)) {
|
|
100
|
+
if (!(at.item && templateChanged(o, a, b)))
|
|
101
|
+
return mergeRecord(o, a, b, at, walk);
|
|
102
|
+
}
|
|
103
|
+
else if (isRecordList(a) && isRecordList(b) && (isRecordList(o) || absentOrEmpty)) {
|
|
104
|
+
const merged = mergeList(o, a, b, at, walk);
|
|
105
|
+
if (merged)
|
|
106
|
+
return merged;
|
|
107
|
+
}
|
|
108
|
+
return mergeLeaf(o, a, b, at, walk);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* A side left exactly as the ancestor had it yields to the other side, byte for byte, before any
|
|
112
|
+
* looser equality decides, so `merge(O, A, O)` is A and `merge(O, O, B)` is B.
|
|
113
|
+
*/
|
|
114
|
+
function mergeLeaf(o, a, b, at, walk) {
|
|
115
|
+
if (exactToken(o) === exactToken(b))
|
|
116
|
+
return a;
|
|
117
|
+
if (exactToken(o) === exactToken(a) || sameValue(a, b) || sameValue(o, a))
|
|
118
|
+
return b;
|
|
119
|
+
if (sameValue(o, b))
|
|
120
|
+
return a;
|
|
121
|
+
const empty = o === undefined || isEmptyValue(o);
|
|
122
|
+
if (isPlainList(a) && isPlainList(b) && (isPlainList(o) || empty)) {
|
|
123
|
+
const merged = mergeScalarList(asList(o), a, b);
|
|
124
|
+
if (merged)
|
|
125
|
+
return merged;
|
|
126
|
+
}
|
|
127
|
+
else if (typeof a === 'string' &&
|
|
128
|
+
typeof b === 'string' &&
|
|
129
|
+
(typeof o === 'string' || empty) &&
|
|
130
|
+
`${typeof o === 'string' ? o : ''}${a}${b}`.includes('\n')) {
|
|
131
|
+
const merged = mergeText(typeof o === 'string' ? o : '', a, b);
|
|
132
|
+
if (merged) {
|
|
133
|
+
const text = {
|
|
134
|
+
at: at.segments,
|
|
135
|
+
places: at.places,
|
|
136
|
+
placeTrail: at.placeTrail,
|
|
137
|
+
versions: { o, a, b },
|
|
138
|
+
};
|
|
139
|
+
if (merged.regions.length > 0) {
|
|
140
|
+
text.conflict = {
|
|
141
|
+
at: at.segments,
|
|
142
|
+
places: at.places,
|
|
143
|
+
placeTrail: at.placeTrail,
|
|
144
|
+
trail: at.trail,
|
|
145
|
+
kind: 'changed',
|
|
146
|
+
...versions(o, a, b),
|
|
147
|
+
regions: merged.regions,
|
|
148
|
+
};
|
|
149
|
+
walk.conflicts.push(text.conflict);
|
|
150
|
+
}
|
|
151
|
+
walk.texts.push(text);
|
|
152
|
+
return merged.text;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
conflict(o, a, b, at, walk);
|
|
156
|
+
return b;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* diff3 over a list of plain values (tags). Values both sides inserted at the same place are all
|
|
160
|
+
* kept ({@link insertedByBoth}), since an insertion loses nothing; any other overlap is undefined,
|
|
161
|
+
* a whole-field conflict.
|
|
162
|
+
*/
|
|
163
|
+
function mergeScalarList(o, a, b) {
|
|
164
|
+
const lists = { ancestor: o, base: a, branch: b };
|
|
165
|
+
const [oT, aT, bT] = [o, a, b].map((list) => list.map(valueToken));
|
|
166
|
+
const regions = diff3Regions(oT, aT, bT);
|
|
167
|
+
if (!regions)
|
|
168
|
+
return undefined;
|
|
169
|
+
const out = [];
|
|
170
|
+
for (const region of regions) {
|
|
171
|
+
if (region.stable) {
|
|
172
|
+
out.push(...lists[region.side].slice(...region.span));
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const [aPart, bPart] = [a.slice(...region.base), b.slice(...region.branch)];
|
|
176
|
+
const [aTokens, bTokens] = [aT.slice(...region.base), bT.slice(...region.branch)];
|
|
177
|
+
if (aTokens.length === bTokens.length && aTokens.every((t, n) => t === bTokens[n])) {
|
|
178
|
+
out.push(...bPart);
|
|
179
|
+
}
|
|
180
|
+
else if (region.ancestor[0] === region.ancestor[1]) {
|
|
181
|
+
out.push(...insertedByBoth(aPart, aTokens, bPart, bTokens));
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return withoutExtraRepeats(out, a, b);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* `merged` holding each value no more often than the side holding it most: a value both sides
|
|
191
|
+
* added at different places is one value, at the first place.
|
|
192
|
+
*/
|
|
193
|
+
function withoutExtraRepeats(merged, a, b) {
|
|
194
|
+
const count = (list) => {
|
|
195
|
+
const counts = new Map();
|
|
196
|
+
for (const v of list)
|
|
197
|
+
counts.set(valueToken(v), (counts.get(valueToken(v)) ?? 0) + 1);
|
|
198
|
+
return counts;
|
|
199
|
+
};
|
|
200
|
+
const [inA, inB] = [count(a), count(b)];
|
|
201
|
+
const kept = new Map();
|
|
202
|
+
return merged.filter((v) => {
|
|
203
|
+
const token = valueToken(v);
|
|
204
|
+
const n = (kept.get(token) ?? 0) + 1;
|
|
205
|
+
kept.set(token, n);
|
|
206
|
+
return n <= Math.max(inA.get(token) ?? 0, inB.get(token) ?? 0);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** True when `x` appears in `y` in order, not necessarily side by side. */
|
|
210
|
+
const isSubsequence = (x, y) => {
|
|
211
|
+
let n = 0;
|
|
212
|
+
for (const token of y)
|
|
213
|
+
if (token === x[n])
|
|
214
|
+
n++;
|
|
215
|
+
return n === x.length;
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Values both sides inserted at one place: the side whose insert holds the other's in order,
|
|
219
|
+
* else the base's then the branch's, each repeat of a base value counted once.
|
|
220
|
+
*/
|
|
221
|
+
function insertedByBoth(aPart, aTokens, bPart, bTokens) {
|
|
222
|
+
if (isSubsequence(aTokens, bTokens))
|
|
223
|
+
return [...bPart];
|
|
224
|
+
if (isSubsequence(bTokens, aTokens))
|
|
225
|
+
return [...aPart];
|
|
226
|
+
const fromBase = new Map();
|
|
227
|
+
for (const token of aTokens)
|
|
228
|
+
fromBase.set(token, (fromBase.get(token) ?? 0) + 1);
|
|
229
|
+
const rest = bPart.filter((_, n) => {
|
|
230
|
+
const left = fromBase.get(bTokens[n]) ?? 0;
|
|
231
|
+
if (left === 0)
|
|
232
|
+
return true;
|
|
233
|
+
fromBase.set(bTokens[n], left - 1);
|
|
234
|
+
return false;
|
|
235
|
+
});
|
|
236
|
+
return [...aPart, ...rest];
|
|
237
|
+
}
|
|
238
|
+
function mergeRecord(o, a, b, at, walk) {
|
|
239
|
+
const ancestor = isPlainRecord(o) ? o : {};
|
|
240
|
+
const out = {};
|
|
241
|
+
for (const name of new Set([...Object.keys(b), ...Object.keys(a), ...Object.keys(ancestor)])) {
|
|
242
|
+
if (name === ITEM_KEY)
|
|
243
|
+
continue;
|
|
244
|
+
const [om, am, bm] = [member(ancestor, name), member(a, name), member(b, name)];
|
|
245
|
+
const v = mergeValue(om, am, bm, child(at, { field: name }, { o: om, a: am, b: bm }), walk);
|
|
246
|
+
if (v !== undefined)
|
|
247
|
+
setMember(out, name, v);
|
|
248
|
+
}
|
|
249
|
+
// A list item's key is chosen by its list; any other record's is carried.
|
|
250
|
+
const key = member(b, ITEM_KEY) ?? member(a, ITEM_KEY);
|
|
251
|
+
if (!at.item && key !== undefined)
|
|
252
|
+
setMember(out, ITEM_KEY, key);
|
|
253
|
+
return out;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Items are matched across the three versions by identity (`list-alignment.ts`), merged item by
|
|
257
|
+
* item, and ordered by `mergeOrder`. A matched item takes the base's key, else, when the base
|
|
258
|
+
* changed it, the key the base's version reads under, else the branch's: the merged file converges
|
|
259
|
+
* on the base's keys. An item neither side keyed gets no key. An item
|
|
260
|
+
* removed on one side and changed on the other is an item conflict, and the branch's state holds.
|
|
261
|
+
*/
|
|
262
|
+
function mergeList(o, a, b, at, walk, namedBy) {
|
|
263
|
+
const O = asList(o);
|
|
264
|
+
const identities = namedBy === undefined
|
|
265
|
+
? identifyByKey(O, a, b, walk.ancestorKeys, walk.movedKeys)
|
|
266
|
+
: identifyByName(O, a, b, namedBy);
|
|
267
|
+
// A list too far apart to align in time, or whose items can't all be told apart, is one value.
|
|
268
|
+
// So is one with an item not clearly new or old, unless the other side holds the ancestor's
|
|
269
|
+
// items as they were: that side is the ancestor item for item, and the changed side's list is
|
|
270
|
+
// the merge whichever the item is.
|
|
271
|
+
if (!identities)
|
|
272
|
+
return undefined;
|
|
273
|
+
let { aIds, bIds, aByPlace, bByPlace } = identities;
|
|
274
|
+
const { oKeys } = identities;
|
|
275
|
+
if (identities.unsure) {
|
|
276
|
+
// By place, unless a key the side holds names another ancestor item: alike items reordered.
|
|
277
|
+
const asAncestor = (side) => side.length === O.length &&
|
|
278
|
+
sameValue(O, side) &&
|
|
279
|
+
side.every((item, n) => {
|
|
280
|
+
const key = usableKey(item);
|
|
281
|
+
return key === undefined || key === oKeys[n] || !oKeys.includes(key);
|
|
282
|
+
});
|
|
283
|
+
if (asAncestor(a))
|
|
284
|
+
[aIds, aByPlace] = [oKeys, new Set()];
|
|
285
|
+
else if (asAncestor(b))
|
|
286
|
+
[bIds, bByPlace] = [oKeys, new Set()];
|
|
287
|
+
else
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
const readList = (v, raw) => Array.isArray(v) && v.length === raw.length ? v : raw;
|
|
291
|
+
const [aRead, bRead] = [readList(at.asRead.a, a), readList(at.asRead.b, b)];
|
|
292
|
+
const [aReadKeys, bReadKeys] = [itemKeysAsRead(aRead), itemKeysAsRead(bRead)];
|
|
293
|
+
const held = new Set([...a, ...b].flatMap((item) => usableKey(item) ?? []));
|
|
294
|
+
const index = (ids) => new Map(ids.map((id, n) => [id, n]));
|
|
295
|
+
const [oAt, aAt, bAt] = [index(oKeys), index(aIds), index(bIds)];
|
|
296
|
+
// An item kept whole from one side keeps its key, so it claims the key before any merged item
|
|
297
|
+
// picks one: a merged item never takes a key another item of the merged list holds.
|
|
298
|
+
const used = new Set();
|
|
299
|
+
const out = [];
|
|
300
|
+
const keep = (item) => {
|
|
301
|
+
const key = usableKey(item);
|
|
302
|
+
if (key !== undefined)
|
|
303
|
+
used.add(key);
|
|
304
|
+
out.push(item);
|
|
305
|
+
};
|
|
306
|
+
const keyLater = [];
|
|
307
|
+
for (const id of mergeOrder(oKeys, aIds, bIds)) {
|
|
308
|
+
const i = isNewIdentity(id) ? undefined : oAt.get(id);
|
|
309
|
+
const ja = aAt.get(id);
|
|
310
|
+
const jb = bAt.get(id);
|
|
311
|
+
const [oi, ai, bi] = [
|
|
312
|
+
i === undefined ? undefined : O[i],
|
|
313
|
+
ja === undefined ? undefined : a[ja],
|
|
314
|
+
jb === undefined ? undefined : b[jb],
|
|
315
|
+
];
|
|
316
|
+
const itemVersions = { o: oi, a: ai, b: bi };
|
|
317
|
+
const itemPlaces = placesOn(at.places, (side) => {
|
|
318
|
+
const index = { o: i, a: ja, b: jb }[side];
|
|
319
|
+
return index === undefined ? undefined : { index };
|
|
320
|
+
});
|
|
321
|
+
const itemAt = {
|
|
322
|
+
segments: [...at.segments, { index: out.length }],
|
|
323
|
+
places: itemPlaces,
|
|
324
|
+
placeTrail: [...at.placeTrail, itemPlaces],
|
|
325
|
+
trail: [...at.trail, itemVersions],
|
|
326
|
+
branchKeys: [...at.branchKeys, (jb !== undefined && bReadKeys[jb]) || id],
|
|
327
|
+
item: true,
|
|
328
|
+
asRead: {
|
|
329
|
+
a: ja === undefined ? undefined : aRead[ja],
|
|
330
|
+
b: jb === undefined ? undefined : bRead[jb],
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
if (i !== undefined && ja === undefined && jb === undefined)
|
|
334
|
+
continue;
|
|
335
|
+
if (i !== undefined && ja === undefined) {
|
|
336
|
+
if (sameValue(oi, bi))
|
|
337
|
+
continue;
|
|
338
|
+
conflict(oi, ai, bi, itemAt, walk);
|
|
339
|
+
}
|
|
340
|
+
else if (i !== undefined && jb === undefined) {
|
|
341
|
+
if (sameValue(oi, ai))
|
|
342
|
+
continue;
|
|
343
|
+
// Named as the drawer names it once the merge lands, comparing the base with the branch:
|
|
344
|
+
// by the key the base's version reads under.
|
|
345
|
+
const named = (ja !== undefined && aReadKeys[ja]) || id;
|
|
346
|
+
conflict(oi, ai, bi, { ...itemAt, segments: [...at.segments, { item: named }] }, walk);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (ja === undefined || jb === undefined) {
|
|
350
|
+
keep(ja === undefined ? bi : ai);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
// Matched by place alone, it may be another item: one side's change stands whole, two conflict.
|
|
354
|
+
const placeOnly = aByPlace.has(ja) || bByPlace.has(jb);
|
|
355
|
+
if (placeOnly && !sameValue(ai, bi)) {
|
|
356
|
+
const [aChanged, bChanged] = [!sameValue(oi, ai), !sameValue(oi, bi)];
|
|
357
|
+
if (aChanged && bChanged)
|
|
358
|
+
conflict(oi, ai, bi, itemAt, walk);
|
|
359
|
+
// The base's item stands in the branch's place: the branch's threads follow it there.
|
|
360
|
+
const from = bReadKeys[jb];
|
|
361
|
+
if (aChanged && !bChanged && from !== undefined && !walk.ambiguous(at.branchKeys, from)) {
|
|
362
|
+
walk.pendingRemaps.push({ at: itemAt.segments, within: at.branchKeys, from });
|
|
363
|
+
}
|
|
364
|
+
keep(aChanged && !bChanged ? ai : bi);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const merged = mergeValue(oi, ai, bi, itemAt, walk);
|
|
368
|
+
// A keyless item the base changed takes the key the base's version reads under, which the
|
|
369
|
+
// next sync's ancestor names it by, never the branch's read key for content it no longer has.
|
|
370
|
+
// Never onto a key another item of either version holds as its own, nor for a branch key that
|
|
371
|
+
// names items in two lists alike: a remap could not tell them apart (see Walk.ambiguous).
|
|
372
|
+
const baseKey = aReadKeys[ja];
|
|
373
|
+
const rekey = oi !== undefined &&
|
|
374
|
+
usableKey(bi) !== undefined &&
|
|
375
|
+
!sameValue(oi, ai) &&
|
|
376
|
+
baseKey !== undefined &&
|
|
377
|
+
(!held.has(baseKey) || baseKey === usableKey(bi)) &&
|
|
378
|
+
(bReadKeys[jb] === undefined || !walk.ambiguous(at.branchKeys, bReadKeys[jb]));
|
|
379
|
+
const keys = { base: rekey ? baseKey : undefined, branchRead: bReadKeys[jb] };
|
|
380
|
+
const place = out.push(merged) - 1;
|
|
381
|
+
keyLater.push(() => {
|
|
382
|
+
const keyed = withListKey(merged, ai, bi, keys, used, at, walk);
|
|
383
|
+
// An item left with no key reads under a hash of its merged content: the branch's threads
|
|
384
|
+
// follow it there, once the merged entry reads.
|
|
385
|
+
if (usableKey(keyed) === undefined &&
|
|
386
|
+
keys.branchRead !== undefined &&
|
|
387
|
+
!walk.ambiguous(at.branchKeys, keys.branchRead)) {
|
|
388
|
+
walk.pendingRemaps.push({
|
|
389
|
+
at: itemAt.segments,
|
|
390
|
+
within: at.branchKeys,
|
|
391
|
+
from: keys.branchRead,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
out[place] = keyed;
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
for (const key of keyLater)
|
|
398
|
+
key();
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
function identifyByKey(O, a, b, inEntry, moved) {
|
|
402
|
+
const oKeys = itemKeysAsRead(O).map((key, i) => key ?? `${i}`);
|
|
403
|
+
const aligned = alignToAncestor(a, O, oKeys, 'a', inEntry, moved.a, b);
|
|
404
|
+
const bAligned = alignToAncestor(b, O, oKeys, 'b', inEntry, moved.b, a);
|
|
405
|
+
if (!aligned || !bAligned)
|
|
406
|
+
return undefined;
|
|
407
|
+
const known = new Set(oKeys);
|
|
408
|
+
pairNewItems(a, aligned.ids, b, bAligned.ids, (key) => inEntry.has(key) && !known.has(key));
|
|
409
|
+
return {
|
|
410
|
+
oKeys,
|
|
411
|
+
aIds: aligned.ids,
|
|
412
|
+
bIds: bAligned.ids,
|
|
413
|
+
aByPlace: aligned.byPlace,
|
|
414
|
+
bByPlace: bAligned.byPlace,
|
|
415
|
+
unsure: aligned.unsure || bAligned.unsure,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Identities by the string member `name`. A version with an item that has no name, or a name two
|
|
420
|
+
* items share, leaves no way to tell its items apart: undefined, and the list is one value.
|
|
421
|
+
*/
|
|
422
|
+
function identifyByName(O, a, b, name) {
|
|
423
|
+
const ids = (items) => {
|
|
424
|
+
const names = items.map((item) => (isPlainRecord(item) ? member(item, name) : undefined));
|
|
425
|
+
const distinct = new Set(names);
|
|
426
|
+
const usable = names.every((n) => typeof n === 'string' && !isNewIdentity(n));
|
|
427
|
+
return usable && distinct.size === names.length ? names : undefined;
|
|
428
|
+
};
|
|
429
|
+
const [oKeys, aIds, bIds] = [ids(O), ids(a), ids(b)];
|
|
430
|
+
if (!oKeys || !aIds || !bIds)
|
|
431
|
+
return undefined;
|
|
432
|
+
const none = new Set();
|
|
433
|
+
return { oKeys, aIds, bIds, aByPlace: none, bByPlace: none, unsure: false };
|
|
434
|
+
}
|
|
435
|
+
/** `merged` with the key its list gives it, recording the remap when the branch's key changes. */
|
|
436
|
+
function withListKey(merged, ai, bi, keys, used, at, walk) {
|
|
437
|
+
if (!isPlainRecord(merged))
|
|
438
|
+
return merged;
|
|
439
|
+
const branchReadKey = keys.branchRead;
|
|
440
|
+
const key = [usableKey(ai) ?? keys.base, usableKey(bi)].find((k) => k !== undefined && !used.has(k));
|
|
441
|
+
if (key === undefined) {
|
|
442
|
+
const carried = isPlainRecord(bi) ? bi[ITEM_KEY] : undefined;
|
|
443
|
+
return carried === undefined ? merged : { ...merged, [ITEM_KEY]: carried };
|
|
444
|
+
}
|
|
445
|
+
used.add(key);
|
|
446
|
+
if (branchReadKey !== undefined &&
|
|
447
|
+
branchReadKey !== key &&
|
|
448
|
+
!walk.ambiguous(at.branchKeys, branchReadKey)) {
|
|
449
|
+
walk.keyRemaps.push({ within: at.branchKeys, from: branchReadKey, to: key });
|
|
450
|
+
}
|
|
451
|
+
return { ...merged, [ITEM_KEY]: key };
|
|
452
|
+
}
|
|
453
|
+
/** A version's data with its md/mdx body under the body field, as a read gives the form. */
|
|
454
|
+
const withBody = (side, bodyName) => side.body === undefined ? side.data : { ...side.data, [bodyName]: side.body };
|
|
455
|
+
const mdxParses = (text) => parseMarkdownBody(text, 'mdx') !== undefined;
|
|
456
|
+
const sameContent = (x, y) => sameValue(x.data, y.data) && (x.body ?? '') === (y.body ?? '');
|
|
457
|
+
function mergeBody(o, a, b, format, at, walk) {
|
|
458
|
+
if (a === b || o === a)
|
|
459
|
+
return b;
|
|
460
|
+
if (o === b)
|
|
461
|
+
return a;
|
|
462
|
+
const merged = mergeText(o, a, b);
|
|
463
|
+
if (!merged) {
|
|
464
|
+
conflict(o, a, b, at, walk);
|
|
465
|
+
return b;
|
|
466
|
+
}
|
|
467
|
+
const { text, regions } = merged;
|
|
468
|
+
const parses = (body) => format !== 'mdx' || mdxParses(body);
|
|
469
|
+
if (!parses(text) && parses(a) && parses(b)) {
|
|
470
|
+
walk.conflicts.push({
|
|
471
|
+
at: at.segments,
|
|
472
|
+
places: at.places,
|
|
473
|
+
kind: 'changed',
|
|
474
|
+
...versions(o, a, b),
|
|
475
|
+
unparseable: true,
|
|
476
|
+
});
|
|
477
|
+
return b;
|
|
478
|
+
}
|
|
479
|
+
if (regions.length > 0) {
|
|
480
|
+
walk.conflicts.push({
|
|
481
|
+
at: at.segments,
|
|
482
|
+
places: at.places,
|
|
483
|
+
kind: 'changed',
|
|
484
|
+
...versions(o, a, b),
|
|
485
|
+
regions,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
return text;
|
|
489
|
+
}
|
|
490
|
+
export function mergeEntry({ format, ancestor, base, branch, fields, }) {
|
|
491
|
+
if (base.entryType !== branch.entryType) {
|
|
492
|
+
const baseRetyped = base.entryType !== ancestor.entryType;
|
|
493
|
+
const branchRetyped = branch.entryType !== ancestor.entryType;
|
|
494
|
+
const unchanged = baseRetyped ? branch : base;
|
|
495
|
+
if (baseRetyped && branchRetyped)
|
|
496
|
+
return { kind: 'entry-conflict', reason: 'entry-type' };
|
|
497
|
+
if (!sameContent(ancestor, unchanged))
|
|
498
|
+
return { kind: 'entry-conflict', reason: 'entry-type' };
|
|
499
|
+
const retyped = baseRetyped ? base : branch;
|
|
500
|
+
return {
|
|
501
|
+
kind: 'merged',
|
|
502
|
+
data: retyped.data,
|
|
503
|
+
...(retyped.body !== undefined && { body: retyped.body }),
|
|
504
|
+
conflicts: [],
|
|
505
|
+
keyRemaps: [],
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
const ancestorKeys = new Set();
|
|
509
|
+
visitItems(ancestor.data, (_item, key) => ancestorKeys.add(key));
|
|
510
|
+
// Each list by the own keys of the items enclosing it, as a parent's read key changes with its
|
|
511
|
+
// content and its own key does not; by its field path alone where the ancestor has no such
|
|
512
|
+
// parent, as for a keyless parent a side edited and then keyed.
|
|
513
|
+
const exact = new Map();
|
|
514
|
+
const byField = new Map();
|
|
515
|
+
const parents = new Set();
|
|
516
|
+
const field = (list) => list.replace(/\[[^\]]*\]/g, '[]');
|
|
517
|
+
// A keyless ancestor item by its place, and by the key a side's first save gave it.
|
|
518
|
+
for (const keylessByRead of [false, true]) {
|
|
519
|
+
visitByOwnKey(ancestor.data, (_item, read, list, at) => {
|
|
520
|
+
exact.set(list, (exact.get(list) ?? new Set()).add(read));
|
|
521
|
+
byField.set(field(list), (byField.get(field(list)) ?? new Set()).add(read));
|
|
522
|
+
parents.add(at);
|
|
523
|
+
}, keylessByRead);
|
|
524
|
+
}
|
|
525
|
+
const movedIn = (data) => {
|
|
526
|
+
const keys = new Set();
|
|
527
|
+
visitByOwnKey(data, (item, _read, list) => {
|
|
528
|
+
const own = usableKey(item);
|
|
529
|
+
if (own === undefined)
|
|
530
|
+
return;
|
|
531
|
+
const parent = list.slice(0, list.lastIndexOf(']') + 1);
|
|
532
|
+
const there = parent === '' || parents.has(parent) ? exact.get(list) : byField.get(field(list));
|
|
533
|
+
if (!there?.has(own))
|
|
534
|
+
keys.add(own);
|
|
535
|
+
});
|
|
536
|
+
return keys;
|
|
537
|
+
};
|
|
538
|
+
const asRead = (side) => (fields ? entryAsRead(fields, side.data) : side.data);
|
|
539
|
+
const root = { ...ROOT, asRead: { a: asRead(base), b: asRead(branch) } };
|
|
540
|
+
const walk = {
|
|
541
|
+
conflicts: [],
|
|
542
|
+
keyRemaps: [],
|
|
543
|
+
pendingRemaps: [],
|
|
544
|
+
texts: [],
|
|
545
|
+
ancestorKeys,
|
|
546
|
+
movedKeys: { a: movedIn(base.data), b: movedIn(branch.data) },
|
|
547
|
+
ambiguous: ambiguityOf(root.asRead.b, fields !== undefined),
|
|
548
|
+
};
|
|
549
|
+
const data = mergeRecord(ancestor.data, base.data, branch.data, root, walk);
|
|
550
|
+
if (fields)
|
|
551
|
+
undoUnparseableMdx(fields, data, walk);
|
|
552
|
+
flagItemsMovedAway(ancestor.data, base.data, branch.data, data, walk);
|
|
553
|
+
const hasBody = [ancestor, base, branch].some((side) => side.body !== undefined);
|
|
554
|
+
const bodyName = findBodyFieldName(fields ?? []);
|
|
555
|
+
const body = hasBody
|
|
556
|
+
? mergeBody(ancestor.body ?? '', base.body ?? '', branch.body ?? '', format, child(ROOT, { field: bodyName }, { o: ancestor.body, a: base.body, b: branch.body }), walk)
|
|
557
|
+
: undefined;
|
|
558
|
+
const read = fields ? entryAsRead(fields, data) : data;
|
|
559
|
+
for (const { at, within, from } of walk.pendingRemaps) {
|
|
560
|
+
const last = at.at(-1);
|
|
561
|
+
const list = valueAt(read, at.slice(0, -1));
|
|
562
|
+
const to = last && 'index' in last && Array.isArray(list) ? itemKeysAsRead(list)[last.index] : undefined;
|
|
563
|
+
if (to !== undefined && to !== from)
|
|
564
|
+
walk.keyRemaps.push({ within, from, to });
|
|
565
|
+
}
|
|
566
|
+
return {
|
|
567
|
+
kind: 'merged',
|
|
568
|
+
data,
|
|
569
|
+
...(body !== undefined && { body }),
|
|
570
|
+
conflicts: named(walk.conflicts, fields, read, {
|
|
571
|
+
o: withBody(ancestor, bodyName),
|
|
572
|
+
a: withBody(base, bodyName),
|
|
573
|
+
b: withBody(branch, bodyName),
|
|
574
|
+
}),
|
|
575
|
+
keyRemaps: walk.keyRemaps,
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* {@link Walk.ambiguous} for `branch`, as read. A schema's read keys each list a thread can name,
|
|
580
|
+
* so only keyed items count; with no schema, every list's items count by their read keys.
|
|
581
|
+
*/
|
|
582
|
+
function ambiguityOf(branch, schemaRead) {
|
|
583
|
+
const seen = new Map();
|
|
584
|
+
const name = (within, key) => JSON.stringify([...within, key]);
|
|
585
|
+
const visit = (node, within) => {
|
|
586
|
+
if (Array.isArray(node)) {
|
|
587
|
+
const readKeys = schemaRead ? [] : itemKeysAsRead(node);
|
|
588
|
+
node.forEach((item, n) => {
|
|
589
|
+
const key = schemaRead ? usableKey(item) : readKeys[n];
|
|
590
|
+
if (key === undefined)
|
|
591
|
+
return;
|
|
592
|
+
seen.set(name(within, key), (seen.get(name(within, key)) ?? 0) + 1);
|
|
593
|
+
visit(item, [...within, key]);
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
else if (isPlainRecord(node)) {
|
|
597
|
+
for (const value of Object.values(node))
|
|
598
|
+
visit(value, within);
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
visit(branch, []);
|
|
602
|
+
return (within, key) => (seen.get(name(within, key)) ?? 0) > 1;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Each item in `data`, at any depth, by the key a read gives it, so a keyless item is found by its
|
|
606
|
+
* content; null for a key held more than once in the entry.
|
|
607
|
+
*/
|
|
608
|
+
function keyedItems(data) {
|
|
609
|
+
const found = new Map();
|
|
610
|
+
visitItems(data, (item, key, list, at) => {
|
|
611
|
+
for (const name of [key, contentName(item)]) {
|
|
612
|
+
found.set(name, found.has(name) ? null : { item, list, at });
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
return found;
|
|
616
|
+
}
|
|
617
|
+
/** An item's content as a name, so an item a side moved without its key is still found. */
|
|
618
|
+
const contentName = (item) => `${ITEM_KEY}:${valueToken(item)}`;
|
|
619
|
+
/**
|
|
620
|
+
* Each list item of `node` with its read key, its list's path and its own: enclosing items by own
|
|
621
|
+
* key, else by place, or with `keylessByRead` by read key.
|
|
622
|
+
*/
|
|
623
|
+
function visitByOwnKey(node, onItem, keylessByRead = false, list = '') {
|
|
624
|
+
if (Array.isArray(node)) {
|
|
625
|
+
const reads = itemKeysAsRead(node);
|
|
626
|
+
node.forEach((item, index) => {
|
|
627
|
+
const own = usableKey(item);
|
|
628
|
+
const read = reads[index];
|
|
629
|
+
// A key a list holds twice names only its first holder.
|
|
630
|
+
const name = own !== undefined && own === read ? own : keylessByRead ? read : undefined;
|
|
631
|
+
const at = `${list}[${name ?? `#${index}`}]`;
|
|
632
|
+
if (read !== undefined)
|
|
633
|
+
onItem(item, read, list, at);
|
|
634
|
+
visitByOwnKey(item, onItem, keylessByRead, at);
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
else if (isPlainRecord(node)) {
|
|
638
|
+
for (const [name, value] of Object.entries(node)) {
|
|
639
|
+
visitByOwnKey(value, onItem, keylessByRead, `${list}.${name}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/** Calls `onItem` for every record in a list in `node`, with its read key, list and path. */
|
|
644
|
+
function visitItems(node, onItem, at = [], list = '') {
|
|
645
|
+
if (Array.isArray(node)) {
|
|
646
|
+
const keys = itemKeysAsRead(node);
|
|
647
|
+
node.forEach((item, index) => {
|
|
648
|
+
const key = keys[index];
|
|
649
|
+
const itemAt = [...at, { index }];
|
|
650
|
+
if (key !== undefined)
|
|
651
|
+
onItem(item, key, list, itemAt);
|
|
652
|
+
visitItems(item, onItem, itemAt, `${list}[${key ?? `#${index}`}]`);
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
else if (isPlainRecord(node)) {
|
|
656
|
+
for (const [name, value] of Object.entries(node)) {
|
|
657
|
+
visitItems(value, onItem, [...at, { field: name }], `${list}.${name}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Lists merge one at a time, so an item one side moved to another list survives there although
|
|
663
|
+
* the other side removed it, or is there twice when the other side changed it in its old place.
|
|
664
|
+
* An item the merged entry holds in another list than the ancestor did, which a side no longer has
|
|
665
|
+
* anywhere (by its key as read, so a keyless item by its content), or a keyed item the entry now
|
|
666
|
+
* holds twice, is an item conflict.
|
|
667
|
+
*/
|
|
668
|
+
function flagItemsMovedAway(ancestor, base, branch, merged, walk) {
|
|
669
|
+
const [o, a, b] = [keyedItems(ancestor), keyedItems(base), keyedItems(branch)];
|
|
670
|
+
const named = new Set(walk.conflicts.map((c) => JSON.stringify(c.at)));
|
|
671
|
+
const seen = new Set();
|
|
672
|
+
visitItems(merged, (item, key, list, at) => {
|
|
673
|
+
// A keyed item is found by its key alone: another item reading alike is not it. A keyless
|
|
674
|
+
// one, by its content too, which finds an item a side moved without its key.
|
|
675
|
+
const keyed = usableKey(item) !== undefined;
|
|
676
|
+
const content = contentName(item);
|
|
677
|
+
const find = (found) => found.has(key) || keyed ? found.get(key) : found.get(content);
|
|
678
|
+
const holds = (found) => found.has(key) || (!keyed && found.has(content));
|
|
679
|
+
const [inO, inA, inB] = [find(o), find(a), find(b)];
|
|
680
|
+
// A side holding it twice copied it: no move to judge.
|
|
681
|
+
if (inA === null || inB === null)
|
|
682
|
+
return;
|
|
683
|
+
const twice = keyed && seen.has(key);
|
|
684
|
+
seen.add(key);
|
|
685
|
+
const moved = inO && inO.list !== list;
|
|
686
|
+
const kind = twice
|
|
687
|
+
? 'changed'
|
|
688
|
+
: moved && !holds(a)
|
|
689
|
+
? 'removed-on-base'
|
|
690
|
+
: moved && !holds(b)
|
|
691
|
+
? 'removed-by-you'
|
|
692
|
+
: undefined;
|
|
693
|
+
if (!kind || named.has(JSON.stringify(at)))
|
|
694
|
+
return;
|
|
695
|
+
walk.conflicts.push({
|
|
696
|
+
at,
|
|
697
|
+
places: { o: inO?.at, a: inA?.at, b: inB?.at },
|
|
698
|
+
kind,
|
|
699
|
+
item: true,
|
|
700
|
+
...versions(inO?.item, inA?.item, inB?.item),
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* The conflicts as the drawer names them. Where the drawer shows a whole value (an image, a field
|
|
706
|
+
* the schema doesn't know) a conflict inside it is lifted to that value: its versions become the
|
|
707
|
+
* whole value's, and the conflicts inside it are one. The merged entry keeps what merged there.
|
|
708
|
+
*/
|
|
709
|
+
function named(conflicts, fields, read, sides) {
|
|
710
|
+
const seen = new Set();
|
|
711
|
+
const out = [];
|
|
712
|
+
const asRead = (side) => fields && isPlainRecord(side) ? entryAsRead(fields, side) : side;
|
|
713
|
+
const reads = { o: asRead(sides.o), a: asRead(sides.a), b: asRead(sides.b) };
|
|
714
|
+
for (const { at, places, placeTrail, trail, ...rest } of conflicts) {
|
|
715
|
+
const { key, dataPath, depth } = describePath(fields, read, at);
|
|
716
|
+
if (seen.has(key))
|
|
717
|
+
continue;
|
|
718
|
+
seen.add(key);
|
|
719
|
+
// Each version's own path to where its value below was taken: its place, or its place at the
|
|
720
|
+
// step the name stops at when the merge's trail gave the whole value, or the merged path when
|
|
721
|
+
// the whole value was found by member names alone. A version without the item a conflict names
|
|
722
|
+
// is named by the list it holds, one step short of `dataPath`, which says it lacks the item. A
|
|
723
|
+
// side naming the place at another depth offers none.
|
|
724
|
+
const prefix = at.slice(0, depth);
|
|
725
|
+
const byName = prefix.every((segment) => 'field' in segment);
|
|
726
|
+
const placeOf = (side) => depth === at.length
|
|
727
|
+
? places[side]
|
|
728
|
+
: trail?.[depth - 1]
|
|
729
|
+
? placeTrail?.[depth - 1]?.[side]
|
|
730
|
+
: byName
|
|
731
|
+
? prefix
|
|
732
|
+
: undefined;
|
|
733
|
+
const describe = (side, place) => {
|
|
734
|
+
const named = describePath(fields, reads[side], place);
|
|
735
|
+
return named.depth === place.length ? named.dataPath : undefined;
|
|
736
|
+
};
|
|
737
|
+
const sidePath = (side) => {
|
|
738
|
+
const place = placeOf(side);
|
|
739
|
+
if (place)
|
|
740
|
+
return describe(side, place);
|
|
741
|
+
const list = depth === at.length && rest.item ? placeTrail?.[at.length - 2]?.[side] : undefined;
|
|
742
|
+
const holder = list && describe(side, list);
|
|
743
|
+
const missing = dataPath.at(-1);
|
|
744
|
+
return holder && missing && 'item' in missing ? holder : undefined;
|
|
745
|
+
};
|
|
746
|
+
const [ancestor, base, branch] = [sidePath('o'), sidePath('a'), sidePath('b')];
|
|
747
|
+
const sidePaths = {
|
|
748
|
+
...(ancestor && { ancestor }),
|
|
749
|
+
...(base && { base }),
|
|
750
|
+
...(branch && { branch }),
|
|
751
|
+
};
|
|
752
|
+
if (depth === at.length) {
|
|
753
|
+
out.push({ key, dataPath, sidePaths, ...rest });
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
// The whole value's versions: from the merge's own trail, else by member names when the way
|
|
757
|
+
// there passes no list item (whose place differs between versions), else none to offer.
|
|
758
|
+
const whole = trail?.[depth - 1] ??
|
|
759
|
+
(byName
|
|
760
|
+
? { o: valueAt(sides.o, prefix), a: valueAt(sides.a, prefix), b: valueAt(sides.b, prefix) }
|
|
761
|
+
: undefined);
|
|
762
|
+
out.push({
|
|
763
|
+
key,
|
|
764
|
+
dataPath,
|
|
765
|
+
sidePaths,
|
|
766
|
+
kind: whole ? kindOf(whole.a, whole.b) : 'changed',
|
|
767
|
+
...(!('field' in at[depth - 1]) && { item: true }),
|
|
768
|
+
...(whole && versions(whole.o, whole.a, whole.b)),
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
/** The value at `at` in `data`. */
|
|
774
|
+
function valueAt(data, at) {
|
|
775
|
+
let node = data;
|
|
776
|
+
for (const segment of at) {
|
|
777
|
+
node =
|
|
778
|
+
'index' in segment
|
|
779
|
+
? asList(node)[segment.index]
|
|
780
|
+
: 'field' in segment && isPlainRecord(node)
|
|
781
|
+
? member(node, segment.field)
|
|
782
|
+
: undefined;
|
|
783
|
+
}
|
|
784
|
+
return node;
|
|
785
|
+
}
|
|
786
|
+
/** The member at `at` in `data`, which the merge built along every such path, set to `value`. */
|
|
787
|
+
function setAt(data, at, value) {
|
|
788
|
+
const holder = valueAt(data, at.slice(0, -1));
|
|
789
|
+
const last = at.at(-1);
|
|
790
|
+
if (last && 'field' in last && isPlainRecord(holder))
|
|
791
|
+
setMember(holder, last.field, value);
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* An `mdx` field whose line merge does not parse, where both sides' texts do: the branch's text
|
|
795
|
+
* goes back in, and the whole field is one conflict, as for an mdx body.
|
|
796
|
+
*/
|
|
797
|
+
function undoUnparseableMdx(fields, data, walk) {
|
|
798
|
+
for (const text of walk.texts) {
|
|
799
|
+
if (describePath(fields, data, text.at).field?.type !== 'mdx')
|
|
800
|
+
continue;
|
|
801
|
+
const { o, a, b } = text.versions;
|
|
802
|
+
if (mdxParses(String(valueAt(data, text.at))) || !mdxParses(a) || !mdxParses(b))
|
|
803
|
+
continue;
|
|
804
|
+
setAt(data, text.at, b);
|
|
805
|
+
const whole = {
|
|
806
|
+
at: text.at,
|
|
807
|
+
places: text.places,
|
|
808
|
+
placeTrail: text.placeTrail,
|
|
809
|
+
trail: text.conflict?.trail,
|
|
810
|
+
kind: 'changed',
|
|
811
|
+
...versions(o, a, b),
|
|
812
|
+
unparseable: true,
|
|
813
|
+
};
|
|
814
|
+
const at = text.conflict ? walk.conflicts.indexOf(text.conflict) : -1;
|
|
815
|
+
if (at === -1)
|
|
816
|
+
walk.conflicts.push(whole);
|
|
817
|
+
else
|
|
818
|
+
walk.conflicts[at] = whole;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* `a` (the base's) and `b` (the branch's), both changed from `o`, merged as ordered sets of
|
|
823
|
+
* distinct values: each side's adds and removes apply, and the order is `mergeOrder`'s. Nothing
|
|
824
|
+
* conflicts: a set entry has no content to change.
|
|
825
|
+
*/
|
|
826
|
+
function mergeOrderedSet(o, a, b) {
|
|
827
|
+
const ids = (list) => [...new Set(list.map(valueToken))];
|
|
828
|
+
const [oIds, aIds, bIds] = [ids(o), ids(a), ids(b)];
|
|
829
|
+
const value = new Map([...o, ...a, ...b].map((v) => [valueToken(v), v]));
|
|
830
|
+
const [inO, inA, inB] = [new Set(oIds), new Set(aIds), new Set(bIds)];
|
|
831
|
+
return mergeOrder(oIds, aIds, bIds)
|
|
832
|
+
.filter((id) => (inO.has(id) ? inA.has(id) && inB.has(id) : inA.has(id) || inB.has(id)))
|
|
833
|
+
.map((id) => value.get(id));
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Three-way merge of a collection's `.collection.json`: `order` as an ordered set of content IDs,
|
|
837
|
+
* so two branches that each add an entry both keep theirs; `entries` matched by `name`; every
|
|
838
|
+
* other member by the entry rules.
|
|
839
|
+
*/
|
|
840
|
+
export function mergeCollectionMeta(ancestor, base, branch) {
|
|
841
|
+
const walk = {
|
|
842
|
+
conflicts: [],
|
|
843
|
+
keyRemaps: [],
|
|
844
|
+
pendingRemaps: [],
|
|
845
|
+
texts: [],
|
|
846
|
+
ancestorKeys: new Set(),
|
|
847
|
+
movedKeys: { a: new Set(), b: new Set() },
|
|
848
|
+
ambiguous: () => false,
|
|
849
|
+
};
|
|
850
|
+
const data = {};
|
|
851
|
+
for (const name of new Set([
|
|
852
|
+
...Object.keys(branch),
|
|
853
|
+
...Object.keys(base),
|
|
854
|
+
...Object.keys(ancestor),
|
|
855
|
+
])) {
|
|
856
|
+
const [o, a, b] = [member(ancestor, name), member(base, name), member(branch, name)];
|
|
857
|
+
const at = child(ROOT, { field: name }, { o, a, b });
|
|
858
|
+
const lists = [o, a, b].every((v) => v === undefined || Array.isArray(v));
|
|
859
|
+
let v;
|
|
860
|
+
if (name === 'order' && lists && !sameValue(o, b) && !sameValue(o, a)) {
|
|
861
|
+
v = mergeOrderedSet(asList(o), asList(a), asList(b));
|
|
862
|
+
}
|
|
863
|
+
else if (name === 'entries' && [o, a, b].every((x) => x === undefined || isRecordList(x))) {
|
|
864
|
+
v = mergeList(o, asList(a), asList(b), at, walk, 'name') ?? mergeLeaf(o, a, b, at, walk);
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
v = mergeValue(o, a, b, at, walk);
|
|
868
|
+
}
|
|
869
|
+
if (v !== undefined)
|
|
870
|
+
setMember(data, name, v);
|
|
871
|
+
}
|
|
872
|
+
return {
|
|
873
|
+
data,
|
|
874
|
+
conflicts: named(walk.conflicts, undefined, data, { o: ancestor, a: base, b: branch }),
|
|
875
|
+
};
|
|
876
|
+
}
|