@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.
Files changed (47) hide show
  1. package/LICENSE.md +6 -0
  2. package/README.md +37 -0
  3. package/package.json +89 -0
  4. package/src/collaboration/artist.ts +63 -0
  5. package/src/collaboration/block-room-codec/ai-document-applicator.ts +319 -0
  6. package/src/collaboration/block-room-codec/ai-document-field-mutations.ts +557 -0
  7. package/src/collaboration/block-room-codec/ai-document-page-structure-mutations.ts +449 -0
  8. package/src/collaboration/block-room-codec/ai-document-values.ts +368 -0
  9. package/src/collaboration/block-room-codec/hydration.ts +257 -0
  10. package/src/collaboration/block-room-codec/internal.ts +432 -0
  11. package/src/collaboration/block-room-codec/locale-change-validation.ts +237 -0
  12. package/src/collaboration/block-room-codec/locale-presence.ts +827 -0
  13. package/src/collaboration/block-room-codec/materialization.ts +450 -0
  14. package/src/collaboration/block-room-codec/observation.ts +518 -0
  15. package/src/collaboration/block-room-codec/payload-mutations.ts +347 -0
  16. package/src/collaboration/block-room-codec/room-access.ts +154 -0
  17. package/src/collaboration/block-room-codec/structure-mutations.ts +456 -0
  18. package/src/collaboration/block-room-codec.ts +86 -0
  19. package/src/collaboration/campaign.ts +37 -0
  20. package/src/collaboration/document-layout.ts +75 -0
  21. package/src/collaboration/document.ts +185 -0
  22. package/src/collaboration/email-layout.ts +150 -0
  23. package/src/collaboration/form.ts +513 -0
  24. package/src/collaboration/label.ts +49 -0
  25. package/src/collaboration/map-theme.ts +157 -0
  26. package/src/collaboration/member-id.ts +9 -0
  27. package/src/collaboration/menu.ts +258 -0
  28. package/src/collaboration/metadata-ai.ts +99 -0
  29. package/src/collaboration/page.ts +483 -0
  30. package/src/collaboration/post-series.ts +96 -0
  31. package/src/collaboration/post.ts +59 -0
  32. package/src/collaboration/release.ts +221 -0
  33. package/src/collaboration/runtime-events.ts +547 -0
  34. package/src/collaboration/work.ts +107 -0
  35. package/src/editor/link-normalization.ts +212 -0
  36. package/src/editor/materialized-blocks.ts +58 -0
  37. package/src/index.ts +22 -0
  38. package/src/media/block-schemas.ts +78 -0
  39. package/src/media/hydration.ts +226 -0
  40. package/src/page/block-fixtures.ts +586 -0
  41. package/src/page/index.ts +3 -0
  42. package/src/page/types.ts +57 -0
  43. package/src/post/index.ts +1 -0
  44. package/src/post/types.ts +13 -0
  45. package/src/test/random-id.ts +9 -0
  46. package/src/translation/release.ts +88 -0
  47. package/src/types.ts +14 -0
@@ -0,0 +1,557 @@
1
+ import type { JsonValue } from "@bufbuild/protobuf";
2
+ import {
3
+ pageImmersiveUnitCatalog,
4
+ pageSectionCatalog,
5
+ pageSectionKindByProtoCase,
6
+ pageSectionSettingsCatalog,
7
+ richTextBlockCatalog,
8
+ richTextBlockKindByProtoCase,
9
+ type PageSectionKind,
10
+ } from "@echovisionlab/geul-proto/content/block_catalog.ts";
11
+ import type {
12
+ AIDocumentFieldTarget,
13
+ AIDocumentValue,
14
+ } from "@echovisionlab/geul-proto/secure/ai_pb.ts";
15
+ import * as Y from "yjs";
16
+ import { fromYValue, isCollaborativeTextPath, toYValue } from "./internal.ts";
17
+ import { blockRoomBaseNodes } from "./materialization.ts";
18
+ import {
19
+ blockRoomLocaleValueRef,
20
+ deleteBlockRoomLocalePresenceForBlocks,
21
+ markAllBlockRoomLocaleValuesPresentForBlock,
22
+ markBlockRoomLocaleValuePresent,
23
+ } from "./locale-presence.ts";
24
+ import {
25
+ nodeKind,
26
+ payloadParent,
27
+ roomLocaleRole,
28
+ roomNode,
29
+ type BlockRoomPayloadRef,
30
+ } from "./room-access.ts";
31
+ import {
32
+ blockTarget,
33
+ operationFail,
34
+ plainValue,
35
+ requiredHandle,
36
+ resolvedCatalogField,
37
+ resolvedPath,
38
+ yPath,
39
+ type CatalogField,
40
+ type JsonPathPart,
41
+ } from "./ai-document-values.ts";
42
+
43
+ type RichTextCatalogEntry = {
44
+ readonly content: string;
45
+ readonly fields: Readonly<Record<string, CatalogField>>;
46
+ };
47
+
48
+ const richTextCatalog = richTextBlockCatalog as Readonly<
49
+ Record<string, RichTextCatalogEntry>
50
+ >;
51
+
52
+ type PageSectionCatalogEntry = {
53
+ readonly fields: Readonly<Record<string, CatalogField>>;
54
+ };
55
+
56
+ const pageCatalog = pageSectionCatalog as Readonly<
57
+ Record<PageSectionKind, PageSectionCatalogEntry>
58
+ >;
59
+
60
+ function ownedFields(
61
+ fields: Readonly<Record<string, CatalogField>>,
62
+ ownerships: ReadonlySet<string>,
63
+ ): Readonly<Record<string, CatalogField>> {
64
+ return Object.fromEntries(
65
+ Object.entries(fields).filter(([, descriptor]) =>
66
+ ownerships.has(descriptor.ownership as string),
67
+ ),
68
+ );
69
+ }
70
+
71
+ function pageField(
72
+ kind: string,
73
+ field: string,
74
+ ): { locale: boolean; prefix: JsonPathPart[]; descriptor: CatalogField } {
75
+ const catalogKind =
76
+ pageSectionKindByProtoCase[kind as keyof typeof pageSectionKindByProtoCase];
77
+ if (!catalogKind) return operationFail(`page_kind:${kind}`);
78
+ const fields = pageCatalog[catalogKind].fields;
79
+ if (field === "settings") {
80
+ return {
81
+ locale: false,
82
+ prefix: ["settings"],
83
+ descriptor: {
84
+ type: "object",
85
+ fields: pageSectionSettingsCatalog as Readonly<
86
+ Record<string, CatalogField>
87
+ >,
88
+ },
89
+ };
90
+ }
91
+ const locale = field === "locale-data";
92
+ if (!locale && field !== "data") return operationFail(`field:${field}`);
93
+ const sectionFields =
94
+ catalogKind === "columns"
95
+ ? Object.fromEntries(
96
+ Object.entries(fields).filter(
97
+ ([fieldName]) => fieldName !== "columns",
98
+ ),
99
+ )
100
+ : fields;
101
+ const descriptorFields: Record<string, CatalogField> = {
102
+ props: {
103
+ type: "object",
104
+ fields: ownedFields(
105
+ sectionFields,
106
+ new Set(locale ? ["locale"] : ["shared", "source"]),
107
+ ),
108
+ },
109
+ };
110
+ if (catalogKind === "immersive-scene") {
111
+ const identityField = locale ? "unitId" : "id";
112
+ descriptorFields.units = {
113
+ type: "array",
114
+ item_identity: { strategy: "field", field: identityField },
115
+ items: {
116
+ type: "object",
117
+ fields: {
118
+ [identityField]: { type: "uuid" },
119
+ props: {
120
+ type: "object",
121
+ fields: ownedFields(
122
+ pageImmersiveUnitCatalog as Readonly<
123
+ Record<string, CatalogField>
124
+ >,
125
+ new Set(locale ? ["locale"] : ["shared", "source"]),
126
+ ),
127
+ },
128
+ },
129
+ },
130
+ };
131
+ }
132
+ return {
133
+ locale,
134
+ prefix: [],
135
+ descriptor: { type: "object", fields: descriptorFields },
136
+ };
137
+ }
138
+
139
+ function parsedYPath(path: string): JsonPathPart[] {
140
+ const result: JsonPathPart[] = [];
141
+ for (const segment of path.split(".")) {
142
+ const match = /^([A-Za-z][A-Za-z0-9]*)(?:\[(\d+)\])?$/.exec(segment)!;
143
+ result.push(match[1]!);
144
+ if (match[2] !== undefined) result.push(Number(match[2]));
145
+ }
146
+ return result;
147
+ }
148
+
149
+ function ensureYParent(node: Y.Map<unknown>, path: string): void {
150
+ const parts = parsedYPath(path);
151
+ parts.pop();
152
+ let current: unknown = node.get("payload");
153
+ for (const part of parts) {
154
+ if (typeof part === "string") {
155
+ const parent = current as Y.Map<unknown>;
156
+ let child = parent.get(part);
157
+ if (child === undefined) {
158
+ child = new Y.Map<unknown>();
159
+ parent.set(part, child);
160
+ }
161
+ current = child;
162
+ continue;
163
+ }
164
+ current = (current as Y.Array<unknown>).get(part);
165
+ }
166
+ }
167
+
168
+ function replaceYValue(
169
+ node: Y.Map<unknown>,
170
+ ref: BlockRoomPayloadRef,
171
+ value: JsonValue,
172
+ ): void {
173
+ const reason = `node:${ref.id}:path:${ref.path}`;
174
+ ensureYParent(node, ref.path);
175
+ const { parent, key } = payloadParent(node, ref.path, reason);
176
+ const current =
177
+ parent instanceof Y.Map
178
+ ? parent.get(key as string)
179
+ : parent.get(key as number);
180
+ const predicate = (path: string): boolean =>
181
+ isCollaborativeTextPath(ref.family, nodeKind(node, ref), path);
182
+ if (current instanceof Y.Text && typeof value === "string") {
183
+ if (current.length > 0) current.delete(0, current.length);
184
+ if (value) current.insert(0, value);
185
+ return;
186
+ }
187
+ if (current instanceof Y.Array && Array.isArray(value)) {
188
+ if (current.length > 0) current.delete(0, current.length);
189
+ if (value.length > 0) {
190
+ current.insert(
191
+ 0,
192
+ value.map((child, index) =>
193
+ toYValue(child, `${ref.path}[${index}]`, predicate),
194
+ ),
195
+ );
196
+ }
197
+ return;
198
+ }
199
+ if (
200
+ current instanceof Y.Map &&
201
+ value !== null &&
202
+ typeof value === "object" &&
203
+ !Array.isArray(value)
204
+ ) {
205
+ for (const child of [...current.keys()]) current.delete(child);
206
+ for (const [child, childValue] of Object.entries(value).sort(
207
+ ([left], [right]) => left.localeCompare(right),
208
+ )) {
209
+ current.set(
210
+ child,
211
+ toYValue(childValue, `${ref.path}.${child}`, predicate),
212
+ );
213
+ }
214
+ return;
215
+ }
216
+ const converted = toYValue(value, ref.path, predicate);
217
+ if (parent instanceof Y.Map) {
218
+ parent.set(key as string, converted);
219
+ return;
220
+ }
221
+ const index = key as number;
222
+ parent.delete(index, 1);
223
+ parent.insert(index, [converted]);
224
+ }
225
+
226
+ function deleteYValue(node: Y.Map<unknown>, ref: BlockRoomPayloadRef): void {
227
+ const { parent, key } = payloadParent(
228
+ node,
229
+ ref.path,
230
+ `node:${ref.id}:path:${ref.path}`,
231
+ );
232
+ if (parent instanceof Y.Map) {
233
+ if (!parent.has(key as string)) operationFail("field_path:missing");
234
+ parent.delete(key as string);
235
+ return;
236
+ }
237
+ const index = key as number;
238
+ parent.delete(index, 1);
239
+ }
240
+
241
+ type TableRecord = Record<string, unknown>;
242
+
243
+ function tableRecord(value: unknown, reason: string): TableRecord {
244
+ if (!value || typeof value !== "object" || Array.isArray(value))
245
+ return operationFail(reason);
246
+ return value as TableRecord;
247
+ }
248
+
249
+ function tableArray(value: unknown, reason: string): unknown[] {
250
+ if (!Array.isArray(value)) return operationFail(reason);
251
+ return value;
252
+ }
253
+
254
+ function reconcileSourceTableLocale(yDocument: Y.Doc, blockId: string): void {
255
+ const baseNode = roomNode(yDocument, { id: blockId, family: "rich_text" });
256
+ const localeNode = roomNode(yDocument, {
257
+ id: blockId,
258
+ family: "rich_text",
259
+ locale: true,
260
+ });
261
+ const basePayload = tableRecord(
262
+ fromYValue(baseNode.get("payload")),
263
+ "table:base",
264
+ );
265
+ const baseContent = tableRecord(basePayload.content, "table:base_content");
266
+ const localePayload = tableRecord(
267
+ fromYValue(localeNode.get("payload")),
268
+ "table:locale",
269
+ );
270
+ const localeContent = tableRecord(
271
+ localePayload.content,
272
+ "table:locale_content",
273
+ );
274
+ const localeRows = new Map(
275
+ tableArray(localeContent.rows, "table:locale_rows").map((value) => {
276
+ const row = tableRecord(value, "table:locale_row");
277
+ return [String(row.rowId), row] as const;
278
+ }),
279
+ );
280
+ const rows = tableArray(baseContent.rows, "table:base_rows").map((value) => {
281
+ const baseRow = tableRecord(value, "table:base_row");
282
+ const rowId = String(baseRow.id);
283
+ const localeRow = localeRows.get(rowId);
284
+ const localeCells = new Map(
285
+ tableArray(localeRow?.cells ?? [], "table:locale_cells").map(
286
+ (cellValue) => {
287
+ const cell = tableRecord(cellValue, "table:locale_cell");
288
+ return [String(cell.cellId), cell] as const;
289
+ },
290
+ ),
291
+ );
292
+ return {
293
+ rowId,
294
+ cells: tableArray(baseRow.cells, "table:base_cells").map((cellValue) => {
295
+ const cellId = String(tableRecord(cellValue, "table:base_cell").id);
296
+ return {
297
+ cellId,
298
+ content: (localeCells.get(cellId)?.content ?? []) as JsonValue,
299
+ };
300
+ }),
301
+ };
302
+ });
303
+ replaceYValue(
304
+ localeNode,
305
+ { id: blockId, family: "rich_text", locale: true, path: "content" },
306
+ { rows } as JsonValue,
307
+ );
308
+ deleteBlockRoomLocalePresenceForBlocks(yDocument, new Set([blockId]));
309
+ markAllBlockRoomLocaleValuesPresentForBlock(yDocument, blockId);
310
+ }
311
+
312
+ function reconcileSourceImmersiveLocale(
313
+ yDocument: Y.Doc,
314
+ blockId: string,
315
+ ): void {
316
+ const baseNode = roomNode(yDocument, {
317
+ id: blockId,
318
+ family: "page_section",
319
+ });
320
+ const localeNode = roomNode(yDocument, {
321
+ id: blockId,
322
+ family: "page_section",
323
+ locale: true,
324
+ });
325
+ const basePayload = tableRecord(
326
+ fromYValue(baseNode.get("payload")),
327
+ "immersive:base",
328
+ );
329
+ const localePayload = tableRecord(
330
+ fromYValue(localeNode.get("payload")),
331
+ "immersive:locale",
332
+ );
333
+ const localizedByID = new Map(
334
+ tableArray(localePayload.units, "immersive:locale_units").map((value) => {
335
+ const unit = tableRecord(value, "immersive:locale_unit");
336
+ return [String(unit.unitId), unit] as const;
337
+ }),
338
+ );
339
+ const units = tableArray(basePayload.units, "immersive:base_units").map(
340
+ (value) => {
341
+ const id = String(tableRecord(value, "immersive:base_unit").id);
342
+ return {
343
+ unitId: id,
344
+ props: (localizedByID.get(id)?.props ?? {}) as JsonValue,
345
+ };
346
+ },
347
+ );
348
+ replaceYValue(
349
+ localeNode,
350
+ {
351
+ id: blockId,
352
+ family: "page_section",
353
+ locale: true,
354
+ path: "units",
355
+ },
356
+ units,
357
+ );
358
+ deleteBlockRoomLocalePresenceForBlocks(yDocument, new Set([blockId]));
359
+ markAllBlockRoomLocaleValuesPresentForBlock(yDocument, blockId);
360
+ }
361
+
362
+ function isWholeImmersiveUnitList(
363
+ target: ReturnType<typeof blockTarget>,
364
+ ): boolean {
365
+ return (
366
+ target.field === "data" &&
367
+ target.path.length === 1 &&
368
+ target.path[0]?.selector.case === "fieldHandle" &&
369
+ target.path[0].selector.value === "units"
370
+ );
371
+ }
372
+
373
+ function sourceTableValue(value: JsonValue): JsonValue {
374
+ const table = tableRecord(value, "table:value");
375
+ return {
376
+ ...table,
377
+ rows: tableArray(table.rows, "table:rows").map((rowValue) => {
378
+ const row = tableRecord(rowValue, "table:row");
379
+ const { rowId, cells, ...rowFields } = row;
380
+ return {
381
+ ...rowFields,
382
+ id: String(rowId),
383
+ cells: tableArray(cells, "table:cells").map((cellValue) => {
384
+ const cell = tableRecord(cellValue, "table:cell");
385
+ const { cellId, ...cellFields } = cell;
386
+ return { ...cellFields, id: String(cellId) };
387
+ }),
388
+ };
389
+ }),
390
+ } as JsonValue;
391
+ }
392
+
393
+ function richField(
394
+ kind: string,
395
+ field: string,
396
+ ): { locale: boolean; prefix: JsonPathPart[]; descriptor?: CatalogField } {
397
+ const catalogKind =
398
+ richTextBlockKindByProtoCase[
399
+ kind as keyof typeof richTextBlockKindByProtoCase
400
+ ];
401
+ const catalog = richTextCatalog[catalogKind!]!;
402
+ if (field === "content") {
403
+ if (catalog.content !== "inline" && catalog.content !== "locale_text")
404
+ return operationFail(`field:${field}`);
405
+ return { locale: true, prefix: ["content"] };
406
+ }
407
+ if (field === "table" && catalog.content === "table")
408
+ return { locale: false, prefix: ["content"] };
409
+ if (field === "tableContent" && catalog.content === "table")
410
+ return { locale: true, prefix: ["content"] };
411
+ const descriptor = catalog.fields[field];
412
+ if (!descriptor) return operationFail(`field:${field}`);
413
+ return {
414
+ locale: descriptor.ownership === "locale",
415
+ prefix: ["props", field],
416
+ descriptor,
417
+ };
418
+ }
419
+
420
+ function fieldRef(
421
+ yDocument: Y.Doc,
422
+ target: ReturnType<typeof blockTarget>,
423
+ targetValue: AIDocumentFieldTarget | undefined,
424
+ createMissing: boolean,
425
+ ): {
426
+ ref: BlockRoomPayloadRef;
427
+ descriptor?: CatalogField;
428
+ fieldName: string;
429
+ locale: boolean;
430
+ } {
431
+ const base = blockRoomBaseNodes(yDocument).get(target.blockId);
432
+ if (!(base instanceof Y.Map))
433
+ return operationFail(`block:${target.blockId}:missing`);
434
+ const family = base.get("family") as "page_section" | "rich_text";
435
+ const kind = base.get("kind") as string;
436
+ const { locale, prefix, descriptor } =
437
+ family === "page_section"
438
+ ? pageField(kind, target.field)
439
+ : richField(kind, target.field);
440
+ if (target.field === "tableContent") {
441
+ return {
442
+ ref: blockRoomLocaleValueRef(yDocument, targetValue),
443
+ fieldName: "content",
444
+ locale: true,
445
+ };
446
+ }
447
+ const node = roomNode(yDocument, {
448
+ id: target.blockId,
449
+ family,
450
+ ...(locale ? { locale: true as const } : {}),
451
+ });
452
+ const payload = fromYValue(node.get("payload"));
453
+ const path = resolvedPath(
454
+ payload,
455
+ prefix,
456
+ target.path,
457
+ createMissing,
458
+ descriptor,
459
+ target.field,
460
+ );
461
+ const fieldName = target.path.reduce(
462
+ (name, segment) =>
463
+ segment.selector.case === "fieldHandle"
464
+ ? requiredHandle(segment.selector.value, "field_path:field_handle")
465
+ : name,
466
+ target.field,
467
+ );
468
+ return {
469
+ ref: {
470
+ id: target.blockId,
471
+ family,
472
+ ...(locale ? { locale: true as const } : {}),
473
+ path: yPath(path),
474
+ },
475
+ descriptor: resolvedCatalogField(descriptor, target.path),
476
+ fieldName,
477
+ locale,
478
+ };
479
+ }
480
+
481
+ export function setAIDocumentField(
482
+ yDocument: Y.Doc,
483
+ targetValue: AIDocumentFieldTarget | undefined,
484
+ value: AIDocumentValue | undefined,
485
+ ): void {
486
+ const target = blockTarget(targetValue);
487
+ const { ref, descriptor, fieldName, locale } = fieldRef(
488
+ yDocument,
489
+ target,
490
+ targetValue,
491
+ true,
492
+ );
493
+ const node = roomNode(yDocument, ref);
494
+ const next = plainValue(value, descriptor, fieldName);
495
+ if (
496
+ locale &&
497
+ roomLocaleRole(yDocument) === "target" &&
498
+ (descriptor?.type === "object" || descriptor?.type === "array")
499
+ ) {
500
+ operationFail("target_locale_scalar_required");
501
+ }
502
+ replaceYValue(
503
+ node,
504
+ ref,
505
+ target.field === "table" ? sourceTableValue(next) : next,
506
+ );
507
+ if (target.field === "table" && roomLocaleRole(yDocument) === "source") {
508
+ reconcileSourceTableLocale(yDocument, target.blockId);
509
+ return;
510
+ }
511
+ if (
512
+ isWholeImmersiveUnitList(target) &&
513
+ roomLocaleRole(yDocument) === "source"
514
+ ) {
515
+ reconcileSourceImmersiveLocale(yDocument, target.blockId);
516
+ return;
517
+ }
518
+ if (locale) {
519
+ if (descriptor?.type === "object" || descriptor?.type === "array") {
520
+ markAllBlockRoomLocaleValuesPresentForBlock(yDocument, target.blockId);
521
+ return;
522
+ }
523
+ markBlockRoomLocaleValuePresent(yDocument, targetValue);
524
+ }
525
+ }
526
+
527
+ export function unsetAIDocumentField(
528
+ yDocument: Y.Doc,
529
+ targetValue: AIDocumentFieldTarget | undefined,
530
+ ): void {
531
+ const target = blockTarget(targetValue);
532
+ const { ref } = fieldRef(yDocument, target, targetValue, false);
533
+ deleteYValue(roomNode(yDocument, ref), ref);
534
+ }
535
+
536
+ export function attachAIDocumentFile(
537
+ yDocument: Y.Doc,
538
+ targetValue: AIDocumentFieldTarget | undefined,
539
+ file: string,
540
+ ): void {
541
+ const target = blockTarget(targetValue);
542
+ const { ref, descriptor } = fieldRef(yDocument, target, targetValue, true);
543
+ if (descriptor?.type !== "file_attachment") operationFail("file:field");
544
+ replaceYValue(roomNode(yDocument, ref), ref, {
545
+ activeFileId: requiredHandle(file, "file:handle"),
546
+ });
547
+ }
548
+
549
+ export function detachAIDocumentFile(
550
+ yDocument: Y.Doc,
551
+ targetValue: AIDocumentFieldTarget | undefined,
552
+ ): void {
553
+ const target = blockTarget(targetValue);
554
+ const { ref, descriptor } = fieldRef(yDocument, target, targetValue, false);
555
+ if (descriptor?.type !== "file_attachment") operationFail("file:field");
556
+ deleteYValue(roomNode(yDocument, ref), ref);
557
+ }