@echovisionlab/geul-common 0.1.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/LICENSE.md +6 -0
- package/README.md +37 -0
- package/package.json +89 -0
- package/src/collaboration/artist.ts +63 -0
- package/src/collaboration/block-room-codec/ai-document-applicator.ts +319 -0
- package/src/collaboration/block-room-codec/ai-document-field-mutations.ts +557 -0
- package/src/collaboration/block-room-codec/ai-document-page-structure-mutations.ts +449 -0
- package/src/collaboration/block-room-codec/ai-document-values.ts +368 -0
- package/src/collaboration/block-room-codec/hydration.ts +257 -0
- package/src/collaboration/block-room-codec/internal.ts +432 -0
- package/src/collaboration/block-room-codec/locale-change-validation.ts +237 -0
- package/src/collaboration/block-room-codec/locale-presence.ts +827 -0
- package/src/collaboration/block-room-codec/materialization.ts +450 -0
- package/src/collaboration/block-room-codec/observation.ts +518 -0
- package/src/collaboration/block-room-codec/payload-mutations.ts +347 -0
- package/src/collaboration/block-room-codec/room-access.ts +154 -0
- package/src/collaboration/block-room-codec/structure-mutations.ts +456 -0
- package/src/collaboration/block-room-codec.ts +86 -0
- package/src/collaboration/campaign.ts +37 -0
- package/src/collaboration/document-layout.ts +75 -0
- package/src/collaboration/document.ts +185 -0
- package/src/collaboration/email-layout.ts +150 -0
- package/src/collaboration/form.ts +513 -0
- package/src/collaboration/label.ts +49 -0
- package/src/collaboration/map-theme.ts +157 -0
- package/src/collaboration/member-id.ts +9 -0
- package/src/collaboration/menu.ts +258 -0
- package/src/collaboration/metadata-ai.ts +99 -0
- package/src/collaboration/page.ts +483 -0
- package/src/collaboration/post-series.ts +96 -0
- package/src/collaboration/post.ts +59 -0
- package/src/collaboration/release.ts +221 -0
- package/src/collaboration/runtime-events.ts +547 -0
- package/src/collaboration/work.ts +107 -0
- package/src/editor/link-normalization.ts +212 -0
- package/src/editor/materialized-blocks.ts +58 -0
- package/src/index.ts +22 -0
- package/src/media/block-schemas.ts +78 -0
- package/src/media/hydration.ts +226 -0
- package/src/page/block-fixtures.ts +586 -0
- package/src/page/index.ts +3 -0
- package/src/page/types.ts +57 -0
- package/src/post/index.ts +1 -0
- package/src/post/types.ts +13 -0
- package/src/test/random-id.ts +9 -0
- package/src/translation/release.ts +88 -0
- package/src/types.ts +14 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
import { create, type JsonValue } from "@bufbuild/protobuf";
|
|
2
|
+
import {
|
|
3
|
+
pageImmersiveUnitCatalog,
|
|
4
|
+
pageSectionCatalog,
|
|
5
|
+
pageSectionKindByProtoCase,
|
|
6
|
+
richTextBlockCatalog,
|
|
7
|
+
richTextBlockKindByProtoCase,
|
|
8
|
+
} from "@echovisionlab/geul-proto/content/block_catalog.ts";
|
|
9
|
+
import {
|
|
10
|
+
AIDocumentFieldPathSegmentSchema,
|
|
11
|
+
AIDocumentFieldTargetSchema,
|
|
12
|
+
type AIDocumentFieldPathSegment,
|
|
13
|
+
type AIDocumentFieldTarget,
|
|
14
|
+
} from "@echovisionlab/geul-proto/secure/ai_pb.ts";
|
|
15
|
+
import * as Y from "yjs";
|
|
16
|
+
import {
|
|
17
|
+
BLOCK_ROOM_LOCALE_PRESENCE,
|
|
18
|
+
fail,
|
|
19
|
+
fromYValue,
|
|
20
|
+
type JsonObject,
|
|
21
|
+
} from "./internal.ts";
|
|
22
|
+
import {
|
|
23
|
+
blockRoomBaseNodes,
|
|
24
|
+
blockRoomLocaleOverlay,
|
|
25
|
+
} from "./materialization.ts";
|
|
26
|
+
import {
|
|
27
|
+
payloadValue,
|
|
28
|
+
roomDocumentType,
|
|
29
|
+
roomNode,
|
|
30
|
+
type BlockRoomPayloadRef,
|
|
31
|
+
} from "./room-access.ts";
|
|
32
|
+
import {
|
|
33
|
+
resolvedPath,
|
|
34
|
+
yPath,
|
|
35
|
+
type CatalogField,
|
|
36
|
+
type JsonPathPart,
|
|
37
|
+
} from "./ai-document-values.ts";
|
|
38
|
+
|
|
39
|
+
const CANONICAL_UUID_PATTERN =
|
|
40
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
41
|
+
|
|
42
|
+
type CatalogEntry = {
|
|
43
|
+
readonly content?: string;
|
|
44
|
+
readonly fields: Readonly<Record<string, CatalogField>>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type CanonicalPathSegment = readonly ["field" | "item", string];
|
|
48
|
+
|
|
49
|
+
interface CanonicalLocaleValueTarget {
|
|
50
|
+
target: AIDocumentFieldTarget;
|
|
51
|
+
key: string;
|
|
52
|
+
blockId: string;
|
|
53
|
+
field: string;
|
|
54
|
+
path: readonly CanonicalPathSegment[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function presenceFail(reason: string): never {
|
|
58
|
+
return fail(`locale_presence:${reason}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function requiredHandle(value: string, reason: string): string {
|
|
62
|
+
return value ? value : presenceFail(reason);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function canonicalPath(
|
|
66
|
+
path: readonly AIDocumentFieldPathSegment[],
|
|
67
|
+
): CanonicalPathSegment[] {
|
|
68
|
+
return path.map(({ selector }) => {
|
|
69
|
+
if (selector.case === "fieldHandle")
|
|
70
|
+
return ["field", requiredHandle(selector.value, "path:field")] as const;
|
|
71
|
+
if (selector.case === "itemHandle")
|
|
72
|
+
return ["item", requiredHandle(selector.value, "path:item")] as const;
|
|
73
|
+
return presenceFail("path:selector");
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function targetFromParts(
|
|
78
|
+
blockId: string,
|
|
79
|
+
field: string,
|
|
80
|
+
path: readonly CanonicalPathSegment[],
|
|
81
|
+
): AIDocumentFieldTarget {
|
|
82
|
+
return create(AIDocumentFieldTargetSchema, {
|
|
83
|
+
owner: { case: "blockHandle", value: blockId },
|
|
84
|
+
fieldHandle: field,
|
|
85
|
+
path: path.map(([kind, value]) =>
|
|
86
|
+
create(AIDocumentFieldPathSegmentSchema, {
|
|
87
|
+
selector:
|
|
88
|
+
kind === "field"
|
|
89
|
+
? { case: "fieldHandle", value }
|
|
90
|
+
: { case: "itemHandle", value },
|
|
91
|
+
}),
|
|
92
|
+
),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function canonicalTarget(
|
|
97
|
+
value: AIDocumentFieldTarget | undefined,
|
|
98
|
+
): CanonicalLocaleValueTarget {
|
|
99
|
+
if (!value) return presenceFail("target:missing");
|
|
100
|
+
if (value.owner.case !== "blockHandle")
|
|
101
|
+
return presenceFail("target:relation_item");
|
|
102
|
+
const blockId = requiredHandle(value.owner.value, "target:block");
|
|
103
|
+
if (!CANONICAL_UUID_PATTERN.test(blockId))
|
|
104
|
+
return presenceFail("target:block_uuid");
|
|
105
|
+
const field = requiredHandle(value.fieldHandle, "target:field");
|
|
106
|
+
const path = canonicalPath(value.path);
|
|
107
|
+
const target = targetFromParts(blockId, field, path);
|
|
108
|
+
return {
|
|
109
|
+
target,
|
|
110
|
+
key: JSON.stringify([blockId, field, path]),
|
|
111
|
+
blockId,
|
|
112
|
+
field,
|
|
113
|
+
path,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function pathField(
|
|
118
|
+
path: readonly CanonicalPathSegment[],
|
|
119
|
+
index: number,
|
|
120
|
+
expected: string,
|
|
121
|
+
): boolean {
|
|
122
|
+
return path[index]?.[0] === "field" && path[index]?.[1] === expected;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function localeScalar(descriptor: CatalogField | undefined): CatalogField {
|
|
126
|
+
if (!descriptor) return presenceFail("catalog:field");
|
|
127
|
+
if (descriptor.type === "object" || descriptor.type === "array")
|
|
128
|
+
return presenceFail("catalog:composite_terminal");
|
|
129
|
+
if (descriptor.type === "file_attachment")
|
|
130
|
+
return presenceFail("catalog:file_terminal");
|
|
131
|
+
if (descriptor.ownership !== "locale")
|
|
132
|
+
return presenceFail("catalog:ownership");
|
|
133
|
+
return descriptor;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function jsonRecord(value: unknown, reason: string): JsonObject {
|
|
137
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
138
|
+
return presenceFail(reason);
|
|
139
|
+
return value as JsonObject;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function jsonArray(value: unknown, reason: string): unknown[] {
|
|
143
|
+
return Array.isArray(value) ? value : presenceFail(reason);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function assertTableLeaf(
|
|
147
|
+
basePayload: unknown,
|
|
148
|
+
path: readonly CanonicalPathSegment[],
|
|
149
|
+
): void {
|
|
150
|
+
if (
|
|
151
|
+
path.length !== 5 ||
|
|
152
|
+
!pathField(path, 0, "rows") ||
|
|
153
|
+
path[1]?.[0] !== "item" ||
|
|
154
|
+
!pathField(path, 2, "cells") ||
|
|
155
|
+
path[3]?.[0] !== "item" ||
|
|
156
|
+
!pathField(path, 4, "content")
|
|
157
|
+
)
|
|
158
|
+
return presenceFail("table:path");
|
|
159
|
+
const rows = jsonArray(
|
|
160
|
+
jsonRecord(
|
|
161
|
+
jsonRecord(basePayload, "table:payload").content,
|
|
162
|
+
"table:content",
|
|
163
|
+
).rows,
|
|
164
|
+
"table:rows",
|
|
165
|
+
);
|
|
166
|
+
const row = rows.find(
|
|
167
|
+
(candidate) => jsonRecord(candidate, "table:row").id === path[1]![1],
|
|
168
|
+
);
|
|
169
|
+
if (!row) return presenceFail("table:row_handle");
|
|
170
|
+
const cells = jsonArray(jsonRecord(row, "table:row").cells, "table:cells");
|
|
171
|
+
if (
|
|
172
|
+
!cells.some(
|
|
173
|
+
(candidate) => jsonRecord(candidate, "table:cell").id === path[3]![1],
|
|
174
|
+
)
|
|
175
|
+
)
|
|
176
|
+
return presenceFail("table:cell_handle");
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function resolvedTablePath(
|
|
180
|
+
basePayload: unknown,
|
|
181
|
+
path: readonly CanonicalPathSegment[],
|
|
182
|
+
): JsonPathPart[] {
|
|
183
|
+
assertTableLeaf(basePayload, path);
|
|
184
|
+
const rows = jsonArray(
|
|
185
|
+
jsonRecord(
|
|
186
|
+
jsonRecord(basePayload, "table:payload").content,
|
|
187
|
+
"table:content",
|
|
188
|
+
).rows,
|
|
189
|
+
"table:rows",
|
|
190
|
+
);
|
|
191
|
+
const rowIndex = rows.findIndex(
|
|
192
|
+
(candidate) => jsonRecord(candidate, "table:row").id === path[1]![1],
|
|
193
|
+
);
|
|
194
|
+
const row = rows[rowIndex]!;
|
|
195
|
+
const cells = jsonArray(jsonRecord(row, "table:row").cells, "table:cells");
|
|
196
|
+
const cellIndex = cells.findIndex(
|
|
197
|
+
(candidate) => jsonRecord(candidate, "table:cell").id === path[3]![1],
|
|
198
|
+
);
|
|
199
|
+
return ["content", "rows", rowIndex, "cells", cellIndex, "content"];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function assertRichTextLeaf(
|
|
203
|
+
kind: string,
|
|
204
|
+
basePayload: unknown,
|
|
205
|
+
field: string,
|
|
206
|
+
path: readonly CanonicalPathSegment[],
|
|
207
|
+
): void {
|
|
208
|
+
const catalogKind =
|
|
209
|
+
richTextBlockKindByProtoCase[
|
|
210
|
+
kind as keyof typeof richTextBlockKindByProtoCase
|
|
211
|
+
];
|
|
212
|
+
const catalog = (
|
|
213
|
+
richTextBlockCatalog as Readonly<Record<string, CatalogEntry>>
|
|
214
|
+
)[catalogKind!];
|
|
215
|
+
if (!catalog) return presenceFail("catalog:block_kind");
|
|
216
|
+
if (field === "content") {
|
|
217
|
+
if (
|
|
218
|
+
path.length !== 0 ||
|
|
219
|
+
(catalog.content !== "inline" && catalog.content !== "locale_text")
|
|
220
|
+
)
|
|
221
|
+
return presenceFail("catalog:content");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (field === "tableContent") {
|
|
225
|
+
if (catalog.content !== "table") return presenceFail("catalog:table");
|
|
226
|
+
assertTableLeaf(basePayload, path);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (field === "table") return presenceFail("catalog:shared_table");
|
|
230
|
+
if (path.length !== 0) return presenceFail("catalog:path");
|
|
231
|
+
localeScalar(catalog.fields[field]);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function assertPageLeaf(
|
|
235
|
+
kind: string,
|
|
236
|
+
basePayload: unknown,
|
|
237
|
+
field: string,
|
|
238
|
+
path: readonly CanonicalPathSegment[],
|
|
239
|
+
): void {
|
|
240
|
+
if (field !== "locale-data") return presenceFail("page:field");
|
|
241
|
+
const catalogKind =
|
|
242
|
+
pageSectionKindByProtoCase[kind as keyof typeof pageSectionKindByProtoCase];
|
|
243
|
+
const catalog = (
|
|
244
|
+
pageSectionCatalog as Readonly<Record<string, CatalogEntry>>
|
|
245
|
+
)[catalogKind!];
|
|
246
|
+
if (!catalog) return presenceFail("page:kind");
|
|
247
|
+
if (
|
|
248
|
+
path.length === 2 &&
|
|
249
|
+
pathField(path, 0, "props") &&
|
|
250
|
+
path[1]?.[0] === "field"
|
|
251
|
+
) {
|
|
252
|
+
localeScalar(catalog.fields[path[1][1]]);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (
|
|
256
|
+
catalogKind === "immersive-scene" &&
|
|
257
|
+
path.length === 4 &&
|
|
258
|
+
pathField(path, 0, "units") &&
|
|
259
|
+
path[1]?.[0] === "item" &&
|
|
260
|
+
pathField(path, 2, "props") &&
|
|
261
|
+
path[3]?.[0] === "field"
|
|
262
|
+
) {
|
|
263
|
+
const units = jsonArray(
|
|
264
|
+
jsonRecord(basePayload, "page:payload").units,
|
|
265
|
+
"page:units",
|
|
266
|
+
);
|
|
267
|
+
if (!units.some((unit) => jsonRecord(unit, "page:unit").id === path[1]![1]))
|
|
268
|
+
return presenceFail("page:unit_handle");
|
|
269
|
+
localeScalar(
|
|
270
|
+
(pageImmersiveUnitCatalog as Readonly<Record<string, CatalogField>>)[
|
|
271
|
+
path[3][1]
|
|
272
|
+
],
|
|
273
|
+
);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
return presenceFail("page:path");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function canonicalBlockRoomLocaleValueTarget(
|
|
280
|
+
yDocument: Y.Doc,
|
|
281
|
+
value: AIDocumentFieldTarget | undefined,
|
|
282
|
+
): AIDocumentFieldTarget {
|
|
283
|
+
const canonical = canonicalTarget(value);
|
|
284
|
+
const rawNode = blockRoomBaseNodes(yDocument).get(canonical.blockId);
|
|
285
|
+
if (!(rawNode instanceof Y.Map)) return presenceFail("target:block_missing");
|
|
286
|
+
const family = rawNode.get("family");
|
|
287
|
+
const kind = rawNode.get("kind");
|
|
288
|
+
if (typeof kind !== "string") return presenceFail("target:block_kind");
|
|
289
|
+
const payload = fromYValue(rawNode.get("payload"));
|
|
290
|
+
if (family === "rich_text") {
|
|
291
|
+
assertRichTextLeaf(kind, payload, canonical.field, canonical.path);
|
|
292
|
+
} else if (family === "page_section") {
|
|
293
|
+
if (roomDocumentType(yDocument) !== "page")
|
|
294
|
+
return presenceFail("target:block_family");
|
|
295
|
+
assertPageLeaf(kind, payload, canonical.field, canonical.path);
|
|
296
|
+
} else {
|
|
297
|
+
return presenceFail("target:block_family");
|
|
298
|
+
}
|
|
299
|
+
return canonical.target;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function blockRoomLocaleValueRef(
|
|
303
|
+
yDocument: Y.Doc,
|
|
304
|
+
value: AIDocumentFieldTarget | undefined,
|
|
305
|
+
): BlockRoomPayloadRef {
|
|
306
|
+
const target = canonicalTarget(
|
|
307
|
+
canonicalBlockRoomLocaleValueTarget(yDocument, value),
|
|
308
|
+
);
|
|
309
|
+
const rawNode = blockRoomBaseNodes(yDocument).get(
|
|
310
|
+
target.blockId,
|
|
311
|
+
) as Y.Map<unknown>;
|
|
312
|
+
const family = rawNode.get("family") as "rich_text" | "page_section";
|
|
313
|
+
const kind = rawNode.get("kind") as string;
|
|
314
|
+
const basePayload = fromYValue(rawNode.get("payload"));
|
|
315
|
+
const localeNode = blockRoomLocaleOverlay(yDocument).get(target.blockId);
|
|
316
|
+
if (!(localeNode instanceof Y.Map))
|
|
317
|
+
return presenceFail("target:locale_missing");
|
|
318
|
+
const localePayload = fromYValue(localeNode.get("payload"));
|
|
319
|
+
let path: JsonPathPart[];
|
|
320
|
+
if (family === "rich_text") {
|
|
321
|
+
const catalogKind =
|
|
322
|
+
richTextBlockKindByProtoCase[
|
|
323
|
+
kind as keyof typeof richTextBlockKindByProtoCase
|
|
324
|
+
];
|
|
325
|
+
const catalog = (
|
|
326
|
+
richTextBlockCatalog as Readonly<Record<string, CatalogEntry>>
|
|
327
|
+
)[catalogKind!]!;
|
|
328
|
+
if (target.field === "content") path = ["content"];
|
|
329
|
+
else if (target.field === "tableContent")
|
|
330
|
+
path = resolvedTablePath(basePayload, target.path);
|
|
331
|
+
else {
|
|
332
|
+
path = resolvedPath(
|
|
333
|
+
localePayload,
|
|
334
|
+
["props", target.field],
|
|
335
|
+
target.target.path,
|
|
336
|
+
false,
|
|
337
|
+
catalog.fields[target.field],
|
|
338
|
+
target.field,
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
} else if (pathField(target.path, 0, "props")) {
|
|
342
|
+
const field = target.path[1]![1];
|
|
343
|
+
const catalogKind =
|
|
344
|
+
pageSectionKindByProtoCase[
|
|
345
|
+
kind as keyof typeof pageSectionKindByProtoCase
|
|
346
|
+
];
|
|
347
|
+
const catalog = (
|
|
348
|
+
pageSectionCatalog as Readonly<Record<string, CatalogEntry>>
|
|
349
|
+
)[catalogKind!]!;
|
|
350
|
+
path = resolvedPath(
|
|
351
|
+
localePayload,
|
|
352
|
+
["props", field],
|
|
353
|
+
target.target.path.slice(2),
|
|
354
|
+
false,
|
|
355
|
+
catalog.fields[field],
|
|
356
|
+
field,
|
|
357
|
+
);
|
|
358
|
+
} else {
|
|
359
|
+
const units = jsonArray(
|
|
360
|
+
jsonRecord(basePayload, "page:payload").units,
|
|
361
|
+
"page:units",
|
|
362
|
+
);
|
|
363
|
+
const unitIndex = units.findIndex(
|
|
364
|
+
(unit) => jsonRecord(unit, "page:unit").id === target.path[1]![1],
|
|
365
|
+
);
|
|
366
|
+
const field = target.path[3]![1];
|
|
367
|
+
path = resolvedPath(
|
|
368
|
+
localePayload,
|
|
369
|
+
["units", unitIndex, "props", field],
|
|
370
|
+
target.target.path.slice(4),
|
|
371
|
+
false,
|
|
372
|
+
(pageImmersiveUnitCatalog as Readonly<Record<string, CatalogField>>)[
|
|
373
|
+
field
|
|
374
|
+
],
|
|
375
|
+
field,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
return { id: target.blockId, family, locale: true, path: yPath(path) };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function catalogDefault(descriptor: CatalogField | undefined): JsonValue {
|
|
382
|
+
return localeScalar(descriptor).default as string;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function localeValueDefault(
|
|
386
|
+
yDocument: Y.Doc,
|
|
387
|
+
value: AIDocumentFieldTarget,
|
|
388
|
+
): JsonValue {
|
|
389
|
+
const target = canonicalTarget(value);
|
|
390
|
+
const rawNode = blockRoomBaseNodes(yDocument).get(
|
|
391
|
+
target.blockId,
|
|
392
|
+
) as Y.Map<unknown>;
|
|
393
|
+
const family = rawNode.get("family");
|
|
394
|
+
const kind = rawNode.get("kind") as string;
|
|
395
|
+
if (family === "rich_text") {
|
|
396
|
+
const catalogKind =
|
|
397
|
+
richTextBlockKindByProtoCase[
|
|
398
|
+
kind as keyof typeof richTextBlockKindByProtoCase
|
|
399
|
+
];
|
|
400
|
+
const catalog = (
|
|
401
|
+
richTextBlockCatalog as unknown as Readonly<Record<string, CatalogEntry>>
|
|
402
|
+
)[catalogKind!]!;
|
|
403
|
+
if (target.field === "content")
|
|
404
|
+
return catalog.content === "inline" ? [] : "";
|
|
405
|
+
if (target.field === "tableContent") return [];
|
|
406
|
+
return catalogDefault(catalog.fields[target.field]);
|
|
407
|
+
}
|
|
408
|
+
const catalogKind =
|
|
409
|
+
pageSectionKindByProtoCase[kind as keyof typeof pageSectionKindByProtoCase];
|
|
410
|
+
if (target.path[0]?.[1] === "props") {
|
|
411
|
+
const field = target.path[1]![1];
|
|
412
|
+
const catalog = (
|
|
413
|
+
pageSectionCatalog as unknown as Readonly<Record<string, CatalogEntry>>
|
|
414
|
+
)[catalogKind!]!;
|
|
415
|
+
return catalogDefault(catalog.fields[field]);
|
|
416
|
+
}
|
|
417
|
+
const field = target.path[3]![1];
|
|
418
|
+
return catalogDefault(
|
|
419
|
+
(pageImmersiveUnitCatalog as Readonly<Record<string, CatalogField>>)[field],
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function blockRoomLocaleValue(
|
|
424
|
+
yDocument: Y.Doc,
|
|
425
|
+
target: AIDocumentFieldTarget,
|
|
426
|
+
): unknown {
|
|
427
|
+
const ref = blockRoomLocaleValueRef(yDocument, target);
|
|
428
|
+
try {
|
|
429
|
+
return fromYValue(
|
|
430
|
+
payloadValue(roomNode(yDocument, ref), ref.path, "locale_presence:value"),
|
|
431
|
+
);
|
|
432
|
+
} catch {
|
|
433
|
+
return localeValueDefault(yDocument, target);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export function blockRoomLocaleValueIsEncoded(
|
|
438
|
+
yDocument: Y.Doc,
|
|
439
|
+
target: AIDocumentFieldTarget,
|
|
440
|
+
): boolean {
|
|
441
|
+
try {
|
|
442
|
+
const ref = blockRoomLocaleValueRef(yDocument, target);
|
|
443
|
+
return (
|
|
444
|
+
payloadValue(
|
|
445
|
+
roomNode(yDocument, ref),
|
|
446
|
+
ref.path,
|
|
447
|
+
"locale_presence:encoded",
|
|
448
|
+
) !== undefined
|
|
449
|
+
);
|
|
450
|
+
} catch {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function mutableObject(value: JsonValue | undefined): JsonObject {
|
|
456
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
457
|
+
? (value as JsonObject)
|
|
458
|
+
: {};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function upsertIdentityItem(
|
|
462
|
+
values: JsonValue[],
|
|
463
|
+
identityField: string,
|
|
464
|
+
identity: string,
|
|
465
|
+
): JsonObject {
|
|
466
|
+
const existing = values.find(
|
|
467
|
+
(value) => mutableObject(value)[identityField] === identity,
|
|
468
|
+
);
|
|
469
|
+
if (existing) return mutableObject(existing);
|
|
470
|
+
const created: JsonObject = { [identityField]: identity };
|
|
471
|
+
values.push(created);
|
|
472
|
+
return created;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Reconstructs only the affected persisted locale leaves. Source-fallback
|
|
477
|
+
* values that are visible in the complete resident projection are never
|
|
478
|
+
* copied into this sparse payload.
|
|
479
|
+
*/
|
|
480
|
+
export function sparseBlockRoomLocalePayload(
|
|
481
|
+
yDocument: Y.Doc,
|
|
482
|
+
blockId: string,
|
|
483
|
+
values: readonly AIDocumentFieldTarget[],
|
|
484
|
+
): JsonValue {
|
|
485
|
+
const rawBase = blockRoomBaseNodes(yDocument).get(blockId);
|
|
486
|
+
if (!(rawBase instanceof Y.Map)) return presenceFail("sparse:block_missing");
|
|
487
|
+
const family = rawBase.get("family");
|
|
488
|
+
const result: JsonObject = {};
|
|
489
|
+
for (const value of values) {
|
|
490
|
+
const target = canonicalTarget(
|
|
491
|
+
canonicalBlockRoomLocaleValueTarget(yDocument, value),
|
|
492
|
+
);
|
|
493
|
+
if (target.blockId !== blockId) return presenceFail("sparse:block_owner");
|
|
494
|
+
const leaf = blockRoomLocaleValue(yDocument, target.target) as JsonValue;
|
|
495
|
+
if (family === "rich_text") {
|
|
496
|
+
if (target.field === "content") {
|
|
497
|
+
result.content = leaf;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (target.field === "tableContent") {
|
|
501
|
+
const content = mutableObject(result.content);
|
|
502
|
+
result.content = content;
|
|
503
|
+
const rows = (content.rows ??= []) as JsonValue[];
|
|
504
|
+
const row = upsertIdentityItem(rows, "rowId", target.path[1]![1]);
|
|
505
|
+
const cells = (row.cells ??= []) as JsonValue[];
|
|
506
|
+
const cell = upsertIdentityItem(cells, "cellId", target.path[3]![1]);
|
|
507
|
+
cell.content = leaf;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const props = mutableObject(result.props);
|
|
511
|
+
result.props = props;
|
|
512
|
+
props[target.field] = leaf;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
if (target.path[0]?.[1] === "props") {
|
|
516
|
+
const props = mutableObject(result.props);
|
|
517
|
+
result.props = props;
|
|
518
|
+
props[target.path[1]![1]] = leaf;
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const units = (result.units ??= []) as JsonValue[];
|
|
522
|
+
const unit = upsertIdentityItem(units, "unitId", target.path[1]![1]);
|
|
523
|
+
const props = mutableObject(unit.props);
|
|
524
|
+
unit.props = props;
|
|
525
|
+
props[target.path[3]![1]] = leaf;
|
|
526
|
+
}
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export function canonicalBlockRoomLocaleValueTargetKey(
|
|
531
|
+
yDocument: Y.Doc,
|
|
532
|
+
value: AIDocumentFieldTarget | undefined,
|
|
533
|
+
): string {
|
|
534
|
+
return canonicalTarget(canonicalBlockRoomLocaleValueTarget(yDocument, value))
|
|
535
|
+
.key;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Validates, canonicalizes, sorts, and deduplicates locale leaf targets. */
|
|
539
|
+
export function canonicalBlockRoomLocaleValueTargets(
|
|
540
|
+
yDocument: Y.Doc,
|
|
541
|
+
values: readonly AIDocumentFieldTarget[],
|
|
542
|
+
): AIDocumentFieldTarget[] {
|
|
543
|
+
return [
|
|
544
|
+
...new Map(
|
|
545
|
+
values.map((value) => {
|
|
546
|
+
const target = canonicalBlockRoomLocaleValueTarget(yDocument, value);
|
|
547
|
+
return [canonicalTarget(target).key, target] as const;
|
|
548
|
+
}),
|
|
549
|
+
).entries(),
|
|
550
|
+
]
|
|
551
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
552
|
+
.map(([, target]) => target);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function blockRoomLocaleValueTargetIdentity(
|
|
556
|
+
value: AIDocumentFieldTarget | undefined,
|
|
557
|
+
): string {
|
|
558
|
+
return canonicalTarget(value).key;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function blockRoomLocaleValueTargetBlockId(
|
|
562
|
+
value: AIDocumentFieldTarget | undefined,
|
|
563
|
+
): string {
|
|
564
|
+
return canonicalTarget(value).blockId;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function scalarLocaleCatalogFields(
|
|
568
|
+
fields: Readonly<Record<string, CatalogField>>,
|
|
569
|
+
): string[] {
|
|
570
|
+
return Object.entries(fields)
|
|
571
|
+
.filter(
|
|
572
|
+
([, descriptor]) =>
|
|
573
|
+
descriptor.ownership === "locale" &&
|
|
574
|
+
descriptor.type !== "object" &&
|
|
575
|
+
descriptor.type !== "array" &&
|
|
576
|
+
descriptor.type !== "file_attachment",
|
|
577
|
+
)
|
|
578
|
+
.map(([field]) => field)
|
|
579
|
+
.sort();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function allBlockRoomLocaleValueTargets(
|
|
583
|
+
yDocument: Y.Doc,
|
|
584
|
+
): AIDocumentFieldTarget[] {
|
|
585
|
+
const targets: AIDocumentFieldTarget[] = [];
|
|
586
|
+
for (const [blockId, rawNode] of [
|
|
587
|
+
...blockRoomBaseNodes(yDocument).entries(),
|
|
588
|
+
].sort(([left], [right]) => left.localeCompare(right))) {
|
|
589
|
+
const node = rawNode as Y.Map<unknown>;
|
|
590
|
+
const family = node.get("family");
|
|
591
|
+
const kind = node.get("kind") as string;
|
|
592
|
+
const payload = fromYValue(node.get("payload"));
|
|
593
|
+
if (family === "rich_text") {
|
|
594
|
+
const catalogKind =
|
|
595
|
+
richTextBlockKindByProtoCase[
|
|
596
|
+
kind as keyof typeof richTextBlockKindByProtoCase
|
|
597
|
+
];
|
|
598
|
+
const catalog = (
|
|
599
|
+
richTextBlockCatalog as Readonly<Record<string, CatalogEntry>>
|
|
600
|
+
)[catalogKind!]!;
|
|
601
|
+
if (catalog.content === "inline" || catalog.content === "locale_text")
|
|
602
|
+
targets.push(targetFromParts(blockId, "content", []));
|
|
603
|
+
if (catalog.content === "table") {
|
|
604
|
+
const rows = jsonArray(
|
|
605
|
+
jsonRecord(
|
|
606
|
+
jsonRecord(payload, "table:payload").content,
|
|
607
|
+
"table:content",
|
|
608
|
+
).rows,
|
|
609
|
+
"table:rows",
|
|
610
|
+
);
|
|
611
|
+
for (const row of rows) {
|
|
612
|
+
const rowRecord = jsonRecord(row, "table:row");
|
|
613
|
+
const rowId = String(rowRecord.id);
|
|
614
|
+
for (const cell of jsonArray(rowRecord.cells, "table:cells")) {
|
|
615
|
+
const cellId = String(jsonRecord(cell, "table:cell").id);
|
|
616
|
+
targets.push(
|
|
617
|
+
targetFromParts(blockId, "tableContent", [
|
|
618
|
+
["field", "rows"],
|
|
619
|
+
["item", rowId],
|
|
620
|
+
["field", "cells"],
|
|
621
|
+
["item", cellId],
|
|
622
|
+
["field", "content"],
|
|
623
|
+
]),
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
for (const field of scalarLocaleCatalogFields(catalog.fields))
|
|
629
|
+
targets.push(targetFromParts(blockId, field, []));
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const catalogKind =
|
|
633
|
+
pageSectionKindByProtoCase[
|
|
634
|
+
kind as keyof typeof pageSectionKindByProtoCase
|
|
635
|
+
];
|
|
636
|
+
const catalog = (
|
|
637
|
+
pageSectionCatalog as Readonly<Record<string, CatalogEntry>>
|
|
638
|
+
)[catalogKind!]!;
|
|
639
|
+
for (const field of scalarLocaleCatalogFields(catalog.fields))
|
|
640
|
+
targets.push(
|
|
641
|
+
targetFromParts(blockId, "locale-data", [
|
|
642
|
+
["field", "props"],
|
|
643
|
+
["field", field],
|
|
644
|
+
]),
|
|
645
|
+
);
|
|
646
|
+
if (catalogKind === "immersive-scene") {
|
|
647
|
+
const fields = scalarLocaleCatalogFields(
|
|
648
|
+
pageImmersiveUnitCatalog as Readonly<Record<string, CatalogField>>,
|
|
649
|
+
);
|
|
650
|
+
const rawUnits = jsonRecord(payload, "page:payload").units;
|
|
651
|
+
const units =
|
|
652
|
+
rawUnits === undefined ? [] : jsonArray(rawUnits, "page:units");
|
|
653
|
+
for (const unit of units) {
|
|
654
|
+
const unitId = String(jsonRecord(unit, "page:unit").id);
|
|
655
|
+
for (const field of fields)
|
|
656
|
+
targets.push(
|
|
657
|
+
targetFromParts(blockId, "locale-data", [
|
|
658
|
+
["field", "units"],
|
|
659
|
+
["item", unitId],
|
|
660
|
+
["field", "props"],
|
|
661
|
+
["field", field],
|
|
662
|
+
]),
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return targets.sort((left, right) =>
|
|
668
|
+
canonicalTarget(left).key.localeCompare(canonicalTarget(right).key),
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export function blockRoomLocalePresence(yDocument: Y.Doc): Y.Map<unknown> {
|
|
673
|
+
const value = yDocument
|
|
674
|
+
.getMap<unknown>("block-document")
|
|
675
|
+
.get(BLOCK_ROOM_LOCALE_PRESENCE);
|
|
676
|
+
return value instanceof Y.Map ? value : presenceFail("map");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
export function hydrateBlockRoomLocalePresence(
|
|
680
|
+
yDocument: Y.Doc,
|
|
681
|
+
values: readonly AIDocumentFieldTarget[],
|
|
682
|
+
): void {
|
|
683
|
+
const presence = blockRoomLocalePresence(yDocument);
|
|
684
|
+
let previous = "";
|
|
685
|
+
for (const value of values) {
|
|
686
|
+
const target = canonicalBlockRoomLocaleValueTarget(yDocument, value);
|
|
687
|
+
const key = canonicalTarget(target).key;
|
|
688
|
+
if (key <= previous) return presenceFail("order_or_duplicate");
|
|
689
|
+
previous = key;
|
|
690
|
+
presence.set(key, true);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function targetFromKey(key: string): AIDocumentFieldTarget {
|
|
695
|
+
let decoded: unknown;
|
|
696
|
+
try {
|
|
697
|
+
decoded = JSON.parse(key);
|
|
698
|
+
} catch {
|
|
699
|
+
return presenceFail("key");
|
|
700
|
+
}
|
|
701
|
+
if (
|
|
702
|
+
!Array.isArray(decoded) ||
|
|
703
|
+
decoded.length !== 3 ||
|
|
704
|
+
!Array.isArray(decoded[2])
|
|
705
|
+
)
|
|
706
|
+
return presenceFail("key");
|
|
707
|
+
const [blockId, field, rawPath] = decoded;
|
|
708
|
+
if (typeof blockId !== "string" || typeof field !== "string")
|
|
709
|
+
return presenceFail("key");
|
|
710
|
+
const path: CanonicalPathSegment[] = rawPath.map((segment) => {
|
|
711
|
+
if (
|
|
712
|
+
!Array.isArray(segment) ||
|
|
713
|
+
segment.length !== 2 ||
|
|
714
|
+
(segment[0] !== "field" && segment[0] !== "item") ||
|
|
715
|
+
typeof segment[1] !== "string"
|
|
716
|
+
)
|
|
717
|
+
return presenceFail("key");
|
|
718
|
+
return [segment[0], segment[1]];
|
|
719
|
+
});
|
|
720
|
+
return targetFromParts(blockId, field, path);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
export function blockRoomPresentLocaleValues(
|
|
724
|
+
yDocument: Y.Doc,
|
|
725
|
+
): AIDocumentFieldTarget[] {
|
|
726
|
+
return [...blockRoomLocalePresence(yDocument).entries()]
|
|
727
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
728
|
+
.map(([key, value]) => {
|
|
729
|
+
if (value !== true) return presenceFail("value");
|
|
730
|
+
const target = targetFromKey(key);
|
|
731
|
+
canonicalBlockRoomLocaleValueTarget(yDocument, target);
|
|
732
|
+
return target;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export function markBlockRoomLocaleValuePresent(
|
|
737
|
+
yDocument: Y.Doc,
|
|
738
|
+
value: AIDocumentFieldTarget | undefined,
|
|
739
|
+
): void {
|
|
740
|
+
const key = canonicalBlockRoomLocaleValueTargetKey(yDocument, value);
|
|
741
|
+
blockRoomLocalePresence(yDocument).set(key, true);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
export function markAllBlockRoomLocaleValuesPresentForBlock(
|
|
745
|
+
yDocument: Y.Doc,
|
|
746
|
+
blockId: string,
|
|
747
|
+
): void {
|
|
748
|
+
for (const target of allBlockRoomLocaleValueTargets(yDocument)) {
|
|
749
|
+
if (target.owner.case === "blockHandle" && target.owner.value === blockId)
|
|
750
|
+
markBlockRoomLocaleValuePresent(yDocument, target);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
export function deleteBlockRoomLocalePresenceForBlocks(
|
|
755
|
+
yDocument: Y.Doc,
|
|
756
|
+
blockIds: ReadonlySet<string>,
|
|
757
|
+
): void {
|
|
758
|
+
const presence = blockRoomLocalePresence(yDocument);
|
|
759
|
+
for (const key of [...presence.keys()]) {
|
|
760
|
+
const target = canonicalTarget(targetFromKey(key));
|
|
761
|
+
if (blockIds.has(target.blockId)) presence.delete(key);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function assertBlockRoomLocalePresenceNotRemoved(
|
|
766
|
+
before: Y.Doc,
|
|
767
|
+
after: Y.Doc,
|
|
768
|
+
): void {
|
|
769
|
+
const beforeKeys = new Set(
|
|
770
|
+
blockRoomPresentLocaleValues(before).map(
|
|
771
|
+
(target) => canonicalTarget(target).key,
|
|
772
|
+
),
|
|
773
|
+
);
|
|
774
|
+
const afterKeys = new Set(
|
|
775
|
+
blockRoomPresentLocaleValues(after).map(
|
|
776
|
+
(target) => canonicalTarget(target).key,
|
|
777
|
+
),
|
|
778
|
+
);
|
|
779
|
+
for (const key of beforeKeys) {
|
|
780
|
+
if (afterKeys.has(key)) continue;
|
|
781
|
+
try {
|
|
782
|
+
canonicalBlockRoomLocaleValueTarget(after, targetFromKey(key));
|
|
783
|
+
} catch {
|
|
784
|
+
// Source structural deletion and kind replacement remove targets that no
|
|
785
|
+
// longer exist in the post-state catalog. Those stale markers must go.
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
return presenceFail("removed");
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export function assertBlockRoomLocaleValueChangesPresent(
|
|
793
|
+
before: Y.Doc,
|
|
794
|
+
after: Y.Doc,
|
|
795
|
+
): void {
|
|
796
|
+
const beforeTargets = new Map(
|
|
797
|
+
allBlockRoomLocaleValueTargets(before).map((target) => [
|
|
798
|
+
canonicalBlockRoomLocaleValueTargetKey(before, target),
|
|
799
|
+
target,
|
|
800
|
+
]),
|
|
801
|
+
);
|
|
802
|
+
const afterPresence = new Set(
|
|
803
|
+
blockRoomPresentLocaleValues(after).map((target) =>
|
|
804
|
+
canonicalBlockRoomLocaleValueTargetKey(after, target),
|
|
805
|
+
),
|
|
806
|
+
);
|
|
807
|
+
for (const target of allBlockRoomLocaleValueTargets(after)) {
|
|
808
|
+
const key = canonicalBlockRoomLocaleValueTargetKey(after, target);
|
|
809
|
+
const beforeTarget = beforeTargets.get(key);
|
|
810
|
+
const changed =
|
|
811
|
+
!beforeTarget ||
|
|
812
|
+
JSON.stringify(blockRoomLocaleValue(before, beforeTarget)) !==
|
|
813
|
+
JSON.stringify(blockRoomLocaleValue(after, target));
|
|
814
|
+
if (changed && !afterPresence.has(key))
|
|
815
|
+
return presenceFail("unmarked_change");
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function localePresenceTargetsForBlock(
|
|
820
|
+
yDocument: Y.Doc,
|
|
821
|
+
blockId: string,
|
|
822
|
+
): AIDocumentFieldTarget[] {
|
|
823
|
+
return blockRoomPresentLocaleValues(yDocument).filter(
|
|
824
|
+
(target) =>
|
|
825
|
+
target.owner.case === "blockHandle" && target.owner.value === blockId,
|
|
826
|
+
);
|
|
827
|
+
}
|