@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,449 @@
1
+ import { fromJson, type JsonValue } from "@bufbuild/protobuf";
2
+ import {
3
+ pageSectionKindByProtoCase,
4
+ type PageSectionKind,
5
+ } from "@echovisionlab/geul-proto/content/block_catalog.ts";
6
+ import {
7
+ PageSectionLocaleSchema,
8
+ PageSectionNodeSchema,
9
+ type PageSectionLocale,
10
+ type PageSectionNode,
11
+ } from "@echovisionlab/geul-proto/content/block_content_pb.ts";
12
+ import type { AIDocumentOperation } from "@echovisionlab/geul-proto/secure/ai_pb.ts";
13
+ import * as Y from "yjs";
14
+ import {
15
+ arrayPath,
16
+ fromYValue,
17
+ jsonObject,
18
+ setNodePayload,
19
+ toYValue,
20
+ yMap,
21
+ } from "./internal.ts";
22
+ import {
23
+ blockTarget,
24
+ operationFail,
25
+ plainValue,
26
+ requiredHandle,
27
+ } from "./ai-document-values.ts";
28
+ import {
29
+ blockRoomBaseNodes,
30
+ blockRoomBaseOrder,
31
+ decodeBaseNodes,
32
+ type BlockRoomBaseNodeSnapshot,
33
+ } from "./materialization.ts";
34
+ import { setBlockRoomAtomicValue } from "./payload-mutations.ts";
35
+ import {
36
+ roomLocaleRole,
37
+ roomNode,
38
+ type BlockRoomPayloadRef,
39
+ } from "./room-access.ts";
40
+ import {
41
+ deleteBlockRoomLocalePresenceForBlocks,
42
+ markAllBlockRoomLocaleValuesPresentForBlock,
43
+ } from "./locale-presence.ts";
44
+ import {
45
+ deleteBlockRoomBaseNode,
46
+ insertPageSectionLocale,
47
+ insertPageSectionNode,
48
+ movePageSectionNode,
49
+ } from "./structure-mutations.ts";
50
+
51
+ const PAGE_COLUMN_KIND = "page-column";
52
+ const pageSectionProtoCaseByKind = new Map<string, string>(
53
+ Object.entries(pageSectionKindByProtoCase).map(([protoCase, kind]) => [
54
+ kind,
55
+ protoCase,
56
+ ]),
57
+ );
58
+
59
+ interface PageColumnLocation {
60
+ sectionId: string;
61
+ index: number;
62
+ id: string;
63
+ }
64
+
65
+ function pageBaseNodes(yDocument: Y.Doc): BlockRoomBaseNodeSnapshot[] {
66
+ return [
67
+ ...decodeBaseNodes(
68
+ blockRoomBaseNodes(yDocument),
69
+ blockRoomBaseOrder(yDocument),
70
+ ).values(),
71
+ ];
72
+ }
73
+
74
+ function pageColumns(node: BlockRoomBaseNodeSnapshot): JsonValue[] {
75
+ if (node.family !== "page_section" || node.kind !== "columns") return [];
76
+ const payload = jsonObject(node.payload, `page_columns:${node.id}:payload`);
77
+ const props = payload.props;
78
+ if (props === undefined) return [];
79
+ const columns = jsonObject(props, `page_columns:${node.id}:props`).columns;
80
+ if (columns === undefined) return [];
81
+ return Array.isArray(columns)
82
+ ? columns
83
+ : operationFail(`page_columns:${node.id}:shape`);
84
+ }
85
+
86
+ function findPageColumn(
87
+ yDocument: Y.Doc,
88
+ columnId: string,
89
+ ): PageColumnLocation | undefined {
90
+ for (const node of pageBaseNodes(yDocument)) {
91
+ const columns = pageColumns(node);
92
+ const index = columns.findIndex(
93
+ (value) =>
94
+ jsonObject(value, `page_column:${node.id}:entry`).id === columnId,
95
+ );
96
+ if (index >= 0) return { sectionId: node.id, index, id: columnId };
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ function pageColumnsArray(
102
+ yDocument: Y.Doc,
103
+ sectionId: string,
104
+ create: boolean,
105
+ ): Y.Array<unknown> {
106
+ const node = roomNode(yDocument, {
107
+ id: sectionId,
108
+ family: "page_section",
109
+ });
110
+ if (node.get("kind") !== "columns")
111
+ return operationFail(`page_column:parent:${sectionId}`);
112
+ const payload = yMap(node.get("payload"), `page_column:${sectionId}:payload`);
113
+ let props = payload.get("props");
114
+ if (props === undefined && create) {
115
+ props = new Y.Map<unknown>();
116
+ payload.set("props", props);
117
+ }
118
+ const propsMap = yMap(props, `page_column:${sectionId}:props`);
119
+ let columns = propsMap.get("columns");
120
+ if (columns === undefined && create) {
121
+ columns = new Y.Array<unknown>();
122
+ propsMap.set("columns", columns);
123
+ }
124
+ return columns as Y.Array<unknown>;
125
+ }
126
+
127
+ function insertionIndex(
128
+ ids: readonly string[],
129
+ afterId: string | undefined,
130
+ ): number {
131
+ if (afterId === undefined) return 0;
132
+ const after = ids.indexOf(afterId);
133
+ return after < 0
134
+ ? operationFail(`after_block:${afterId}:not_sibling`)
135
+ : after + 1;
136
+ }
137
+
138
+ function pageSectionPlacement(
139
+ yDocument: Y.Doc,
140
+ parentHandle: string | undefined,
141
+ afterHandle: string | undefined,
142
+ movingId?: string,
143
+ ): {
144
+ parentSectionId?: string;
145
+ columnId?: string;
146
+ index: number;
147
+ } {
148
+ const nodes = pageBaseNodes(yDocument);
149
+ let parentSectionId: string | undefined;
150
+ let columnId: string | undefined;
151
+ if (parentHandle) {
152
+ const column = findPageColumn(yDocument, parentHandle);
153
+ if (!column) return operationFail(`page_parent:${parentHandle}:not_column`);
154
+ parentSectionId = column.sectionId;
155
+ columnId = column.id;
156
+ }
157
+ const siblings = nodes
158
+ .filter(
159
+ (node) =>
160
+ node.family === "page_section" &&
161
+ node.id !== movingId &&
162
+ node.parentId === (parentSectionId ?? null) &&
163
+ node.columnId === columnId,
164
+ )
165
+ .sort((left, right) => left.position - right.position);
166
+ return {
167
+ ...(parentSectionId ? { parentSectionId } : {}),
168
+ ...(columnId ? { columnId } : {}),
169
+ index: insertionIndex(
170
+ siblings.map(({ id }) => id),
171
+ afterHandle,
172
+ ),
173
+ };
174
+ }
175
+
176
+ function insertPageSection(
177
+ yDocument: Y.Doc,
178
+ blockId: string,
179
+ kind: string,
180
+ parentHandle: string | undefined,
181
+ afterHandle: string | undefined,
182
+ ): void {
183
+ if (findPageColumn(yDocument, blockId))
184
+ return operationFail(`page_section:${blockId}:column_exists`);
185
+ const protoCase = pageSectionProtoCaseByKind.get(kind)!;
186
+ const placement = pageSectionPlacement(yDocument, parentHandle, afterHandle);
187
+ const node = fromJson(PageSectionNodeSchema, {
188
+ section: { id: blockId, settings: {}, [protoCase]: {} },
189
+ placement,
190
+ }) as PageSectionNode;
191
+ const locale = fromJson(PageSectionLocaleSchema, {
192
+ sectionId: blockId,
193
+ [protoCase]: {},
194
+ }) as PageSectionLocale;
195
+ insertPageSectionNode(yDocument, node);
196
+ insertPageSectionLocale(yDocument, locale);
197
+ }
198
+
199
+ function insertPageColumn(
200
+ yDocument: Y.Doc,
201
+ blockId: string,
202
+ parentHandle: string | undefined,
203
+ afterHandle: string | undefined,
204
+ ): void {
205
+ const parent = requiredHandle(parentHandle ?? "", "page_column:parent");
206
+ const nodes = pageBaseNodes(yDocument);
207
+ if (
208
+ nodes.some(({ id }) => id === blockId) ||
209
+ findPageColumn(yDocument, blockId)
210
+ ) {
211
+ return operationFail(`page_column:${blockId}:exists`);
212
+ }
213
+ const columns = pageColumnsArray(yDocument, parent, true);
214
+ const ids = columns.toArray().map((value) => {
215
+ const object = jsonObject(fromYValue(value), `page_column:${parent}:entry`);
216
+ return String(object.id ?? "");
217
+ });
218
+ const index = insertionIndex(ids, afterHandle);
219
+ columns.insert(index, [
220
+ toYValue(
221
+ { id: blockId, ratio: 1 },
222
+ arrayPath("props.columns", index),
223
+ () => false,
224
+ ),
225
+ ]);
226
+ }
227
+
228
+ function setPageColumnField(
229
+ yDocument: Y.Doc,
230
+ column: PageColumnLocation,
231
+ operation: Extract<
232
+ AIDocumentOperation["operation"],
233
+ { case: "setField" }
234
+ >["value"],
235
+ ): void {
236
+ const target = blockTarget(operation.target);
237
+ if (
238
+ target.field !== "ratio" ||
239
+ target.path.length !== 0 ||
240
+ operation.value?.value.case !== "number"
241
+ ) {
242
+ return operationFail("page_column:ratio");
243
+ }
244
+ const value = plainValue(operation.value, { type: "number" }, "ratio");
245
+ const ref: BlockRoomPayloadRef = {
246
+ id: column.sectionId,
247
+ family: "page_section",
248
+ path: `props.columns[${column.index}].ratio`,
249
+ };
250
+ setBlockRoomAtomicValue(yDocument, ref, value as number);
251
+ }
252
+
253
+ function deletePageColumn(yDocument: Y.Doc, column: PageColumnLocation): void {
254
+ if (pageBaseNodes(yDocument).some(({ columnId }) => columnId === column.id))
255
+ return operationFail(`page_column:${column.id}:not_empty`);
256
+ pageColumnsArray(yDocument, column.sectionId, false).delete(column.index, 1);
257
+ }
258
+
259
+ function movePageColumn(
260
+ yDocument: Y.Doc,
261
+ column: PageColumnLocation,
262
+ parentHandle: string | undefined,
263
+ afterHandle: string | undefined,
264
+ ): void {
265
+ if (parentHandle !== column.sectionId)
266
+ return operationFail(`page_column:${column.id}:parent`);
267
+ const columns = pageColumnsArray(yDocument, column.sectionId, false);
268
+ const remaining = columns
269
+ .toArray()
270
+ .map((value) =>
271
+ String(
272
+ jsonObject(fromYValue(value), `page_column:${column.sectionId}:entry`)
273
+ .id ?? "",
274
+ ),
275
+ )
276
+ .filter((id) => id !== column.id);
277
+ const index = insertionIndex(remaining, afterHandle);
278
+ const value = fromYValue(columns.get(column.index));
279
+ columns.delete(column.index, 1);
280
+ columns.insert(index, [
281
+ toYValue(value, arrayPath("props.columns", index), () => false),
282
+ ]);
283
+ }
284
+
285
+ function replacePageSectionKind(
286
+ yDocument: Y.Doc,
287
+ sectionId: string,
288
+ kind: string,
289
+ ): void {
290
+ const protoCase = pageSectionProtoCaseByKind.get(kind);
291
+ if (!protoCase) return operationFail(`page_section:kind:${kind}`);
292
+ const nodes = pageBaseNodes(yDocument);
293
+ const section = nodes.find(
294
+ ({ id, family }) => id === sectionId && family === "page_section",
295
+ )!;
296
+ const hasChildren = nodes.some(
297
+ ({ id, parentId }) => id !== sectionId && parentId === sectionId,
298
+ );
299
+ if (hasChildren || pageColumns(section).length > 0)
300
+ return operationFail(`page_section:${sectionId}:not_empty`);
301
+ const payload = jsonObject(
302
+ section.payload,
303
+ `page_section:${sectionId}:payload`,
304
+ );
305
+ let nextPayload: JsonValue = {};
306
+ if (payload.settings !== undefined)
307
+ nextPayload = { settings: payload.settings };
308
+ const baseNode = roomNode(yDocument, {
309
+ id: sectionId,
310
+ family: "page_section",
311
+ });
312
+ const localeNode = roomNode(yDocument, {
313
+ id: sectionId,
314
+ family: "page_section",
315
+ locale: true,
316
+ });
317
+ deleteBlockRoomLocalePresenceForBlocks(yDocument, new Set([sectionId]));
318
+ setNodePayload(baseNode, "page_section", protoCase, nextPayload);
319
+ setNodePayload(localeNode, "page_section", protoCase, {});
320
+ if (roomLocaleRole(yDocument) === "source")
321
+ markAllBlockRoomLocaleValuesPresentForBlock(yDocument, sectionId);
322
+ }
323
+
324
+ type PageTargetOperation = Extract<
325
+ AIDocumentOperation["operation"],
326
+ {
327
+ case:
328
+ | "setField"
329
+ | "unsetField"
330
+ | "attachFile"
331
+ | "detachFile"
332
+ | "deleteBlock"
333
+ | "moveBlock"
334
+ | "replaceBlockKind";
335
+ }
336
+ >;
337
+
338
+ function targetedPageOperation(
339
+ operation: AIDocumentOperation,
340
+ ): { target: string; operation: PageTargetOperation } | undefined {
341
+ switch (operation.operation.case) {
342
+ case "setField":
343
+ case "unsetField":
344
+ case "attachFile":
345
+ case "detachFile":
346
+ return {
347
+ target: blockTarget(operation.operation.value.target).blockId,
348
+ operation: operation.operation,
349
+ };
350
+ case "deleteBlock":
351
+ case "moveBlock":
352
+ case "replaceBlockKind":
353
+ return {
354
+ target: operation.operation.value.blockHandle,
355
+ operation: operation.operation,
356
+ };
357
+ default:
358
+ return undefined;
359
+ }
360
+ }
361
+
362
+ /** Applies only Page section and Page-column operations; nested Rich Text falls through. */
363
+ export function applyPageStructureAIDocumentOperation(
364
+ yDocument: Y.Doc,
365
+ operation: AIDocumentOperation,
366
+ ): boolean {
367
+ if (operation.operation.case === "insertBlock") {
368
+ const value = operation.operation.value;
369
+ const id = requiredHandle(value.blockHandle, "insert:block");
370
+ const kind = requiredHandle(value.kind, "insert:kind");
371
+ if (kind === PAGE_COLUMN_KIND) {
372
+ insertPageColumn(
373
+ yDocument,
374
+ id,
375
+ value.parentBlockHandle,
376
+ value.afterBlockHandle,
377
+ );
378
+ return true;
379
+ }
380
+ if (pageSectionProtoCaseByKind.has(kind)) {
381
+ insertPageSection(
382
+ yDocument,
383
+ id,
384
+ kind as PageSectionKind,
385
+ value.parentBlockHandle,
386
+ value.afterBlockHandle,
387
+ );
388
+ return true;
389
+ }
390
+ return false;
391
+ }
392
+ const targeted = targetedPageOperation(operation);
393
+ if (!targeted) return false;
394
+ const { target } = targeted;
395
+ const column = findPageColumn(yDocument, target);
396
+ if (column) {
397
+ switch (targeted.operation.case) {
398
+ case "setField":
399
+ setPageColumnField(yDocument, column, targeted.operation.value);
400
+ return true;
401
+ case "deleteBlock":
402
+ deletePageColumn(yDocument, column);
403
+ return true;
404
+ case "moveBlock":
405
+ movePageColumn(
406
+ yDocument,
407
+ column,
408
+ targeted.operation.value.parentBlockHandle,
409
+ targeted.operation.value.afterBlockHandle,
410
+ );
411
+ return true;
412
+ case "unsetField":
413
+ operationFail("page_column:ratio_required");
414
+ case "replaceBlockKind":
415
+ operationFail("page_column:replace_kind");
416
+ case "attachFile":
417
+ case "detachFile":
418
+ operationFail("page_column:file");
419
+ }
420
+ }
421
+ const node = pageBaseNodes(yDocument).find(({ id }) => id === target);
422
+ if (!node || node.family !== "page_section") return false;
423
+ switch (targeted.operation.case) {
424
+ case "deleteBlock":
425
+ deleteBlockRoomBaseNode(yDocument, target);
426
+ return true;
427
+ case "moveBlock":
428
+ movePageSectionNode(
429
+ yDocument,
430
+ target,
431
+ pageSectionPlacement(
432
+ yDocument,
433
+ targeted.operation.value.parentBlockHandle,
434
+ targeted.operation.value.afterBlockHandle,
435
+ target,
436
+ ),
437
+ );
438
+ return true;
439
+ case "replaceBlockKind":
440
+ replacePageSectionKind(
441
+ yDocument,
442
+ target,
443
+ requiredHandle(targeted.operation.value.kind, "replace:kind"),
444
+ );
445
+ return true;
446
+ default:
447
+ return false;
448
+ }
449
+ }