@zseven-w/pen-core 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +177 -25
- package/package.json +19 -5
- package/src/__tests__/arc-path.test.ts +23 -23
- package/src/__tests__/codegen-utils.test.ts +50 -0
- package/src/__tests__/design-md-parser.test.ts +49 -0
- package/src/__tests__/font-utils.test.ts +15 -15
- package/src/__tests__/layout-engine.test.ts +169 -83
- package/src/__tests__/merge-helpers.test.ts +143 -0
- package/src/__tests__/node-diff.test.ts +139 -0
- package/src/__tests__/node-helpers.test.ts +19 -19
- package/src/__tests__/node-merge.test.ts +425 -0
- package/src/__tests__/normalize-stroke-fill-schema.test.ts +416 -0
- package/src/__tests__/normalize-tree-layout.test.ts +294 -0
- package/src/__tests__/normalize.test.ts +119 -80
- package/src/__tests__/path-anchors.test.ts +98 -0
- package/src/__tests__/resolve-variables-recursive.test.ts +109 -54
- package/src/__tests__/strip-redundant-section-fills.test.ts +278 -0
- package/src/__tests__/text-measure.test.ts +84 -79
- package/src/__tests__/tree-utils.test.ts +133 -102
- package/src/__tests__/unwrap-fake-phone-mockup.test.ts +322 -0
- package/src/__tests__/variables.test.ts +68 -65
- package/src/arc-path.ts +35 -35
- package/src/boolean-ops.ts +131 -142
- package/src/constants.ts +36 -36
- package/src/design-md-parser.ts +363 -0
- package/src/font-utils.ts +30 -15
- package/src/id.ts +1 -1
- package/src/index.ts +47 -13
- package/src/layout/engine.ts +255 -224
- package/src/layout/normalize-tree.ts +140 -0
- package/src/layout/strip-redundant-section-fills.ts +155 -0
- package/src/layout/text-measure.ts +130 -106
- package/src/layout/unwrap-fake-phone-mockup.ts +147 -0
- package/src/merge/index.ts +16 -0
- package/src/merge/merge-helpers.ts +113 -0
- package/src/merge/node-diff.ts +123 -0
- package/src/merge/node-merge.ts +651 -0
- package/src/node-helpers.ts +18 -5
- package/src/normalize/normalize-stroke-fill-schema.ts +297 -0
- package/src/normalize.ts +75 -81
- package/src/path-anchors.ts +331 -0
- package/src/sync-lock.ts +3 -3
- package/src/tree-utils.ts +180 -158
- package/src/variables/replace-refs.ts +63 -60
- package/src/variables/resolve.ts +98 -106
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
// packages/pen-core/src/merge/node-merge.ts
|
|
2
|
+
//
|
|
3
|
+
// Pure 3-way merge of PenDocument trees.
|
|
4
|
+
|
|
5
|
+
import type { PenDocument, PenNode } from '@zseven-w/pen-types';
|
|
6
|
+
import {
|
|
7
|
+
indexNodesById,
|
|
8
|
+
nodeFieldsEqual,
|
|
9
|
+
stripChildren,
|
|
10
|
+
jsonEqual,
|
|
11
|
+
type IndexedNode,
|
|
12
|
+
} from './merge-helpers.js';
|
|
13
|
+
|
|
14
|
+
export interface MergeInput {
|
|
15
|
+
base: PenDocument;
|
|
16
|
+
ours: PenDocument;
|
|
17
|
+
theirs: PenDocument;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type NodeConflictReason =
|
|
21
|
+
| 'both-modified-same-field'
|
|
22
|
+
| 'modify-vs-delete'
|
|
23
|
+
| 'add-vs-add-different'
|
|
24
|
+
| 'reparent-conflict';
|
|
25
|
+
|
|
26
|
+
export interface NodeConflict {
|
|
27
|
+
pageId: string | null;
|
|
28
|
+
nodeId: string;
|
|
29
|
+
reason: NodeConflictReason;
|
|
30
|
+
base: PenNode | null;
|
|
31
|
+
ours: PenNode | null;
|
|
32
|
+
theirs: PenNode | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type DocFieldName = 'variables' | 'themes' | 'pages-order' | 'name' | 'version';
|
|
36
|
+
|
|
37
|
+
export interface DocFieldConflict {
|
|
38
|
+
field: DocFieldName;
|
|
39
|
+
path: string;
|
|
40
|
+
base: unknown;
|
|
41
|
+
ours: unknown;
|
|
42
|
+
theirs: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MergeResult {
|
|
46
|
+
merged: PenDocument;
|
|
47
|
+
nodeConflicts: NodeConflict[];
|
|
48
|
+
docFieldConflicts: DocFieldConflict[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Per-id decision the classification step produces. Invariants:
|
|
53
|
+
* - Every id in the merged-id union is either present in the decisions map
|
|
54
|
+
* OR omitted entirely. A null `mergedNode` means "explicitly deleted by
|
|
55
|
+
* the merge"; an absent map entry means "the rebuild step shouldn't see
|
|
56
|
+
* this id at all" (which currently never happens — every id produces a
|
|
57
|
+
* decision). The two states are kept distinct so the rebuild step in
|
|
58
|
+
* Task 9 can assert on its inputs.
|
|
59
|
+
* - When `mergedNode` is non-null, `mergedFields` excludes the `children`
|
|
60
|
+
* field. The rebuild step in Task 9 produces children by walking the
|
|
61
|
+
* decision map, not by reading the merged-node value.
|
|
62
|
+
* - `index` is a HINT for ordering inside the new parent. The rebuild step
|
|
63
|
+
* prefers ours' indices when both sides modified the same parent (this
|
|
64
|
+
* matches the spec's "fall back to ours" tiebreaker).
|
|
65
|
+
*/
|
|
66
|
+
interface NodeDecision {
|
|
67
|
+
/** The merged node, with `children` left empty (the rebuild step fills them). null = deleted */
|
|
68
|
+
mergedNode: PenNode | null;
|
|
69
|
+
/** Where the merged node should live; null when mergedNode is null */
|
|
70
|
+
pageId: string | null;
|
|
71
|
+
parentId: string | null;
|
|
72
|
+
/** Position hint within parent.children; the rebuild step uses ours' index when available */
|
|
73
|
+
index: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Pure 3-way merge of PenDocument trees.
|
|
78
|
+
*
|
|
79
|
+
* Implementation is split:
|
|
80
|
+
* - Tasks 7-8: classify each node id, produce node-level conflicts
|
|
81
|
+
* - Task 9: rebuild the merged tree from decisions
|
|
82
|
+
* - Task 10: merge doc-level fields (variables, themes, pages-order, name, version)
|
|
83
|
+
* - Task 11: tests
|
|
84
|
+
*/
|
|
85
|
+
export function mergeDocuments(input: MergeInput): MergeResult {
|
|
86
|
+
const { base, ours, theirs } = input;
|
|
87
|
+
const baseIdx = indexNodesById(base);
|
|
88
|
+
const oursIdx = indexNodesById(ours);
|
|
89
|
+
const theirsIdx = indexNodesById(theirs);
|
|
90
|
+
|
|
91
|
+
const allIds = new Set<string>([...baseIdx.keys(), ...oursIdx.keys(), ...theirsIdx.keys()]);
|
|
92
|
+
|
|
93
|
+
const decisions = new Map<string, NodeDecision>();
|
|
94
|
+
const nodeConflicts: NodeConflict[] = [];
|
|
95
|
+
|
|
96
|
+
for (const id of allIds) {
|
|
97
|
+
const b = baseIdx.get(id);
|
|
98
|
+
const o = oursIdx.get(id);
|
|
99
|
+
const t = theirsIdx.get(id);
|
|
100
|
+
const result = classifyNode(id, b, o, t);
|
|
101
|
+
if (result.conflict) nodeConflicts.push(result.conflict);
|
|
102
|
+
if (result.decision) decisions.set(id, result.decision);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const merged = rebuildDocument(ours, decisions);
|
|
106
|
+
const docFieldConflicts: DocFieldConflict[] = [];
|
|
107
|
+
|
|
108
|
+
// Merge doc-level scalar fields (name, version)
|
|
109
|
+
mergeDocScalarField(base, ours, theirs, 'name', merged, docFieldConflicts);
|
|
110
|
+
mergeDocScalarField(base, ours, theirs, 'version', merged, docFieldConflicts);
|
|
111
|
+
|
|
112
|
+
// Merge variables (object map)
|
|
113
|
+
merged.variables = mergeRecord(
|
|
114
|
+
base.variables,
|
|
115
|
+
ours.variables,
|
|
116
|
+
theirs.variables,
|
|
117
|
+
'variables',
|
|
118
|
+
docFieldConflicts,
|
|
119
|
+
);
|
|
120
|
+
if (merged.variables && Object.keys(merged.variables).length === 0) {
|
|
121
|
+
delete merged.variables;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Merge themes (object map of string[])
|
|
125
|
+
merged.themes = mergeRecord(base.themes, ours.themes, theirs.themes, 'themes', docFieldConflicts);
|
|
126
|
+
if (merged.themes && Object.keys(merged.themes).length === 0) {
|
|
127
|
+
delete merged.themes;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Merge pages order (only if both sides have pages)
|
|
131
|
+
if (base.pages && ours.pages && theirs.pages) {
|
|
132
|
+
const reorderConflict = detectPagesOrderConflict(base.pages, ours.pages, theirs.pages);
|
|
133
|
+
if (reorderConflict) {
|
|
134
|
+
docFieldConflicts.push(reorderConflict);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
merged,
|
|
140
|
+
nodeConflicts,
|
|
141
|
+
docFieldConflicts,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 3-way merge for a top-level scalar string field on PenDocument (name/version).
|
|
147
|
+
*/
|
|
148
|
+
function mergeDocScalarField(
|
|
149
|
+
base: PenDocument,
|
|
150
|
+
ours: PenDocument,
|
|
151
|
+
theirs: PenDocument,
|
|
152
|
+
field: 'name' | 'version',
|
|
153
|
+
merged: PenDocument,
|
|
154
|
+
conflicts: DocFieldConflict[],
|
|
155
|
+
): void {
|
|
156
|
+
const bv = base[field];
|
|
157
|
+
const ov = ours[field];
|
|
158
|
+
const tv = theirs[field];
|
|
159
|
+
if (jsonEqual(ov, bv) && jsonEqual(tv, bv)) {
|
|
160
|
+
if (bv !== undefined) merged[field] = bv as string;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (jsonEqual(ov, bv)) {
|
|
164
|
+
if (tv !== undefined) merged[field] = tv as string;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (jsonEqual(tv, bv)) {
|
|
168
|
+
if (ov !== undefined) merged[field] = ov as string;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (jsonEqual(ov, tv)) {
|
|
172
|
+
if (ov !== undefined) merged[field] = ov as string;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Both changed differently — conflict; take ours as placeholder.
|
|
176
|
+
if (ov !== undefined) merged[field] = ov as string;
|
|
177
|
+
conflicts.push({
|
|
178
|
+
field,
|
|
179
|
+
path: field,
|
|
180
|
+
base: bv,
|
|
181
|
+
ours: ov,
|
|
182
|
+
theirs: tv,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 3-way merge for an object record (variables, themes). Walks every key from
|
|
188
|
+
* the union and applies the per-key merge rules. Returns the merged record.
|
|
189
|
+
*/
|
|
190
|
+
function mergeRecord<V>(
|
|
191
|
+
base: Record<string, V> | undefined,
|
|
192
|
+
ours: Record<string, V> | undefined,
|
|
193
|
+
theirs: Record<string, V> | undefined,
|
|
194
|
+
fieldName: 'variables' | 'themes',
|
|
195
|
+
conflicts: DocFieldConflict[],
|
|
196
|
+
): Record<string, V> | undefined {
|
|
197
|
+
if (!base && !ours && !theirs) return undefined;
|
|
198
|
+
const b = base ?? {};
|
|
199
|
+
const o = ours ?? {};
|
|
200
|
+
const t = theirs ?? {};
|
|
201
|
+
const allKeys = new Set<string>([...Object.keys(b), ...Object.keys(o), ...Object.keys(t)]);
|
|
202
|
+
const merged: Record<string, V> = {};
|
|
203
|
+
for (const key of allKeys) {
|
|
204
|
+
const bv = b[key];
|
|
205
|
+
const ov = o[key];
|
|
206
|
+
const tv = t[key];
|
|
207
|
+
const inBase = key in b;
|
|
208
|
+
const inOurs = key in o;
|
|
209
|
+
const inTheirs = key in t;
|
|
210
|
+
|
|
211
|
+
// Use the same 7-grid logic as nodes.
|
|
212
|
+
if (inBase && inOurs && inTheirs) {
|
|
213
|
+
// ✓✓✓
|
|
214
|
+
if (jsonEqual(ov, bv) && jsonEqual(tv, bv)) {
|
|
215
|
+
merged[key] = bv;
|
|
216
|
+
} else if (jsonEqual(ov, bv)) {
|
|
217
|
+
merged[key] = tv;
|
|
218
|
+
} else if (jsonEqual(tv, bv)) {
|
|
219
|
+
merged[key] = ov;
|
|
220
|
+
} else if (jsonEqual(ov, tv)) {
|
|
221
|
+
merged[key] = ov;
|
|
222
|
+
} else {
|
|
223
|
+
merged[key] = ov;
|
|
224
|
+
conflicts.push({
|
|
225
|
+
field: fieldName,
|
|
226
|
+
path: `${fieldName}.${key}`,
|
|
227
|
+
base: bv,
|
|
228
|
+
ours: ov,
|
|
229
|
+
theirs: tv,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} else if (inBase && inOurs && !inTheirs) {
|
|
233
|
+
// ✓✓✗ — theirs deleted
|
|
234
|
+
if (jsonEqual(ov, bv)) {
|
|
235
|
+
// ours unchanged → delete
|
|
236
|
+
} else {
|
|
237
|
+
// ours modified → conflict; take ours
|
|
238
|
+
merged[key] = ov;
|
|
239
|
+
conflicts.push({
|
|
240
|
+
field: fieldName,
|
|
241
|
+
path: `${fieldName}.${key}`,
|
|
242
|
+
base: bv,
|
|
243
|
+
ours: ov,
|
|
244
|
+
theirs: undefined,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
} else if (inBase && !inOurs && inTheirs) {
|
|
248
|
+
// ✓✗✓ — ours deleted
|
|
249
|
+
if (jsonEqual(tv, bv)) {
|
|
250
|
+
// theirs unchanged → delete
|
|
251
|
+
} else {
|
|
252
|
+
// theirs modified → conflict; take theirs
|
|
253
|
+
merged[key] = tv;
|
|
254
|
+
conflicts.push({
|
|
255
|
+
field: fieldName,
|
|
256
|
+
path: `${fieldName}.${key}`,
|
|
257
|
+
base: bv,
|
|
258
|
+
ours: undefined,
|
|
259
|
+
theirs: tv,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
} else if (inBase && !inOurs && !inTheirs) {
|
|
263
|
+
// ✓✗✗ — both deleted
|
|
264
|
+
} else if (!inBase && inOurs && inTheirs) {
|
|
265
|
+
// ✗✓✓ — both added
|
|
266
|
+
if (jsonEqual(ov, tv)) {
|
|
267
|
+
merged[key] = ov;
|
|
268
|
+
} else {
|
|
269
|
+
merged[key] = ov;
|
|
270
|
+
conflicts.push({
|
|
271
|
+
field: fieldName,
|
|
272
|
+
path: `${fieldName}.${key}`,
|
|
273
|
+
base: undefined,
|
|
274
|
+
ours: ov,
|
|
275
|
+
theirs: tv,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
} else if (!inBase && inOurs && !inTheirs) {
|
|
279
|
+
merged[key] = ov;
|
|
280
|
+
} else if (!inBase && !inOurs && inTheirs) {
|
|
281
|
+
merged[key] = tv;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return merged;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Detect a `pages-order` conflict. Returns a DocFieldConflict if both sides
|
|
289
|
+
* reordered pages differently from base; otherwise returns null. The merge
|
|
290
|
+
* leaves the pages in ours' order (the rebuildDocument step already did this).
|
|
291
|
+
*/
|
|
292
|
+
function detectPagesOrderConflict(
|
|
293
|
+
basePages: PenDocument['pages'],
|
|
294
|
+
oursPages: PenDocument['pages'],
|
|
295
|
+
theirsPages: PenDocument['pages'],
|
|
296
|
+
): DocFieldConflict | null {
|
|
297
|
+
if (!basePages || !oursPages || !theirsPages) return null;
|
|
298
|
+
const baseOrder = basePages.map((p) => p.id);
|
|
299
|
+
const oursOrder = oursPages.map((p) => p.id);
|
|
300
|
+
const theirsOrder = theirsPages.map((p) => p.id);
|
|
301
|
+
const oursReordered = !sequenceEqual(baseOrder, oursOrder);
|
|
302
|
+
const theirsReordered = !sequenceEqual(baseOrder, theirsOrder);
|
|
303
|
+
if (!oursReordered || !theirsReordered) return null;
|
|
304
|
+
if (sequenceEqual(oursOrder, theirsOrder)) return null;
|
|
305
|
+
return {
|
|
306
|
+
field: 'pages-order',
|
|
307
|
+
path: 'pages-order',
|
|
308
|
+
base: baseOrder,
|
|
309
|
+
ours: oursOrder,
|
|
310
|
+
theirs: theirsOrder,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function sequenceEqual(a: string[], b: string[]): boolean {
|
|
315
|
+
if (a.length !== b.length) return false;
|
|
316
|
+
for (let i = 0; i < a.length; i++) {
|
|
317
|
+
if (a[i] !== b[i]) return false;
|
|
318
|
+
}
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Rebuild a PenDocument from a decision map. Uses ours' page structure as the
|
|
324
|
+
* scaffold (so users see their own page list). For each merged node, attach it
|
|
325
|
+
* to its merged parent at its merged index.
|
|
326
|
+
*
|
|
327
|
+
* Algorithm:
|
|
328
|
+
* 1. Group decisions by (pageId, parentId).
|
|
329
|
+
* 2. For each page in ours (or the legacy single page):
|
|
330
|
+
* a. Build the page's top-level children list from decisions where
|
|
331
|
+
* parentId === null and pageId === thisPageId.
|
|
332
|
+
* b. Recursively populate each container's children from decisions where
|
|
333
|
+
* parentId === containerId.
|
|
334
|
+
* 3. Within a parent, sort by `index` ascending; ties broken by id for
|
|
335
|
+
* determinism.
|
|
336
|
+
*/
|
|
337
|
+
function rebuildDocument(scaffold: PenDocument, decisions: Map<string, NodeDecision>): PenDocument {
|
|
338
|
+
// Group by parent for fast child lookup.
|
|
339
|
+
const byParent = new Map<string, NodeDecision[]>();
|
|
340
|
+
for (const decision of decisions.values()) {
|
|
341
|
+
if (!decision.mergedNode) continue;
|
|
342
|
+
const key = `${decision.pageId ?? '_'}::${decision.parentId ?? '_'}`;
|
|
343
|
+
let bucket = byParent.get(key);
|
|
344
|
+
if (!bucket) {
|
|
345
|
+
bucket = [];
|
|
346
|
+
byParent.set(key, bucket);
|
|
347
|
+
}
|
|
348
|
+
bucket.push(decision);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function buildChildren(pageId: string | null, parentId: string | null): PenNode[] {
|
|
352
|
+
const key = `${pageId ?? '_'}::${parentId ?? '_'}`;
|
|
353
|
+
const bucket = byParent.get(key);
|
|
354
|
+
if (!bucket) return [];
|
|
355
|
+
// Stable sort by (index, id).
|
|
356
|
+
const sorted = [...bucket].sort((a, b) => {
|
|
357
|
+
if (a.index !== b.index) return a.index - b.index;
|
|
358
|
+
const ai = a.mergedNode ? a.mergedNode.id : '';
|
|
359
|
+
const bi = b.mergedNode ? b.mergedNode.id : '';
|
|
360
|
+
return ai.localeCompare(bi);
|
|
361
|
+
});
|
|
362
|
+
return sorted.map((decision) => {
|
|
363
|
+
const node = decision.mergedNode!;
|
|
364
|
+
const children = buildChildren(pageId, node.id);
|
|
365
|
+
// Only attach a children array if the original node type supports it
|
|
366
|
+
// (containers). Following the existing PenDocument convention, attach
|
|
367
|
+
// the children only if the node already had `children` or if there are
|
|
368
|
+
// any children to attach.
|
|
369
|
+
if (children.length > 0 || (node as { children?: PenNode[] }).children !== undefined) {
|
|
370
|
+
return { ...node, children } as PenNode;
|
|
371
|
+
}
|
|
372
|
+
return node;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Use ours' page structure as the scaffold.
|
|
377
|
+
if (scaffold.pages && scaffold.pages.length > 0) {
|
|
378
|
+
const newPages = scaffold.pages.map((page) => ({
|
|
379
|
+
id: page.id,
|
|
380
|
+
name: page.name,
|
|
381
|
+
children: buildChildren(page.id, null),
|
|
382
|
+
}));
|
|
383
|
+
return {
|
|
384
|
+
...scaffold,
|
|
385
|
+
pages: newPages,
|
|
386
|
+
children: [],
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
// Legacy single-page mode.
|
|
390
|
+
return {
|
|
391
|
+
...scaffold,
|
|
392
|
+
children: buildChildren(null, null),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Classify a single node id across the three trees and produce both a decision
|
|
398
|
+
* (the merged node value to use, or null for delete) and an optional conflict.
|
|
399
|
+
*
|
|
400
|
+
* The 7-grid:
|
|
401
|
+
* ✓✓✓ → field-level merge (may produce conflict)
|
|
402
|
+
* ✓✓✗ → modify-vs-delete OR clean delete
|
|
403
|
+
* ✓✗✓ → modify-vs-delete OR clean delete (symmetric)
|
|
404
|
+
* ✓✗✗ → both deleted; no merged node
|
|
405
|
+
* ✗✓✓ → add-vs-add (equal → take ours; different → conflict)
|
|
406
|
+
* ✗✓✗ → only ours added; take ours
|
|
407
|
+
* ✗✗✓ → only theirs added; take theirs
|
|
408
|
+
*/
|
|
409
|
+
function classifyNode(
|
|
410
|
+
id: string,
|
|
411
|
+
b: IndexedNode | undefined,
|
|
412
|
+
o: IndexedNode | undefined,
|
|
413
|
+
t: IndexedNode | undefined,
|
|
414
|
+
): { decision: NodeDecision | null; conflict: NodeConflict | null } {
|
|
415
|
+
// ✓✓✓
|
|
416
|
+
if (b && o && t) {
|
|
417
|
+
return mergeThreeWay(id, b, o, t);
|
|
418
|
+
}
|
|
419
|
+
// ✓✓✗ — theirs deleted
|
|
420
|
+
if (b && o && !t) {
|
|
421
|
+
if (nodeFieldsEqual(b.node, o.node)) {
|
|
422
|
+
// ours unchanged, theirs deleted → delete
|
|
423
|
+
return { decision: null, conflict: null };
|
|
424
|
+
}
|
|
425
|
+
// ours modified, theirs deleted → conflict
|
|
426
|
+
return {
|
|
427
|
+
decision: {
|
|
428
|
+
mergedNode: o.node,
|
|
429
|
+
pageId: o.pageId,
|
|
430
|
+
parentId: o.parentId,
|
|
431
|
+
index: o.index,
|
|
432
|
+
},
|
|
433
|
+
conflict: {
|
|
434
|
+
pageId: o.pageId,
|
|
435
|
+
nodeId: id,
|
|
436
|
+
reason: 'modify-vs-delete',
|
|
437
|
+
base: b.node,
|
|
438
|
+
ours: o.node,
|
|
439
|
+
theirs: null,
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
// ✓✗✓ — ours deleted
|
|
444
|
+
if (b && !o && t) {
|
|
445
|
+
if (nodeFieldsEqual(b.node, t.node)) {
|
|
446
|
+
return { decision: null, conflict: null };
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
decision: {
|
|
450
|
+
mergedNode: t.node,
|
|
451
|
+
pageId: t.pageId,
|
|
452
|
+
parentId: t.parentId,
|
|
453
|
+
index: t.index,
|
|
454
|
+
},
|
|
455
|
+
conflict: {
|
|
456
|
+
pageId: t.pageId,
|
|
457
|
+
nodeId: id,
|
|
458
|
+
reason: 'modify-vs-delete',
|
|
459
|
+
base: b.node,
|
|
460
|
+
ours: null,
|
|
461
|
+
theirs: t.node,
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
// ✓✗✗ — both deleted
|
|
466
|
+
if (b && !o && !t) {
|
|
467
|
+
return { decision: null, conflict: null };
|
|
468
|
+
}
|
|
469
|
+
// ✗✓✓ — both added
|
|
470
|
+
if (!b && o && t) {
|
|
471
|
+
if (nodeFieldsEqual(o.node, t.node)) {
|
|
472
|
+
return {
|
|
473
|
+
decision: {
|
|
474
|
+
mergedNode: o.node,
|
|
475
|
+
pageId: o.pageId,
|
|
476
|
+
parentId: o.parentId,
|
|
477
|
+
index: o.index,
|
|
478
|
+
},
|
|
479
|
+
conflict: null,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return {
|
|
483
|
+
decision: {
|
|
484
|
+
mergedNode: o.node,
|
|
485
|
+
pageId: o.pageId,
|
|
486
|
+
parentId: o.parentId,
|
|
487
|
+
index: o.index,
|
|
488
|
+
},
|
|
489
|
+
conflict: {
|
|
490
|
+
pageId: o.pageId,
|
|
491
|
+
nodeId: id,
|
|
492
|
+
reason: 'add-vs-add-different',
|
|
493
|
+
base: null,
|
|
494
|
+
ours: o.node,
|
|
495
|
+
theirs: t.node,
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
// ✗✓✗ — ours only
|
|
500
|
+
if (!b && o && !t) {
|
|
501
|
+
return {
|
|
502
|
+
decision: {
|
|
503
|
+
mergedNode: o.node,
|
|
504
|
+
pageId: o.pageId,
|
|
505
|
+
parentId: o.parentId,
|
|
506
|
+
index: o.index,
|
|
507
|
+
},
|
|
508
|
+
conflict: null,
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
// ✗✗✓ — theirs only
|
|
512
|
+
if (!b && !o && t) {
|
|
513
|
+
return {
|
|
514
|
+
decision: {
|
|
515
|
+
mergedNode: t.node,
|
|
516
|
+
pageId: t.pageId,
|
|
517
|
+
parentId: t.parentId,
|
|
518
|
+
index: t.index,
|
|
519
|
+
},
|
|
520
|
+
conflict: null,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
// Unreachable: id appeared in the union but is in none of the three indices.
|
|
524
|
+
return { decision: null, conflict: null };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Three-way present case: do field-level merge between b, o, t and detect both
|
|
529
|
+
* `both-modified-same-field` and `reparent-conflict` cases.
|
|
530
|
+
*/
|
|
531
|
+
function mergeThreeWay(
|
|
532
|
+
id: string,
|
|
533
|
+
b: IndexedNode,
|
|
534
|
+
o: IndexedNode,
|
|
535
|
+
t: IndexedNode,
|
|
536
|
+
): { decision: NodeDecision | null; conflict: NodeConflict | null } {
|
|
537
|
+
// First: detect reparent conflict.
|
|
538
|
+
const oursMoved = o.parentId !== b.parentId || o.pageId !== b.pageId;
|
|
539
|
+
const theirsMoved = t.parentId !== b.parentId || t.pageId !== b.pageId;
|
|
540
|
+
let mergedParentId = b.parentId;
|
|
541
|
+
let mergedPageId = b.pageId;
|
|
542
|
+
let reparentConflict = false;
|
|
543
|
+
if (oursMoved && theirsMoved) {
|
|
544
|
+
if (o.parentId === t.parentId && o.pageId === t.pageId) {
|
|
545
|
+
// Both moved to the same place — auto-merge to that.
|
|
546
|
+
mergedParentId = o.parentId;
|
|
547
|
+
mergedPageId = o.pageId;
|
|
548
|
+
} else {
|
|
549
|
+
// Diverging moves.
|
|
550
|
+
reparentConflict = true;
|
|
551
|
+
mergedParentId = o.parentId;
|
|
552
|
+
mergedPageId = o.pageId;
|
|
553
|
+
}
|
|
554
|
+
} else if (oursMoved) {
|
|
555
|
+
mergedParentId = o.parentId;
|
|
556
|
+
mergedPageId = o.pageId;
|
|
557
|
+
} else if (theirsMoved) {
|
|
558
|
+
mergedParentId = t.parentId;
|
|
559
|
+
mergedPageId = t.pageId;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Second: per-field 3-way merge of atomic fields.
|
|
563
|
+
const baseFields = stripChildren(b.node) as Record<string, unknown>;
|
|
564
|
+
const oursFields = stripChildren(o.node) as Record<string, unknown>;
|
|
565
|
+
const theirsFields = stripChildren(t.node) as Record<string, unknown>;
|
|
566
|
+
const allKeys = new Set<string>([
|
|
567
|
+
...Object.keys(baseFields),
|
|
568
|
+
...Object.keys(oursFields),
|
|
569
|
+
...Object.keys(theirsFields),
|
|
570
|
+
]);
|
|
571
|
+
|
|
572
|
+
const mergedFields: Record<string, unknown> = {};
|
|
573
|
+
let fieldConflict = false;
|
|
574
|
+
for (const key of allKeys) {
|
|
575
|
+
const bv = baseFields[key];
|
|
576
|
+
const ov = oursFields[key];
|
|
577
|
+
const tv = theirsFields[key];
|
|
578
|
+
const oursChanged = !jsonEqual(ov, bv);
|
|
579
|
+
const theirsChanged = !jsonEqual(tv, bv);
|
|
580
|
+
if (!oursChanged && !theirsChanged) {
|
|
581
|
+
if (bv !== undefined) mergedFields[key] = bv;
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
if (oursChanged && !theirsChanged) {
|
|
585
|
+
if (ov !== undefined) mergedFields[key] = ov;
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
if (!oursChanged && theirsChanged) {
|
|
589
|
+
if (tv !== undefined) mergedFields[key] = tv;
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
// Both changed.
|
|
593
|
+
if (jsonEqual(ov, tv)) {
|
|
594
|
+
if (ov !== undefined) mergedFields[key] = ov;
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
// Conflict — take ours as placeholder.
|
|
598
|
+
fieldConflict = true;
|
|
599
|
+
if (ov !== undefined) mergedFields[key] = ov;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const mergedNode = mergedFields as unknown as PenNode;
|
|
603
|
+
|
|
604
|
+
if (reparentConflict) {
|
|
605
|
+
return {
|
|
606
|
+
decision: {
|
|
607
|
+
mergedNode,
|
|
608
|
+
pageId: mergedPageId,
|
|
609
|
+
parentId: mergedParentId,
|
|
610
|
+
index: o.index,
|
|
611
|
+
},
|
|
612
|
+
conflict: {
|
|
613
|
+
pageId: mergedPageId,
|
|
614
|
+
nodeId: id,
|
|
615
|
+
reason: 'reparent-conflict',
|
|
616
|
+
base: b.node,
|
|
617
|
+
ours: o.node,
|
|
618
|
+
theirs: t.node,
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
if (fieldConflict) {
|
|
624
|
+
return {
|
|
625
|
+
decision: {
|
|
626
|
+
mergedNode,
|
|
627
|
+
pageId: mergedPageId,
|
|
628
|
+
parentId: mergedParentId,
|
|
629
|
+
index: o.index,
|
|
630
|
+
},
|
|
631
|
+
conflict: {
|
|
632
|
+
pageId: mergedPageId,
|
|
633
|
+
nodeId: id,
|
|
634
|
+
reason: 'both-modified-same-field',
|
|
635
|
+
base: b.node,
|
|
636
|
+
ours: o.node,
|
|
637
|
+
theirs: t.node,
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
return {
|
|
643
|
+
decision: {
|
|
644
|
+
mergedNode,
|
|
645
|
+
pageId: mergedPageId,
|
|
646
|
+
parentId: mergedParentId,
|
|
647
|
+
index: o.index,
|
|
648
|
+
},
|
|
649
|
+
conflict: null,
|
|
650
|
+
};
|
|
651
|
+
}
|
package/src/node-helpers.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PenNode } from '@zseven-w/pen-types'
|
|
1
|
+
import type { PenNode } from '@zseven-w/pen-types';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Check if a node is a badge/overlay that uses absolute positioning
|
|
@@ -6,9 +6,22 @@ import type { PenNode } from '@zseven-w/pen-types'
|
|
|
6
6
|
*/
|
|
7
7
|
export function isBadgeOverlayNode(node: PenNode): boolean {
|
|
8
8
|
if ('role' in node) {
|
|
9
|
-
const role = (node as { role?: string }).role
|
|
10
|
-
if (role === 'badge' || role === 'pill' || role === 'tag') return true
|
|
9
|
+
const role = (node as { role?: string }).role;
|
|
10
|
+
if (role === 'badge' || role === 'pill' || role === 'tag') return true;
|
|
11
11
|
}
|
|
12
|
-
const name = (node.name ?? '').toLowerCase()
|
|
13
|
-
return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name)
|
|
12
|
+
const name = (node.name ?? '').toLowerCase();
|
|
13
|
+
return /badge|indicator|notification[-_\s]?dot|overlay|floating/i.test(name);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Convert a name string to PascalCase.
|
|
18
|
+
* Strips non-alphanumeric characters and joins words.
|
|
19
|
+
*/
|
|
20
|
+
export function sanitizeName(name: string): string {
|
|
21
|
+
return name
|
|
22
|
+
.replace(/[^a-zA-Z0-9\s-_]/g, '')
|
|
23
|
+
.split(/[\s\-_]+/)
|
|
24
|
+
.filter(Boolean)
|
|
25
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
26
|
+
.join('');
|
|
14
27
|
}
|