@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,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resident collaboration document identity.
|
|
3
|
+
*
|
|
4
|
+
* Every room uses `{type}:{entity UUID}:{canonical locale}`. Source and target
|
|
5
|
+
* are current roles derived by the owning API, never document-name segments.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { CollaborativeDocumentType } from "@echovisionlab/geul-proto/secure/collaboration_pb.ts";
|
|
9
|
+
import type * as Y from "yjs";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { memberIdSchema } from "./member-id.ts";
|
|
12
|
+
import type { BlockRoomDocumentType } from "./block-room-codec.ts";
|
|
13
|
+
|
|
14
|
+
export { CollaborativeDocumentType };
|
|
15
|
+
|
|
16
|
+
const CANONICAL_UUID_PATTERN =
|
|
17
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
18
|
+
|
|
19
|
+
type SupportedCollaborativeDocumentType = Exclude<
|
|
20
|
+
CollaborativeDocumentType,
|
|
21
|
+
CollaborativeDocumentType.UNSPECIFIED
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
const documentPrefixes = {
|
|
25
|
+
[CollaborativeDocumentType.POST]: "post",
|
|
26
|
+
[CollaborativeDocumentType.PAGE]: "page",
|
|
27
|
+
[CollaborativeDocumentType.WORK]: "work",
|
|
28
|
+
[CollaborativeDocumentType.CAMPAIGN]: "campaign",
|
|
29
|
+
[CollaborativeDocumentType.EMAIL_TEMPLATE]: "email-template",
|
|
30
|
+
[CollaborativeDocumentType.EMAIL_LAYOUT]: "email-layout",
|
|
31
|
+
[CollaborativeDocumentType.TERMS_HISTORY]: "terms-history",
|
|
32
|
+
[CollaborativeDocumentType.PRIVACY_HISTORY]: "privacy-history",
|
|
33
|
+
[CollaborativeDocumentType.ARTIST]: "artist",
|
|
34
|
+
[CollaborativeDocumentType.RELEASE]: "release",
|
|
35
|
+
[CollaborativeDocumentType.LABEL]: "label",
|
|
36
|
+
[CollaborativeDocumentType.FORM]: "form",
|
|
37
|
+
[CollaborativeDocumentType.MAP_THEME]: "map-theme",
|
|
38
|
+
[CollaborativeDocumentType.PROGRAM_EVENT]: "program-event",
|
|
39
|
+
[CollaborativeDocumentType.MENU]: "menu",
|
|
40
|
+
[CollaborativeDocumentType.POST_SERIES]: "post-series",
|
|
41
|
+
} as const satisfies Record<SupportedCollaborativeDocumentType, string>;
|
|
42
|
+
|
|
43
|
+
const documentTypesByPrefix = new Map<
|
|
44
|
+
string,
|
|
45
|
+
SupportedCollaborativeDocumentType
|
|
46
|
+
>(
|
|
47
|
+
Object.entries(documentPrefixes).map(([type, prefix]) => [
|
|
48
|
+
prefix,
|
|
49
|
+
Number(type) as SupportedCollaborativeDocumentType,
|
|
50
|
+
]),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
function assertEntityId(entityId: string): void {
|
|
54
|
+
if (!CANONICAL_UUID_PATTERN.test(entityId)) {
|
|
55
|
+
throw new Error(`Invalid collaboration entity UUID: ${entityId}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function assertCanonicalLocale(locale: string): void {
|
|
60
|
+
let canonical: string | undefined;
|
|
61
|
+
try {
|
|
62
|
+
[canonical] = Intl.getCanonicalLocales(locale);
|
|
63
|
+
} catch {
|
|
64
|
+
throw new Error(`Invalid collaboration locale: ${locale}`);
|
|
65
|
+
}
|
|
66
|
+
if (canonical !== locale || locale === "source" || locale === "target") {
|
|
67
|
+
throw new Error(`Invalid collaboration locale: ${locale}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assertDocumentLocale(
|
|
72
|
+
type: SupportedCollaborativeDocumentType,
|
|
73
|
+
locale: string,
|
|
74
|
+
): void {
|
|
75
|
+
assertCanonicalLocale(locale);
|
|
76
|
+
if (type === CollaborativeDocumentType.MAP_THEME && locale !== "und") {
|
|
77
|
+
throw new Error("Map Theme collaboration uses the locale-neutral und room");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function residentBlockDocumentType(
|
|
82
|
+
type: CollaborativeDocumentType,
|
|
83
|
+
): BlockRoomDocumentType | undefined {
|
|
84
|
+
switch (type) {
|
|
85
|
+
case CollaborativeDocumentType.POST:
|
|
86
|
+
return "post";
|
|
87
|
+
case CollaborativeDocumentType.PAGE:
|
|
88
|
+
return "page";
|
|
89
|
+
case CollaborativeDocumentType.WORK:
|
|
90
|
+
return "work";
|
|
91
|
+
case CollaborativeDocumentType.PROGRAM_EVENT:
|
|
92
|
+
return "program-event";
|
|
93
|
+
case CollaborativeDocumentType.ARTIST:
|
|
94
|
+
return "artist";
|
|
95
|
+
case CollaborativeDocumentType.LABEL:
|
|
96
|
+
return "label";
|
|
97
|
+
case CollaborativeDocumentType.RELEASE:
|
|
98
|
+
return "release";
|
|
99
|
+
case CollaborativeDocumentType.CAMPAIGN:
|
|
100
|
+
return "campaign";
|
|
101
|
+
case CollaborativeDocumentType.EMAIL_TEMPLATE:
|
|
102
|
+
return "email-template";
|
|
103
|
+
case CollaborativeDocumentType.TERMS_HISTORY:
|
|
104
|
+
return "terms-history";
|
|
105
|
+
case CollaborativeDocumentType.PRIVACY_HISTORY:
|
|
106
|
+
return "privacy-history";
|
|
107
|
+
default:
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface ParsedDocument {
|
|
113
|
+
type: CollaborativeDocumentType;
|
|
114
|
+
entityId: string;
|
|
115
|
+
locale: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const documentSaveOptionsSchema = z
|
|
119
|
+
.object({
|
|
120
|
+
/** Distinct authenticated Members whose edits are included in this persisted state. */
|
|
121
|
+
contributorMemberIds: z.array(memberIdSchema).optional(),
|
|
122
|
+
/** Requests a source version checkpoint after ordinary durable persistence. */
|
|
123
|
+
versionCheckpoint: z.boolean().optional(),
|
|
124
|
+
})
|
|
125
|
+
.strict();
|
|
126
|
+
|
|
127
|
+
export type DocumentSaveOptions = z.infer<typeof documentSaveOptionsSchema>;
|
|
128
|
+
|
|
129
|
+
export function parseDocumentSaveOptions(value: unknown): DocumentSaveOptions {
|
|
130
|
+
return documentSaveOptionsSchema.parse(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface DocumentHandler {
|
|
134
|
+
/** Declares that this domain adapter supports generic collaboration version checkpoints. */
|
|
135
|
+
supportsVersionCheckpoints?: boolean;
|
|
136
|
+
store(id: string, doc: Y.Doc, options?: DocumentSaveOptions): Promise<void>;
|
|
137
|
+
load(id: string): Promise<Buffer | null>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getDocumentPrefix(type: CollaborativeDocumentType): string {
|
|
141
|
+
const prefix = documentPrefixes[type as SupportedCollaborativeDocumentType];
|
|
142
|
+
if (!prefix) throw new Error(`Unknown document type: ${type}`);
|
|
143
|
+
return prefix;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create a document name from type, canonical entity UUID, and canonical locale.
|
|
148
|
+
* Always use this function instead of manual string concatenation.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* createDocumentName(CollaborativeDocumentType.POST, entityId, 'ko')
|
|
152
|
+
*/
|
|
153
|
+
export function createDocumentName(
|
|
154
|
+
type: CollaborativeDocumentType,
|
|
155
|
+
entityId: string,
|
|
156
|
+
locale: string,
|
|
157
|
+
): string {
|
|
158
|
+
assertEntityId(entityId);
|
|
159
|
+
const prefix = getDocumentPrefix(type);
|
|
160
|
+
assertDocumentLocale(type as SupportedCollaborativeDocumentType, locale);
|
|
161
|
+
return `${prefix}:${entityId}:${locale}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parse and validate a canonical resident document name.
|
|
166
|
+
*
|
|
167
|
+
* @example
|
|
168
|
+
* parseDocumentName(`post:${entityId}:ko`)
|
|
169
|
+
*
|
|
170
|
+
* @throws Error if document name format is invalid
|
|
171
|
+
*/
|
|
172
|
+
export function parseDocumentName(documentName: string): ParsedDocument {
|
|
173
|
+
const segments = documentName.split(":");
|
|
174
|
+
if (segments.length !== 3) {
|
|
175
|
+
throw new Error(`Invalid document name format: ${documentName}`);
|
|
176
|
+
}
|
|
177
|
+
const [prefix, entityId, locale] = segments;
|
|
178
|
+
const type = documentTypesByPrefix.get(prefix!);
|
|
179
|
+
if (type === undefined || !entityId || !locale) {
|
|
180
|
+
throw new Error(`Invalid document name format: ${documentName}`);
|
|
181
|
+
}
|
|
182
|
+
assertEntityId(entityId);
|
|
183
|
+
assertDocumentLocale(type, locale);
|
|
184
|
+
return { type, entityId, locale };
|
|
185
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import * as Y from "yjs";
|
|
2
|
+
|
|
3
|
+
export const EMAIL_LAYOUT_CONTEXT_MAP_NAME = "email-layout-context";
|
|
4
|
+
export const EMAIL_LAYOUT_HTML_TEXT_NAME = "html-content";
|
|
5
|
+
export const EMAIL_LAYOUT_UNITS_ARRAY_NAME = "email-layout-units";
|
|
6
|
+
export const EMAIL_LAYOUT_LOCALE_VALUES_MAP_NAME = "email-layout-locale-values";
|
|
7
|
+
|
|
8
|
+
export type EmailLayoutUnitKind = "text" | "attribute";
|
|
9
|
+
|
|
10
|
+
export interface EmailLayoutUnit {
|
|
11
|
+
handle: string;
|
|
12
|
+
kind: EmailLayoutUnitKind;
|
|
13
|
+
element: string;
|
|
14
|
+
attribute: string;
|
|
15
|
+
order: number;
|
|
16
|
+
sourceValue: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface MaterializedEmailLayoutUnit extends EmailLayoutUnit {
|
|
20
|
+
value: string;
|
|
21
|
+
localeValuePresent: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface EmailLayoutCanonicalRoomInput {
|
|
25
|
+
sourceLocale: string;
|
|
26
|
+
locale: string;
|
|
27
|
+
localeExists: boolean;
|
|
28
|
+
contentHtml: string;
|
|
29
|
+
units?: readonly EmailLayoutUnit[];
|
|
30
|
+
localeValues?: Readonly<Record<string, string>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function emailLayoutLocaleValuesMap(document: Y.Doc): Y.Map<string> {
|
|
34
|
+
return document.getMap<string>(EMAIL_LAYOUT_LOCALE_VALUES_MAP_NAME);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function hydrateEmailLayoutCanonicalRoom(
|
|
38
|
+
input: EmailLayoutCanonicalRoomInput,
|
|
39
|
+
): Y.Doc {
|
|
40
|
+
const document = new Y.Doc();
|
|
41
|
+
document.transact(() => {
|
|
42
|
+
const context = document.getMap<string | boolean>(
|
|
43
|
+
EMAIL_LAYOUT_CONTEXT_MAP_NAME,
|
|
44
|
+
);
|
|
45
|
+
context.set("sourceLocale", input.sourceLocale);
|
|
46
|
+
context.set("locale", input.locale);
|
|
47
|
+
context.set("localeExists", input.localeExists);
|
|
48
|
+
if (input.locale === input.sourceLocale) {
|
|
49
|
+
if (input.contentHtml.length > 0) {
|
|
50
|
+
document
|
|
51
|
+
.getText(EMAIL_LAYOUT_HTML_TEXT_NAME)
|
|
52
|
+
.insert(0, input.contentHtml);
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const units = [...(input.units ?? [])].sort(
|
|
57
|
+
(left, right) => left.order - right.order,
|
|
58
|
+
);
|
|
59
|
+
assertEmailLayoutUnits(units);
|
|
60
|
+
document
|
|
61
|
+
.getArray<EmailLayoutUnit>(EMAIL_LAYOUT_UNITS_ARRAY_NAME)
|
|
62
|
+
.insert(0, units);
|
|
63
|
+
const values = emailLayoutLocaleValuesMap(document);
|
|
64
|
+
for (const [handle, value] of Object.entries(input.localeValues ?? {})) {
|
|
65
|
+
if (units.some((unit) => unit.handle === handle)) {
|
|
66
|
+
values.set(handle, value);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
return document;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function materializeEmailLayoutUnits(
|
|
74
|
+
document: Y.Doc,
|
|
75
|
+
): MaterializedEmailLayoutUnit[] {
|
|
76
|
+
const values = emailLayoutLocaleValuesMap(document);
|
|
77
|
+
return document
|
|
78
|
+
.getArray<EmailLayoutUnit>(EMAIL_LAYOUT_UNITS_ARRAY_NAME)
|
|
79
|
+
.toArray()
|
|
80
|
+
.sort((left, right) => left.order - right.order)
|
|
81
|
+
.map((unit) => ({
|
|
82
|
+
...unit,
|
|
83
|
+
value: values.has(unit.handle)
|
|
84
|
+
? (values.get(unit.handle) ?? "")
|
|
85
|
+
: unit.sourceValue,
|
|
86
|
+
localeValuePresent: values.has(unit.handle),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function setEmailLayoutLocaleValue(
|
|
91
|
+
document: Y.Doc,
|
|
92
|
+
handle: string,
|
|
93
|
+
value: string,
|
|
94
|
+
): void {
|
|
95
|
+
requireEmailLayoutHandle(document, handle);
|
|
96
|
+
emailLayoutLocaleValuesMap(document).set(handle, value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function unsetEmailLayoutLocaleValue(
|
|
100
|
+
document: Y.Doc,
|
|
101
|
+
handle: string,
|
|
102
|
+
): void {
|
|
103
|
+
requireEmailLayoutHandle(document, handle);
|
|
104
|
+
emailLayoutLocaleValuesMap(document).delete(handle);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function extractEmailLayoutLocaleValues(
|
|
108
|
+
document: Y.Doc,
|
|
109
|
+
): Record<string, string> {
|
|
110
|
+
const allowed = new Set(
|
|
111
|
+
document
|
|
112
|
+
.getArray<EmailLayoutUnit>(EMAIL_LAYOUT_UNITS_ARRAY_NAME)
|
|
113
|
+
.toArray()
|
|
114
|
+
.map((unit) => unit.handle),
|
|
115
|
+
);
|
|
116
|
+
const output: Record<string, string> = {};
|
|
117
|
+
for (const [handle, value] of emailLayoutLocaleValuesMap(document)) {
|
|
118
|
+
if (!allowed.has(handle)) {
|
|
119
|
+
throw new Error(`Unknown Email Layout unit handle: ${handle}`);
|
|
120
|
+
}
|
|
121
|
+
output[handle] = value;
|
|
122
|
+
}
|
|
123
|
+
return output;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function requireEmailLayoutHandle(document: Y.Doc, handle: string): void {
|
|
127
|
+
if (
|
|
128
|
+
!document
|
|
129
|
+
.getArray<EmailLayoutUnit>(EMAIL_LAYOUT_UNITS_ARRAY_NAME)
|
|
130
|
+
.toArray()
|
|
131
|
+
.some((unit) => unit.handle === handle)
|
|
132
|
+
) {
|
|
133
|
+
throw new Error(`Unknown Email Layout unit handle: ${handle}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function assertEmailLayoutUnits(units: readonly EmailLayoutUnit[]): void {
|
|
138
|
+
const handles = new Set<string>();
|
|
139
|
+
for (const unit of units) {
|
|
140
|
+
if (
|
|
141
|
+
!unit.handle ||
|
|
142
|
+
handles.has(unit.handle) ||
|
|
143
|
+
!Number.isSafeInteger(unit.order) ||
|
|
144
|
+
unit.order < 0
|
|
145
|
+
) {
|
|
146
|
+
throw new Error("Invalid Email Layout unit catalog");
|
|
147
|
+
}
|
|
148
|
+
handles.add(unit.handle);
|
|
149
|
+
}
|
|
150
|
+
}
|