@echovisionlab/geul-common 0.2.4 → 0.3.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/package.json +3 -2
- package/src/collaboration/block-room-codec/internal.ts +39 -20
- package/src/collaboration/block-room-codec/replay-base.ts +195 -0
- package/src/collaboration/block-room-codec/replay-locale.ts +182 -0
- package/src/collaboration/block-room-codec/replay-payload.ts +317 -0
- package/src/collaboration/block-room-codec/replay-structure.ts +504 -0
- package/src/collaboration/block-room-codec/replay.ts +75 -0
- package/src/collaboration/block-room-codec.ts +1 -0
- package/src/collaboration/form-schema-delta.ts +440 -0
- package/src/collaboration/menu.ts +109 -19
- package/src/index.ts +1 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
export type FormSchemaPatchScope = "source" | "target";
|
|
2
|
+
|
|
3
|
+
export type FormSchemaPatchErrorReason =
|
|
4
|
+
| "invalid_schema"
|
|
5
|
+
| "schema_identity_changed"
|
|
6
|
+
| "target_topology_changed"
|
|
7
|
+
| "schema_identity_collision"
|
|
8
|
+
| "schema_add_parent_missing";
|
|
9
|
+
|
|
10
|
+
export class FormSchemaPatchError extends Error {
|
|
11
|
+
constructor(readonly reason: FormSchemaPatchErrorReason) {
|
|
12
|
+
super(`form_schema_patch:${reason}`);
|
|
13
|
+
this.name = "FormSchemaPatchError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type NodeKind = "step" | "field" | "option" | "validator";
|
|
18
|
+
|
|
19
|
+
interface SchemaNode {
|
|
20
|
+
kind: NodeKind;
|
|
21
|
+
id: string;
|
|
22
|
+
parent: string;
|
|
23
|
+
order: number;
|
|
24
|
+
properties: Record<string, unknown>;
|
|
25
|
+
validationProperties?: Record<string, unknown>;
|
|
26
|
+
childrenPresent?: boolean;
|
|
27
|
+
validationPresent?: boolean;
|
|
28
|
+
validatorsPresent?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface FormSchemaIndex {
|
|
32
|
+
id: string;
|
|
33
|
+
properties: Record<string, unknown>;
|
|
34
|
+
nodes: Map<string, SchemaNode>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const ROOT_PARENT = "form-root";
|
|
38
|
+
const TARGET_LOCALIZED_PROPERTIES: Readonly<
|
|
39
|
+
Record<NodeKind, ReadonlySet<string>>
|
|
40
|
+
> = {
|
|
41
|
+
step: new Set(["title", "description"]),
|
|
42
|
+
field: new Set(["label", "description", "placeholder", "checkboxLabel"]),
|
|
43
|
+
option: new Set(["label"]),
|
|
44
|
+
validator: new Set(["message"]),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function fail(): never {
|
|
48
|
+
throw new FormSchemaPatchError("invalid_schema");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
52
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function object(value: unknown): Record<string, unknown> {
|
|
56
|
+
return isObject(value) ? value : fail();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function array(value: unknown): unknown[] {
|
|
60
|
+
return Array.isArray(value) ? value : fail();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function requiredId(value: Record<string, unknown>): string {
|
|
64
|
+
return typeof value.id === "string" && value.id.length > 0
|
|
65
|
+
? value.id
|
|
66
|
+
: fail();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function nodeKey(kind: NodeKind, id: string): string {
|
|
70
|
+
return `${kind}\u0000${id}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function cloneJson(value: unknown): unknown {
|
|
74
|
+
const serialized = JSON.stringify(value);
|
|
75
|
+
if (serialized === undefined) return fail();
|
|
76
|
+
return JSON.parse(serialized) as unknown;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function stableJson(value: unknown): string {
|
|
80
|
+
if (Array.isArray(value)) {
|
|
81
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
82
|
+
}
|
|
83
|
+
if (isObject(value)) {
|
|
84
|
+
return `{${Object.keys(value)
|
|
85
|
+
.filter((key) => value[key] !== undefined)
|
|
86
|
+
.sort()
|
|
87
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
|
88
|
+
.join(",")}}`;
|
|
89
|
+
}
|
|
90
|
+
return JSON.stringify(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function copyProperties(
|
|
94
|
+
value: Record<string, unknown>,
|
|
95
|
+
excluded: ReadonlySet<string>,
|
|
96
|
+
): Record<string, unknown> {
|
|
97
|
+
const properties: Record<string, unknown> = {};
|
|
98
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
99
|
+
if (excluded.has(key) || entry === undefined) continue;
|
|
100
|
+
properties[key] = cloneJson(entry);
|
|
101
|
+
}
|
|
102
|
+
return properties;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function addNode(index: FormSchemaIndex, node: SchemaNode): void {
|
|
106
|
+
const key = nodeKey(node.kind, node.id);
|
|
107
|
+
if ([...index.nodes.values()].some((existing) => existing.id === node.id))
|
|
108
|
+
fail();
|
|
109
|
+
index.nodes.set(key, node);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function indexFormSchema(value: unknown): FormSchemaIndex {
|
|
113
|
+
const schema = object(value);
|
|
114
|
+
const id = requiredId(schema);
|
|
115
|
+
if (!Array.isArray(schema.steps)) fail();
|
|
116
|
+
const index: FormSchemaIndex = {
|
|
117
|
+
id,
|
|
118
|
+
properties: copyProperties(schema, new Set(["steps"])),
|
|
119
|
+
nodes: new Map(),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
array(schema.steps).forEach((rawStep, stepOrder) => {
|
|
123
|
+
const step = object(rawStep);
|
|
124
|
+
const stepId = requiredId(step);
|
|
125
|
+
addNode(index, {
|
|
126
|
+
kind: "step",
|
|
127
|
+
id: stepId,
|
|
128
|
+
parent: ROOT_PARENT,
|
|
129
|
+
order: stepOrder,
|
|
130
|
+
properties: copyProperties(step, new Set(["fields"])),
|
|
131
|
+
...(Object.hasOwn(step, "fields") && step.fields !== undefined
|
|
132
|
+
? { childrenPresent: true }
|
|
133
|
+
: {}),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (step.fields !== undefined) {
|
|
137
|
+
array(step.fields).forEach((rawField, fieldOrder) => {
|
|
138
|
+
const field = object(rawField);
|
|
139
|
+
const fieldId = requiredId(field);
|
|
140
|
+
const validation =
|
|
141
|
+
field.validation === undefined ? undefined : object(field.validation);
|
|
142
|
+
const validatorsPresent =
|
|
143
|
+
validation !== undefined && Object.hasOwn(validation, "validators");
|
|
144
|
+
const validationProperties = validation
|
|
145
|
+
? copyProperties(validation, new Set(["validators"]))
|
|
146
|
+
: undefined;
|
|
147
|
+
addNode(index, {
|
|
148
|
+
kind: "field",
|
|
149
|
+
id: fieldId,
|
|
150
|
+
parent: nodeKey("step", stepId),
|
|
151
|
+
order: fieldOrder,
|
|
152
|
+
properties: copyProperties(field, new Set(["options", "validation"])),
|
|
153
|
+
...(validationProperties ? { validationProperties } : {}),
|
|
154
|
+
...(Object.hasOwn(field, "options") && field.options !== undefined
|
|
155
|
+
? { childrenPresent: true }
|
|
156
|
+
: {}),
|
|
157
|
+
...(validation !== undefined ? { validationPresent: true } : {}),
|
|
158
|
+
...(validatorsPresent ? { validatorsPresent: true } : {}),
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (field.options !== undefined) {
|
|
162
|
+
array(field.options).forEach((rawOption, optionOrder) => {
|
|
163
|
+
const option = object(rawOption);
|
|
164
|
+
const optionId = requiredId(option);
|
|
165
|
+
addNode(index, {
|
|
166
|
+
kind: "option",
|
|
167
|
+
id: optionId,
|
|
168
|
+
parent: nodeKey("field", fieldId),
|
|
169
|
+
order: optionOrder,
|
|
170
|
+
properties: copyProperties(option, new Set()),
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (validatorsPresent) {
|
|
176
|
+
array(validation!.validators).forEach(
|
|
177
|
+
(rawValidator, validatorOrder) => {
|
|
178
|
+
const validator = object(rawValidator);
|
|
179
|
+
const validatorId = requiredId(validator);
|
|
180
|
+
addNode(index, {
|
|
181
|
+
kind: "validator",
|
|
182
|
+
id: validatorId,
|
|
183
|
+
parent: nodeKey("field", fieldId),
|
|
184
|
+
order: validatorOrder,
|
|
185
|
+
properties: copyProperties(validator, new Set()),
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return index;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function targetTopology(index: FormSchemaIndex): unknown {
|
|
197
|
+
const nodes = [...index.nodes.values()].map((node) => {
|
|
198
|
+
const properties = { ...node.properties };
|
|
199
|
+
for (const key of TARGET_LOCALIZED_PROPERTIES[node.kind])
|
|
200
|
+
delete properties[key];
|
|
201
|
+
return {
|
|
202
|
+
kind: node.kind,
|
|
203
|
+
id: node.id,
|
|
204
|
+
parent: node.parent,
|
|
205
|
+
order: node.order,
|
|
206
|
+
properties,
|
|
207
|
+
validationProperties: node.validationProperties,
|
|
208
|
+
childrenPresent: node.childrenPresent,
|
|
209
|
+
validationPresent: node.validationPresent,
|
|
210
|
+
validatorsPresent: node.validatorsPresent,
|
|
211
|
+
};
|
|
212
|
+
});
|
|
213
|
+
return { id: index.id, properties: index.properties, nodes };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function sameNode(left: SchemaNode, right: SchemaNode): boolean {
|
|
217
|
+
return (
|
|
218
|
+
left.parent === right.parent &&
|
|
219
|
+
left.order === right.order &&
|
|
220
|
+
stableJson(left.properties) === stableJson(right.properties) &&
|
|
221
|
+
stableJson(left.validationProperties) ===
|
|
222
|
+
stableJson(right.validationProperties) &&
|
|
223
|
+
left.childrenPresent === right.childrenPresent &&
|
|
224
|
+
left.validationPresent === right.validationPresent &&
|
|
225
|
+
left.validatorsPresent === right.validatorsPresent
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function orderedChildren(
|
|
230
|
+
nodes: Map<string, SchemaNode>,
|
|
231
|
+
parent: string,
|
|
232
|
+
kind: NodeKind,
|
|
233
|
+
): SchemaNode[] {
|
|
234
|
+
return [...nodes.values()]
|
|
235
|
+
.filter((node) => node.parent === parent && node.kind === kind)
|
|
236
|
+
.sort(
|
|
237
|
+
(left, right) =>
|
|
238
|
+
left.order - right.order || left.id.localeCompare(right.id),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function materialize(index: FormSchemaIndex): Record<string, unknown> {
|
|
243
|
+
const buildField = (node: SchemaNode): Record<string, unknown> => {
|
|
244
|
+
const field: Record<string, unknown> = { ...node.properties, id: node.id };
|
|
245
|
+
if (node.childrenPresent) {
|
|
246
|
+
field.options = orderedChildren(
|
|
247
|
+
index.nodes,
|
|
248
|
+
nodeKey("field", node.id),
|
|
249
|
+
"option",
|
|
250
|
+
).map((option) => ({ ...option.properties, id: option.id }));
|
|
251
|
+
}
|
|
252
|
+
if (node.validationPresent) {
|
|
253
|
+
const validationProperties = { ...node.validationProperties };
|
|
254
|
+
if (node.validatorsPresent) {
|
|
255
|
+
validationProperties.validators = orderedChildren(
|
|
256
|
+
index.nodes,
|
|
257
|
+
nodeKey("field", node.id),
|
|
258
|
+
"validator",
|
|
259
|
+
).map((validator) => ({ ...validator.properties, id: validator.id }));
|
|
260
|
+
}
|
|
261
|
+
field.validation = validationProperties;
|
|
262
|
+
}
|
|
263
|
+
return field;
|
|
264
|
+
};
|
|
265
|
+
const buildStep = (node: SchemaNode): Record<string, unknown> => {
|
|
266
|
+
const step: Record<string, unknown> = { ...node.properties, id: node.id };
|
|
267
|
+
if (node.childrenPresent) {
|
|
268
|
+
step.fields = orderedChildren(
|
|
269
|
+
index.nodes,
|
|
270
|
+
nodeKey("step", node.id),
|
|
271
|
+
"field",
|
|
272
|
+
).map(buildField);
|
|
273
|
+
}
|
|
274
|
+
return step;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// The index is parent-first and only receives validated additions and moves.
|
|
278
|
+
const result: Record<string, unknown> = { ...index.properties, id: index.id };
|
|
279
|
+
result.steps = orderedChildren(index.nodes, ROOT_PARENT, "step").map(
|
|
280
|
+
buildStep,
|
|
281
|
+
);
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Applies only the stable-ID property and structural changes between previous
|
|
287
|
+
* and next to the latest server schema. Missing live IDs are never restored
|
|
288
|
+
* by unchanged stale snapshot data. An explicit source parent deletion
|
|
289
|
+
* cascades over its current descendants; target edits are restricted to
|
|
290
|
+
* catalogued localized properties and cannot change topology.
|
|
291
|
+
*/
|
|
292
|
+
export function applyFormSchemaPatch(
|
|
293
|
+
currentSchema: unknown,
|
|
294
|
+
previousSchema: unknown,
|
|
295
|
+
nextSchema: unknown,
|
|
296
|
+
scope: FormSchemaPatchScope,
|
|
297
|
+
): unknown {
|
|
298
|
+
const current = indexFormSchema(currentSchema);
|
|
299
|
+
const previous = indexFormSchema(previousSchema);
|
|
300
|
+
const next = indexFormSchema(nextSchema);
|
|
301
|
+
if (current.id !== previous.id || previous.id !== next.id) {
|
|
302
|
+
throw new FormSchemaPatchError("schema_identity_changed");
|
|
303
|
+
}
|
|
304
|
+
if (
|
|
305
|
+
scope === "target" &&
|
|
306
|
+
stableJson(targetTopology(previous)) !== stableJson(targetTopology(next))
|
|
307
|
+
) {
|
|
308
|
+
throw new FormSchemaPatchError("target_topology_changed");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const result: FormSchemaIndex = {
|
|
312
|
+
id: current.id,
|
|
313
|
+
properties: { ...current.properties },
|
|
314
|
+
nodes: new Map(
|
|
315
|
+
[...current.nodes].map(([key, node]) => [key, structuredClone(node)]),
|
|
316
|
+
),
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
for (const key of new Set([
|
|
320
|
+
...Object.keys(previous.properties),
|
|
321
|
+
...Object.keys(next.properties),
|
|
322
|
+
])) {
|
|
323
|
+
if (key === "id") continue;
|
|
324
|
+
const beforeHas = Object.hasOwn(previous.properties, key);
|
|
325
|
+
const afterHas = Object.hasOwn(next.properties, key);
|
|
326
|
+
if (
|
|
327
|
+
beforeHas === afterHas &&
|
|
328
|
+
stableJson(previous.properties[key]) === stableJson(next.properties[key])
|
|
329
|
+
) {
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (afterHas)
|
|
333
|
+
result.properties[key] = structuredClone(next.properties[key]);
|
|
334
|
+
else delete result.properties[key];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const added = new Set<string>();
|
|
338
|
+
for (const [key, node] of next.nodes) {
|
|
339
|
+
if (previous.nodes.has(key)) continue;
|
|
340
|
+
if (result.nodes.has(key)) {
|
|
341
|
+
const live = result.nodes.get(key)!;
|
|
342
|
+
if (!sameNode(live, node)) {
|
|
343
|
+
throw new FormSchemaPatchError("schema_identity_collision");
|
|
344
|
+
}
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (node.parent !== ROOT_PARENT && !result.nodes.has(node.parent)) {
|
|
348
|
+
throw new FormSchemaPatchError("schema_add_parent_missing");
|
|
349
|
+
}
|
|
350
|
+
result.nodes.set(key, structuredClone(node));
|
|
351
|
+
added.add(key);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
for (const [key, after] of next.nodes) {
|
|
355
|
+
const before = previous.nodes.get(key);
|
|
356
|
+
if (!before || added.has(key)) continue;
|
|
357
|
+
const live = result.nodes.get(key);
|
|
358
|
+
if (!live) continue;
|
|
359
|
+
|
|
360
|
+
for (const property of new Set([
|
|
361
|
+
...Object.keys(before.properties),
|
|
362
|
+
...Object.keys(after.properties),
|
|
363
|
+
])) {
|
|
364
|
+
const beforeHas = Object.hasOwn(before.properties, property);
|
|
365
|
+
const afterHas = Object.hasOwn(after.properties, property);
|
|
366
|
+
if (
|
|
367
|
+
beforeHas === afterHas &&
|
|
368
|
+
stableJson(before.properties[property]) ===
|
|
369
|
+
stableJson(after.properties[property])
|
|
370
|
+
) {
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (afterHas)
|
|
374
|
+
live.properties[property] = structuredClone(after.properties[property]);
|
|
375
|
+
else delete live.properties[property];
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const beforeValidation = before.validationProperties ?? {};
|
|
379
|
+
const afterValidation = after.validationProperties ?? {};
|
|
380
|
+
for (const property of new Set([
|
|
381
|
+
...Object.keys(beforeValidation),
|
|
382
|
+
...Object.keys(afterValidation),
|
|
383
|
+
])) {
|
|
384
|
+
const beforeHas = Object.hasOwn(beforeValidation, property);
|
|
385
|
+
const afterHas = Object.hasOwn(afterValidation, property);
|
|
386
|
+
if (
|
|
387
|
+
beforeHas === afterHas &&
|
|
388
|
+
stableJson(beforeValidation[property]) ===
|
|
389
|
+
stableJson(afterValidation[property])
|
|
390
|
+
) {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
const updated = { ...(live.validationProperties ?? {}) };
|
|
394
|
+
if (afterHas)
|
|
395
|
+
updated[property] = structuredClone(afterValidation[property]);
|
|
396
|
+
else delete updated[property];
|
|
397
|
+
live.validationProperties = updated;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (
|
|
401
|
+
before.parent !== after.parent &&
|
|
402
|
+
(after.parent === ROOT_PARENT || result.nodes.has(after.parent))
|
|
403
|
+
) {
|
|
404
|
+
live.parent = after.parent;
|
|
405
|
+
live.order = after.order;
|
|
406
|
+
} else if (
|
|
407
|
+
before.parent === after.parent &&
|
|
408
|
+
before.order !== after.order &&
|
|
409
|
+
live.parent === before.parent
|
|
410
|
+
) {
|
|
411
|
+
live.order = after.order;
|
|
412
|
+
}
|
|
413
|
+
if (before.childrenPresent !== after.childrenPresent) {
|
|
414
|
+
live.childrenPresent = after.childrenPresent;
|
|
415
|
+
}
|
|
416
|
+
if (before.validationPresent !== after.validationPresent) {
|
|
417
|
+
live.validationPresent = after.validationPresent;
|
|
418
|
+
}
|
|
419
|
+
if (before.validatorsPresent !== after.validatorsPresent) {
|
|
420
|
+
live.validatorsPresent = after.validatorsPresent;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const deleted = new Set(
|
|
425
|
+
[...previous.nodes.keys()].filter((key) => !next.nodes.has(key)),
|
|
426
|
+
);
|
|
427
|
+
let addedDescendant = true;
|
|
428
|
+
while (addedDescendant) {
|
|
429
|
+
addedDescendant = false;
|
|
430
|
+
for (const [key, node] of result.nodes) {
|
|
431
|
+
if (deleted.has(node.parent) && !deleted.has(key)) {
|
|
432
|
+
deleted.add(key);
|
|
433
|
+
addedDescendant = true;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
for (const key of deleted) result.nodes.delete(key);
|
|
438
|
+
|
|
439
|
+
return materialize(result);
|
|
440
|
+
}
|
|
@@ -42,6 +42,11 @@ export interface MenuCanonicalSnapshot {
|
|
|
42
42
|
requestedLabels: Record<string, string>;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export interface MenuCanonicalSourceSnapshot {
|
|
46
|
+
name: string;
|
|
47
|
+
items: readonly MenuCollaborationItem[];
|
|
48
|
+
}
|
|
49
|
+
|
|
45
50
|
export function hydrateMenuCanonicalRoom(input: MenuCanonicalRoomInput): Y.Doc {
|
|
46
51
|
const document = new Y.Doc();
|
|
47
52
|
document.transact(() => {
|
|
@@ -50,7 +55,7 @@ export function hydrateMenuCanonicalRoom(input: MenuCanonicalRoomInput): Y.Doc {
|
|
|
50
55
|
context.set("locale", input.locale);
|
|
51
56
|
context.set("localeExists", input.localeExists);
|
|
52
57
|
document.getMap<string>(MENU_ROOT_MAP_NAME).set("name", input.name);
|
|
53
|
-
replaceMenuStructure(document, input.items);
|
|
58
|
+
replaceMenuStructure(document, [], input.items);
|
|
54
59
|
const sourceLabels = document.getMap<string>(MENU_SOURCE_LABELS_MAP_NAME);
|
|
55
60
|
const requestedLabels = menuLocaleLabelsMap(document);
|
|
56
61
|
for (const [id, label] of Object.entries(input.sourceLabels))
|
|
@@ -125,27 +130,64 @@ export function replaceMenuCanonicalSource(
|
|
|
125
130
|
document: Y.Doc,
|
|
126
131
|
name: string,
|
|
127
132
|
items: readonly MenuCollaborationItem[],
|
|
133
|
+
previous: MenuCanonicalSourceSnapshot,
|
|
128
134
|
): void {
|
|
129
|
-
|
|
130
|
-
|
|
135
|
+
assertMenuCanonicalSourceSnapshot(previous);
|
|
136
|
+
const root = document.getMap<string>(MENU_ROOT_MAP_NAME);
|
|
137
|
+
if (previous.name !== name) root.set("name", name);
|
|
138
|
+
const deletedItemIds = replaceMenuStructure(document, previous.items, items);
|
|
131
139
|
const labels = menuLocaleLabelsMap(document);
|
|
140
|
+
const sourceLabels = document.getMap<string>(MENU_SOURCE_LABELS_MAP_NAME);
|
|
132
141
|
const locale = document
|
|
133
142
|
.getMap<string | boolean>(MENU_CONTEXT_MAP_NAME)
|
|
134
143
|
.get("locale");
|
|
135
144
|
if (typeof locale !== "string") {
|
|
136
145
|
throw new Error("Menu collaboration locale is required");
|
|
137
146
|
}
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
const previousById = new Map(
|
|
148
|
+
flattenMenuItems(previous.items).map((entry) => [
|
|
149
|
+
entry.item.id,
|
|
150
|
+
entry.item,
|
|
151
|
+
]),
|
|
152
|
+
);
|
|
153
|
+
const nextById = new Map(
|
|
154
|
+
flattenMenuItems(items).map((entry) => [entry.item.id, entry.item]),
|
|
155
|
+
);
|
|
156
|
+
for (const id of deletedItemIds) {
|
|
157
|
+
labels.delete(id);
|
|
158
|
+
sourceLabels.delete(id);
|
|
159
|
+
}
|
|
160
|
+
for (const [id, item] of nextById) {
|
|
161
|
+
const before = previousById.get(id);
|
|
162
|
+
const previouslyOwned = before
|
|
163
|
+
? menuItemOwnsLocaleLabel(before, locale)
|
|
164
|
+
: false;
|
|
165
|
+
const currentlyOwned = menuItemOwnsLocaleLabel(item, locale);
|
|
166
|
+
if (!currentlyOwned) {
|
|
167
|
+
if (previouslyOwned) labels.delete(id);
|
|
168
|
+
} else if (!before || !previouslyOwned || before.label !== item.label) {
|
|
169
|
+
if (item.label !== undefined) labels.set(id, item.label);
|
|
145
170
|
}
|
|
146
171
|
}
|
|
147
172
|
}
|
|
148
173
|
|
|
174
|
+
function assertMenuCanonicalSourceSnapshot(
|
|
175
|
+
value: unknown,
|
|
176
|
+
): asserts value is MenuCanonicalSourceSnapshot {
|
|
177
|
+
if (
|
|
178
|
+
!value ||
|
|
179
|
+
typeof value !== "object" ||
|
|
180
|
+
!("name" in value) ||
|
|
181
|
+
typeof value.name !== "string" ||
|
|
182
|
+
!("items" in value) ||
|
|
183
|
+
!Array.isArray(value.items)
|
|
184
|
+
) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
"Menu source replacement requires an observed previous snapshot",
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
149
191
|
export function setMenuLocaleLabel(
|
|
150
192
|
document: Y.Doc,
|
|
151
193
|
itemId: string,
|
|
@@ -180,29 +222,77 @@ export function extractMenuCanonicalSnapshot(
|
|
|
180
222
|
|
|
181
223
|
function replaceMenuStructure(
|
|
182
224
|
document: Y.Doc,
|
|
225
|
+
previousItems: readonly MenuCollaborationItem[],
|
|
183
226
|
nextItems: readonly MenuCollaborationItem[],
|
|
184
|
-
):
|
|
227
|
+
): Set<string> {
|
|
228
|
+
const previous = flattenMenuItems(previousItems);
|
|
185
229
|
const flattened = flattenMenuItems(nextItems);
|
|
230
|
+
const previousById = new Map(previous.map((entry) => [entry.item.id, entry]));
|
|
186
231
|
const nextIDs = new Set(flattened.map(({ item }) => item.id));
|
|
187
232
|
if (nextIDs.size !== flattened.length || nextIDs.has(MENU_ROOT_PARENT)) {
|
|
188
233
|
throw new Error("Invalid Menu collaboration item identity");
|
|
189
234
|
}
|
|
235
|
+
const previousIDs = new Set(previous.map(({ item }) => item.id));
|
|
236
|
+
if (
|
|
237
|
+
previousIDs.size !== previous.length ||
|
|
238
|
+
previousIDs.has(MENU_ROOT_PARENT)
|
|
239
|
+
) {
|
|
240
|
+
throw new Error("Invalid Menu collaboration item identity");
|
|
241
|
+
}
|
|
190
242
|
const items = document.getMap<string>(MENU_ITEMS_MAP_NAME);
|
|
191
243
|
const parents = document.getMap<string>(MENU_PARENTS_MAP_NAME);
|
|
192
244
|
const orders = document.getMap<number>(MENU_ORDERS_MAP_NAME);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
245
|
+
const childrenByParent = new Map<string, string[]>();
|
|
246
|
+
for (const id of items.keys()) {
|
|
247
|
+
const parent = parents.get(id) ?? MENU_ROOT_PARENT;
|
|
248
|
+
const children = childrenByParent.get(parent) ?? [];
|
|
249
|
+
children.push(id);
|
|
250
|
+
childrenByParent.set(parent, children);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const deletedIDs = new Set([...previousIDs].filter((id) => !nextIDs.has(id)));
|
|
254
|
+
const pendingDeletedIDs = [...deletedIDs];
|
|
255
|
+
for (let index = 0; index < pendingDeletedIDs.length; index += 1) {
|
|
256
|
+
const deletedParent = pendingDeletedIDs[index]!;
|
|
257
|
+
for (const childId of childrenByParent.get(deletedParent) ?? []) {
|
|
258
|
+
if (nextIDs.has(childId) || deletedIDs.has(childId)) continue;
|
|
259
|
+
deletedIDs.add(childId);
|
|
260
|
+
pendingDeletedIDs.push(childId);
|
|
198
261
|
}
|
|
199
262
|
}
|
|
263
|
+
for (const id of deletedIDs) {
|
|
264
|
+
items.delete(id);
|
|
265
|
+
parents.delete(id);
|
|
266
|
+
orders.delete(id);
|
|
267
|
+
}
|
|
200
268
|
for (const { item, parent, order } of flattened) {
|
|
269
|
+
const before = previousById.get(item.id);
|
|
201
270
|
const stored = menuItemStructure(item);
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
271
|
+
if (
|
|
272
|
+
!before ||
|
|
273
|
+
stableMenuJson(menuItemStructure(before.item)) !== stableMenuJson(stored)
|
|
274
|
+
) {
|
|
275
|
+
items.set(item.id, JSON.stringify(stored));
|
|
276
|
+
}
|
|
277
|
+
if (!before || before.parent !== parent) parents.set(item.id, parent);
|
|
278
|
+
if (!before || before.order !== order) orders.set(item.id, order);
|
|
279
|
+
}
|
|
280
|
+
return deletedIDs;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function stableMenuJson(value: unknown): string {
|
|
284
|
+
if (Array.isArray(value)) {
|
|
285
|
+
return `[${value.map(stableMenuJson).join(",")}]`;
|
|
286
|
+
}
|
|
287
|
+
if (typeof value === "object" && value !== null) {
|
|
288
|
+
const record = value as Record<string, unknown>;
|
|
289
|
+
return `{${Object.keys(record)
|
|
290
|
+
.filter((key) => record[key] !== undefined)
|
|
291
|
+
.sort()
|
|
292
|
+
.map((key) => `${JSON.stringify(key)}:${stableMenuJson(record[key])}`)
|
|
293
|
+
.join(",")}}`;
|
|
205
294
|
}
|
|
295
|
+
return JSON.stringify(value);
|
|
206
296
|
}
|
|
207
297
|
|
|
208
298
|
function flattenMenuItems(items: readonly MenuCollaborationItem[]): Array<{
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ export * from "./collaboration/block-room-codec.ts";
|
|
|
4
4
|
export * from "./collaboration/document-layout.ts";
|
|
5
5
|
export * from "./collaboration/document.ts";
|
|
6
6
|
export * from "./collaboration/form.ts";
|
|
7
|
+
export * from "./collaboration/form-schema-delta.ts";
|
|
7
8
|
export * from "./collaboration/label.ts";
|
|
8
9
|
export * from "./collaboration/map-theme.ts";
|
|
9
10
|
export * from "./collaboration/metadata-ai.ts";
|