@sequoialabs/payload-plugin-reversia 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 +21 -0
- package/README.md +84 -0
- package/dist/collections/sync-pending.d.ts +2 -0
- package/dist/collections/sync-pending.js +27 -0
- package/dist/endpoints/confirm-resources-sync.d.ts +3 -0
- package/dist/endpoints/confirm-resources-sync.js +31 -0
- package/dist/endpoints/resource.d.ts +3 -0
- package/dist/endpoints/resource.js +82 -0
- package/dist/endpoints/resources-definition.d.ts +3 -0
- package/dist/endpoints/resources-definition.js +54 -0
- package/dist/endpoints/resources-insert.d.ts +3 -0
- package/dist/endpoints/resources-insert.js +174 -0
- package/dist/endpoints/resources-sync.d.ts +3 -0
- package/dist/endpoints/resources-sync.js +59 -0
- package/dist/endpoints/resources.d.ts +3 -0
- package/dist/endpoints/resources.js +107 -0
- package/dist/endpoints/settings.d.ts +3 -0
- package/dist/endpoints/settings.js +34 -0
- package/dist/hooks/after-change.d.ts +11 -0
- package/dist/hooks/after-change.js +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +82 -0
- package/dist/types.d.ts +238 -0
- package/dist/types.js +12 -0
- package/dist/utils/auth.d.ts +3 -0
- package/dist/utils/auth.js +27 -0
- package/dist/utils/cursor.d.ts +7 -0
- package/dist/utils/cursor.js +30 -0
- package/dist/utils/fields.d.ts +58 -0
- package/dist/utils/fields.js +715 -0
- package/dist/utils/json-extract.d.ts +37 -0
- package/dist/utils/json-extract.js +186 -0
- package/dist/utils/labels.d.ts +12 -0
- package/dist/utils/labels.js +27 -0
- package/dist/utils/path-resolver.d.ts +53 -0
- package/dist/utils/path-resolver.js +157 -0
- package/dist/utils/payload-helpers.d.ts +16 -0
- package/dist/utils/payload-helpers.js +45 -0
- package/package.json +57 -0
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
import { ReversiaFieldType } from '../types.js';
|
|
2
|
+
import { applyByKeys, compileKeyMatcher, DEFAULT_RICHTEXT_KEYS, extractByKeys, } from './json-extract.js';
|
|
3
|
+
import { resolveStaticLabel } from './labels.js';
|
|
4
|
+
import { applyTranslationsToContainer, joinPointers, resolveLeafLocations, } from './path-resolver.js';
|
|
5
|
+
/* -------------------------------------------------------------------------- */
|
|
6
|
+
/* Schema introspection */
|
|
7
|
+
/* -------------------------------------------------------------------------- */
|
|
8
|
+
function getFieldLabel(field) {
|
|
9
|
+
if (!('name' in field)) {
|
|
10
|
+
return '';
|
|
11
|
+
}
|
|
12
|
+
const name = String(field.name ?? '');
|
|
13
|
+
if ('label' in field && field.label) {
|
|
14
|
+
return resolveStaticLabel(field.label, name);
|
|
15
|
+
}
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
function getReversiaCustom(field) {
|
|
19
|
+
if ('custom' in field && field.custom && typeof field.custom === 'object') {
|
|
20
|
+
const custom = field.custom;
|
|
21
|
+
if (custom.reversia && typeof custom.reversia === 'object') {
|
|
22
|
+
return custom.reversia;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
function isLocalized(field) {
|
|
28
|
+
return 'localized' in field && field.localized === true;
|
|
29
|
+
}
|
|
30
|
+
function isNamed(field) {
|
|
31
|
+
return 'name' in field && typeof field.name === 'string' && field.name.length > 0;
|
|
32
|
+
}
|
|
33
|
+
function isTabsField(field) {
|
|
34
|
+
return field.type === 'tabs' && 'tabs' in field;
|
|
35
|
+
}
|
|
36
|
+
function isBlocksField(field) {
|
|
37
|
+
return field.type === 'blocks' && 'blocks' in field;
|
|
38
|
+
}
|
|
39
|
+
function isArrayField(field) {
|
|
40
|
+
return field.type === 'array' && 'fields' in field;
|
|
41
|
+
}
|
|
42
|
+
function isGroupLikeField(field) {
|
|
43
|
+
return ((field.type === 'group' || field.type === 'collapsible' || field.type === 'row') &&
|
|
44
|
+
'fields' in field &&
|
|
45
|
+
Array.isArray(field.fields));
|
|
46
|
+
}
|
|
47
|
+
const SCALAR_PAYLOAD_TYPES = new Set([
|
|
48
|
+
'text',
|
|
49
|
+
'textarea',
|
|
50
|
+
'email',
|
|
51
|
+
'code',
|
|
52
|
+
'date',
|
|
53
|
+
'number',
|
|
54
|
+
'select',
|
|
55
|
+
'checkbox',
|
|
56
|
+
'radio',
|
|
57
|
+
'point',
|
|
58
|
+
]);
|
|
59
|
+
function isScalarPayloadType(type) {
|
|
60
|
+
return SCALAR_PAYLOAD_TYPES.has(type);
|
|
61
|
+
}
|
|
62
|
+
/* -------------------------------------------------------------------------- */
|
|
63
|
+
/* Discovery: top-level localized fields */
|
|
64
|
+
/* -------------------------------------------------------------------------- */
|
|
65
|
+
/**
|
|
66
|
+
* Collects one `LocalizedFieldInfo` per top-level field that is itself
|
|
67
|
+
* localized OR that contains at least one localized descendant.
|
|
68
|
+
*
|
|
69
|
+
* Top-level localized scalars become non-container entries. Everything else
|
|
70
|
+
* (richText, json, group, array, blocks, or any unnamed wrapper containing
|
|
71
|
+
* localized descendants) becomes a container with one or more `leaves`.
|
|
72
|
+
*/
|
|
73
|
+
export function findLocalizedFields(fields) {
|
|
74
|
+
const out = [];
|
|
75
|
+
for (const field of flattenTransparentWrappers(fields)) {
|
|
76
|
+
if (!isNamed(field)) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const info = describeTopLevelField(field);
|
|
80
|
+
if (info) {
|
|
81
|
+
out.push(info);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* `tabs` (when unnamed), `row`, `collapsible` and similar wrappers don't
|
|
88
|
+
* introduce a key in the document. Flatten them so their inner fields are
|
|
89
|
+
* treated as top-level for discovery.
|
|
90
|
+
*/
|
|
91
|
+
function flattenTransparentWrappers(fields) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const field of fields) {
|
|
94
|
+
if (isTabsField(field)) {
|
|
95
|
+
for (const tab of field.tabs) {
|
|
96
|
+
if ('name' in tab && typeof tab.name === 'string' && tab.name.length > 0) {
|
|
97
|
+
// A named tab IS a top-level container in its own right.
|
|
98
|
+
out.push({
|
|
99
|
+
name: tab.name,
|
|
100
|
+
type: 'group',
|
|
101
|
+
label: tab.label,
|
|
102
|
+
fields: tab.fields,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
out.push(...flattenTransparentWrappers(tab.fields));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!isNamed(field) && (field.type === 'row' || field.type === 'collapsible')) {
|
|
112
|
+
if ('fields' in field && Array.isArray(field.fields)) {
|
|
113
|
+
out.push(...flattenTransparentWrappers(field.fields));
|
|
114
|
+
}
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
out.push(field);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
function describeTopLevelField(field) {
|
|
122
|
+
const name = field.name;
|
|
123
|
+
const label = getFieldLabel(field);
|
|
124
|
+
const reversia = getReversiaCustom(field);
|
|
125
|
+
const payloadFieldType = field.type;
|
|
126
|
+
// Top-level scalar that is itself localized → non-container, value shipped
|
|
127
|
+
// as a primitive.
|
|
128
|
+
if (isLocalized(field) && isScalarPayloadType(payloadFieldType)) {
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
label,
|
|
132
|
+
payloadFieldType,
|
|
133
|
+
isContainer: false,
|
|
134
|
+
reversia,
|
|
135
|
+
leaves: [
|
|
136
|
+
{
|
|
137
|
+
segments: [],
|
|
138
|
+
kind: 'scalar',
|
|
139
|
+
payloadFieldType,
|
|
140
|
+
reversia,
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// Top-level richText / json (localized or not localized but containing
|
|
146
|
+
// sub-extractions — currently we only look at the field itself for these)
|
|
147
|
+
// → container with one json leaf.
|
|
148
|
+
if (payloadFieldType === 'richText' || payloadFieldType === 'json') {
|
|
149
|
+
if (!isLocalized(field)) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
name,
|
|
154
|
+
label,
|
|
155
|
+
payloadFieldType,
|
|
156
|
+
isContainer: true,
|
|
157
|
+
reversia,
|
|
158
|
+
leaves: [
|
|
159
|
+
{
|
|
160
|
+
segments: [],
|
|
161
|
+
kind: 'json',
|
|
162
|
+
payloadFieldType,
|
|
163
|
+
reversia,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
// Structured wrapper: group / array / blocks. Walk inside to collect
|
|
169
|
+
// localized descendant leaves; emit a container only if any were found.
|
|
170
|
+
const innerLeaves = [];
|
|
171
|
+
if (isGroupLikeField(field)) {
|
|
172
|
+
collectLeaves(field.fields, [], innerLeaves);
|
|
173
|
+
}
|
|
174
|
+
else if (isArrayField(field)) {
|
|
175
|
+
collectLeaves(field.fields, [{ kind: 'iterate' }], innerLeaves);
|
|
176
|
+
}
|
|
177
|
+
else if (isBlocksField(field)) {
|
|
178
|
+
for (const block of field.blocks) {
|
|
179
|
+
collectLeaves(block.fields, [{ kind: 'iterateBlock', blockSlug: block.slug }], innerLeaves);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
if (innerLeaves.length === 0) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
name,
|
|
190
|
+
label,
|
|
191
|
+
payloadFieldType,
|
|
192
|
+
isContainer: true,
|
|
193
|
+
reversia,
|
|
194
|
+
leaves: innerLeaves,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function collectLeaves(fields, parentSegments, out) {
|
|
198
|
+
for (const field of fields) {
|
|
199
|
+
if (!isNamed(field)) {
|
|
200
|
+
if (isTabsField(field)) {
|
|
201
|
+
for (const tab of field.tabs) {
|
|
202
|
+
const tabSegments = 'name' in tab && typeof tab.name === 'string' && tab.name.length > 0
|
|
203
|
+
? [...parentSegments, { kind: 'key', name: tab.name }]
|
|
204
|
+
: parentSegments;
|
|
205
|
+
collectLeaves(tab.fields, tabSegments, out);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if ((field.type === 'row' || field.type === 'collapsible') &&
|
|
209
|
+
'fields' in field &&
|
|
210
|
+
Array.isArray(field.fields)) {
|
|
211
|
+
collectLeaves(field.fields, parentSegments, out);
|
|
212
|
+
}
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const segments = [...parentSegments, { kind: 'key', name: field.name }];
|
|
216
|
+
const reversia = getReversiaCustom(field);
|
|
217
|
+
const localized = isLocalized(field);
|
|
218
|
+
if (localized && isScalarPayloadType(field.type)) {
|
|
219
|
+
out.push({
|
|
220
|
+
segments,
|
|
221
|
+
kind: 'scalar',
|
|
222
|
+
payloadFieldType: field.type,
|
|
223
|
+
reversia,
|
|
224
|
+
});
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (localized && (field.type === 'richText' || field.type === 'json')) {
|
|
228
|
+
out.push({
|
|
229
|
+
segments,
|
|
230
|
+
kind: 'json',
|
|
231
|
+
payloadFieldType: field.type,
|
|
232
|
+
reversia,
|
|
233
|
+
});
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (isGroupLikeField(field)) {
|
|
237
|
+
collectLeaves(field.fields, segments, out);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (isArrayField(field)) {
|
|
241
|
+
collectLeaves(field.fields, [...segments, { kind: 'iterate' }], out);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (isBlocksField(field)) {
|
|
245
|
+
for (const block of field.blocks) {
|
|
246
|
+
collectLeaves(block.fields, [...segments, { kind: 'iterateBlock', blockSlug: block.slug }], out);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/* -------------------------------------------------------------------------- */
|
|
252
|
+
/* Configuration (resources-definition) */
|
|
253
|
+
/* -------------------------------------------------------------------------- */
|
|
254
|
+
export function resolveContentType(field) {
|
|
255
|
+
if (field.reversia?.type) {
|
|
256
|
+
return field.reversia.type;
|
|
257
|
+
}
|
|
258
|
+
if (field.isContainer) {
|
|
259
|
+
return ReversiaFieldType.JSON;
|
|
260
|
+
}
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
function resolveBehavior(field) {
|
|
264
|
+
return field.reversia?.behavior;
|
|
265
|
+
}
|
|
266
|
+
function resolveAsLabel(field, hasLabelAlready) {
|
|
267
|
+
if (field.reversia?.asLabel !== undefined) {
|
|
268
|
+
return field.reversia.asLabel;
|
|
269
|
+
}
|
|
270
|
+
if (!hasLabelAlready && !field.isContainer && (field.name === 'title' || field.name === 'name')) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
function humanize(segment) {
|
|
276
|
+
const spaced = segment
|
|
277
|
+
.replace(/[_-]+/g, ' ')
|
|
278
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
279
|
+
.replace(/\s+/g, ' ')
|
|
280
|
+
.trim();
|
|
281
|
+
if (spaced.length === 0) {
|
|
282
|
+
return segment;
|
|
283
|
+
}
|
|
284
|
+
return spaced
|
|
285
|
+
.split(' ')
|
|
286
|
+
.map((word) => (word.length > 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
|
287
|
+
.join(' ');
|
|
288
|
+
}
|
|
289
|
+
export function buildTranslatableConfiguration(localizedFields) {
|
|
290
|
+
const config = {};
|
|
291
|
+
let hasLabelField = false;
|
|
292
|
+
for (const field of localizedFields) {
|
|
293
|
+
const fieldConfig = {
|
|
294
|
+
label: field.label || humanize(field.name),
|
|
295
|
+
};
|
|
296
|
+
const isLabel = resolveAsLabel(field, hasLabelField);
|
|
297
|
+
if (isLabel) {
|
|
298
|
+
fieldConfig.asLabel = true;
|
|
299
|
+
hasLabelField = true;
|
|
300
|
+
}
|
|
301
|
+
const contentType = resolveContentType(field);
|
|
302
|
+
if (contentType) {
|
|
303
|
+
fieldConfig.type = contentType;
|
|
304
|
+
}
|
|
305
|
+
const behavior = resolveBehavior(field);
|
|
306
|
+
if (behavior) {
|
|
307
|
+
fieldConfig.behavior = behavior;
|
|
308
|
+
}
|
|
309
|
+
if (field.reversia?.selected === false) {
|
|
310
|
+
fieldConfig.selected = false;
|
|
311
|
+
}
|
|
312
|
+
config[field.name] = fieldConfig;
|
|
313
|
+
}
|
|
314
|
+
return config;
|
|
315
|
+
}
|
|
316
|
+
// Back-compat re-export for callers that only need the content-type resolver.
|
|
317
|
+
export const getContentType = resolveContentType;
|
|
318
|
+
/**
|
|
319
|
+
* Produces zero or one serialised entry for a top-level localized field.
|
|
320
|
+
*
|
|
321
|
+
* - Scalars: pass-through primitive value (or `extract` result).
|
|
322
|
+
* - Containers: walk every localized leaf, build a JSON-pointer map of only
|
|
323
|
+
* the translatable atoms (so we never ship non-localized siblings or fields
|
|
324
|
+
* the user opted out of), JSON.stringify and emit one entry.
|
|
325
|
+
*
|
|
326
|
+
* Returns `undefined` when there is nothing translatable to send (empty
|
|
327
|
+
* source value, no matching leaves, or filtered out).
|
|
328
|
+
*/
|
|
329
|
+
export function serializeField(field, doc) {
|
|
330
|
+
const value = doc?.[field.name];
|
|
331
|
+
if (value === undefined || value === null) {
|
|
332
|
+
return undefined;
|
|
333
|
+
}
|
|
334
|
+
const contentType = resolveContentType(field);
|
|
335
|
+
// Top-level extract escape hatch wins.
|
|
336
|
+
if (field.reversia?.extract) {
|
|
337
|
+
if (!field.reversia.apply) {
|
|
338
|
+
throw new Error(`reversia.extract on field "${field.name}" requires a matching reversia.apply.`);
|
|
339
|
+
}
|
|
340
|
+
const extracted = field.reversia.extract(value);
|
|
341
|
+
if (extracted === undefined || extracted === null || extracted.length === 0) {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
return { name: field.name, value: extracted, contentType };
|
|
345
|
+
}
|
|
346
|
+
if (!field.isContainer) {
|
|
347
|
+
if (typeof value === 'object') {
|
|
348
|
+
// Defensive: top-level scalar shouldn't have an object value but if it
|
|
349
|
+
// does we ship a JSON.stringify rather than an opaque [object Object].
|
|
350
|
+
return { name: field.name, value: JSON.stringify(value), contentType };
|
|
351
|
+
}
|
|
352
|
+
return { name: field.name, value: value, contentType };
|
|
353
|
+
}
|
|
354
|
+
const map = extractContainerAtoms(field, value);
|
|
355
|
+
if (Object.keys(map).length === 0) {
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
return { name: field.name, value: JSON.stringify(map), contentType };
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Walks every localized leaf inside a container value and aggregates atomic
|
|
362
|
+
* translatable strings into a single `{ pointer: value }` map. Non-localized
|
|
363
|
+
* sibling fields and structural keys are pre-filtered — Reversia never sees
|
|
364
|
+
* anything we don't ask it to translate.
|
|
365
|
+
*/
|
|
366
|
+
function extractContainerAtoms(field, containerValue) {
|
|
367
|
+
const map = {};
|
|
368
|
+
for (const leaf of field.leaves) {
|
|
369
|
+
const locations = resolveLeafLocations(containerValue, leaf.segments);
|
|
370
|
+
for (const { pointer, value } of locations) {
|
|
371
|
+
if (value === undefined || value === null) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (leaf.kind === 'scalar') {
|
|
375
|
+
if (typeof value === 'string') {
|
|
376
|
+
if (value.length > 0) {
|
|
377
|
+
map[pointer === '' ? '' : pointer] = value;
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
382
|
+
map[pointer === '' ? '' : pointer] = String(value);
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
// leaf.kind === 'json' — extract sub-leaves by translatableKeys
|
|
387
|
+
const subKeys = resolveTranslatableKeys(leaf);
|
|
388
|
+
if (typeof value !== 'object') {
|
|
389
|
+
// Unexpected primitive in a richText/json leaf — ship as-is.
|
|
390
|
+
if (typeof value === 'string' && value.length > 0) {
|
|
391
|
+
map[pointer === '' ? '' : pointer] = value;
|
|
392
|
+
}
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
if (!subKeys) {
|
|
396
|
+
// json leaf with no keys configured: ship the whole serialised value
|
|
397
|
+
// at the leaf's pointer so it round-trips intact.
|
|
398
|
+
map[pointer === '' ? '' : pointer] = JSON.stringify(value);
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
const matcher = compileKeyMatcher(subKeys);
|
|
402
|
+
const sub = extractByKeys(value, matcher);
|
|
403
|
+
for (const [subPointer, subValue] of Object.entries(sub)) {
|
|
404
|
+
map[joinPointers(pointer, subPointer)] = subValue;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return map;
|
|
409
|
+
}
|
|
410
|
+
function resolveTranslatableKeys(leaf) {
|
|
411
|
+
const explicit = leaf.reversia?.translatableKeys;
|
|
412
|
+
if (explicit && explicit.length > 0) {
|
|
413
|
+
return explicit;
|
|
414
|
+
}
|
|
415
|
+
if (leaf.payloadFieldType === 'richText') {
|
|
416
|
+
return [...DEFAULT_RICHTEXT_KEYS];
|
|
417
|
+
}
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
/* -------------------------------------------------------------------------- */
|
|
421
|
+
/* Deserialization */
|
|
422
|
+
/* -------------------------------------------------------------------------- */
|
|
423
|
+
/**
|
|
424
|
+
* Reverses `serializeField` for one top-level field.
|
|
425
|
+
*
|
|
426
|
+
* - Scalars: return `translatedRaw` as-is (after `apply` if defined).
|
|
427
|
+
* - Containers: parse the JSON pointer map; deep-clone the source-locale
|
|
428
|
+
* container value as the base; overlay each translated leaf at its pointer.
|
|
429
|
+
* For `json` sub-leaves the pointer is split between the leaf location and
|
|
430
|
+
* the sub-extraction pointer — `applyByKeys` replays the standard richText
|
|
431
|
+
* path on the leaf's value before we re-attach it.
|
|
432
|
+
*
|
|
433
|
+
* The source-clone strategy guarantees required non-localized siblings (block
|
|
434
|
+
* structure, array item ids, sub-object scaffolding) are preserved when we
|
|
435
|
+
* write to Payload — same pattern as the PrestaShop module.
|
|
436
|
+
*/
|
|
437
|
+
export function deserializeFieldValue(field, sourceValue, translatedRaw) {
|
|
438
|
+
if (field.reversia?.apply) {
|
|
439
|
+
const asString = typeof translatedRaw === 'string' ? translatedRaw : JSON.stringify(translatedRaw);
|
|
440
|
+
return field.reversia.apply(sourceValue, asString);
|
|
441
|
+
}
|
|
442
|
+
if (!field.isContainer) {
|
|
443
|
+
return translatedRaw;
|
|
444
|
+
}
|
|
445
|
+
const translations = coerceTranslationMap(translatedRaw);
|
|
446
|
+
if (!translations) {
|
|
447
|
+
// Nothing usable from Reversia — fall back to source clone.
|
|
448
|
+
return sourceValue === undefined || sourceValue === null
|
|
449
|
+
? sourceValue
|
|
450
|
+
: structuredClone(sourceValue);
|
|
451
|
+
}
|
|
452
|
+
// Group translations by their target leaf so we can hand richText/json
|
|
453
|
+
// sub-pointers to applyByKeys, which already knows how to splice them into
|
|
454
|
+
// a Lexical/JSON tree.
|
|
455
|
+
const buckets = bucketTranslationsByLeaf(field, translations);
|
|
456
|
+
let working = sourceValue === undefined || sourceValue === null ? null : structuredClone(sourceValue);
|
|
457
|
+
for (const bucket of buckets) {
|
|
458
|
+
if (bucket.leaf.kind === 'scalar') {
|
|
459
|
+
// One pointer, one value — write each instance at its container pointer.
|
|
460
|
+
for (const [pointer, value] of Object.entries(bucket.entries)) {
|
|
461
|
+
working = writeRaw(working, pointer, value);
|
|
462
|
+
}
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
// json leaf: locate every leaf instance, apply sub-pointer translations
|
|
466
|
+
// onto it, write the rebuilt subtree back.
|
|
467
|
+
const locations = resolveLeafLocations(working, bucket.leaf.segments);
|
|
468
|
+
for (const { pointer: leafPointer } of locations) {
|
|
469
|
+
const subTranslations = {};
|
|
470
|
+
for (const [pointer, value] of Object.entries(bucket.entries)) {
|
|
471
|
+
if (pointerStartsWith(pointer, leafPointer)) {
|
|
472
|
+
const sub = pointer.slice(leafPointer.length);
|
|
473
|
+
subTranslations[sub === '' ? '' : sub] = value;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (Object.keys(subTranslations).length === 0) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const leafSource = readAtPointer(working, leafPointer);
|
|
480
|
+
let rebuilt;
|
|
481
|
+
if (subTranslations[''] !== undefined && Object.keys(subTranslations).length === 1) {
|
|
482
|
+
// Whole-value json (no translatableKeys): try to JSON.parse round-trip.
|
|
483
|
+
const raw = subTranslations[''];
|
|
484
|
+
try {
|
|
485
|
+
rebuilt = JSON.parse(raw);
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
rebuilt = raw;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
else if (leafSource && typeof leafSource === 'object') {
|
|
492
|
+
rebuilt = applyByKeys(leafSource, subTranslations);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
// No source structure — best-effort tree from sub-pointers.
|
|
496
|
+
rebuilt = applyTranslationsToContainer(leafSource, subTranslations);
|
|
497
|
+
}
|
|
498
|
+
working = writeRaw(working, leafPointer, rebuilt);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return working;
|
|
502
|
+
}
|
|
503
|
+
function bucketTranslationsByLeaf(field, translations) {
|
|
504
|
+
// Order leaves by descending segment length so deeper / more specific leaves
|
|
505
|
+
// claim pointers before broader catch-alls. (Stable for the common
|
|
506
|
+
// shallow-only schema.)
|
|
507
|
+
const ordered = [...field.leaves].sort((a, b) => b.segments.length - a.segments.length);
|
|
508
|
+
const remaining = new Map(Object.entries(translations));
|
|
509
|
+
const buckets = [];
|
|
510
|
+
for (const leaf of ordered) {
|
|
511
|
+
const entries = {};
|
|
512
|
+
const leafPath = leafSegmentsAsPointerPrefix(leaf);
|
|
513
|
+
for (const [pointer, value] of remaining) {
|
|
514
|
+
if (leaf.kind === 'scalar') {
|
|
515
|
+
if (matchesScalarLeaf(pointer, leaf)) {
|
|
516
|
+
entries[pointer] = value;
|
|
517
|
+
remaining.delete(pointer);
|
|
518
|
+
}
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
if (leafPath === null) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
if (leafPath === '' || pointerStartsWith(pointer, leafPath)) {
|
|
525
|
+
entries[pointer] = value;
|
|
526
|
+
remaining.delete(pointer);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (Object.keys(entries).length > 0) {
|
|
530
|
+
buckets.push({ leaf, entries });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return buckets;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* For a scalar leaf, the incoming pointer must address exactly the leaf's
|
|
537
|
+
* structural shape: every `key` segment becomes a literal pointer segment;
|
|
538
|
+
* every `iterate` / `iterateBlock` segment matches any one numeric segment.
|
|
539
|
+
*/
|
|
540
|
+
function matchesScalarLeaf(pointer, leaf) {
|
|
541
|
+
const parts = pointer === '' ? [] : pointer.slice(1).split('/');
|
|
542
|
+
let p = 0;
|
|
543
|
+
for (const seg of leaf.segments) {
|
|
544
|
+
if (p >= parts.length) {
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
if (seg.kind === 'key') {
|
|
548
|
+
if (parts[p] !== seg.name) {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
p++;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (!/^\d+$/.test(parts[p])) {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
p++;
|
|
558
|
+
}
|
|
559
|
+
return p === parts.length;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Returns the pointer prefix for a json leaf when its location is fully
|
|
563
|
+
* key-only (so we can do a fast `startsWith` match). For leaves under
|
|
564
|
+
* iterate / iterateBlock segments, returns `null` — we fall through to
|
|
565
|
+
* per-instance resolution.
|
|
566
|
+
*/
|
|
567
|
+
function leafSegmentsAsPointerPrefix(leaf) {
|
|
568
|
+
const parts = [];
|
|
569
|
+
for (const seg of leaf.segments) {
|
|
570
|
+
if (seg.kind === 'key') {
|
|
571
|
+
parts.push(seg.name);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
return null;
|
|
575
|
+
}
|
|
576
|
+
return parts.length === 0 ? '' : `/${parts.join('/')}`;
|
|
577
|
+
}
|
|
578
|
+
function pointerStartsWith(pointer, prefix) {
|
|
579
|
+
if (prefix === '') {
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
if (!pointer.startsWith(prefix)) {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
// Ensure prefix lands on a segment boundary.
|
|
586
|
+
return pointer.length === prefix.length || pointer[prefix.length] === '/';
|
|
587
|
+
}
|
|
588
|
+
function readAtPointer(target, pointer) {
|
|
589
|
+
if (pointer === '') {
|
|
590
|
+
return target;
|
|
591
|
+
}
|
|
592
|
+
const parts = pointer.slice(1).split('/').map(decodePointerSegment);
|
|
593
|
+
let current = target;
|
|
594
|
+
for (const part of parts) {
|
|
595
|
+
if (current === null || current === undefined) {
|
|
596
|
+
return undefined;
|
|
597
|
+
}
|
|
598
|
+
if (Array.isArray(current)) {
|
|
599
|
+
const idx = Number(part);
|
|
600
|
+
if (!Number.isInteger(idx)) {
|
|
601
|
+
return undefined;
|
|
602
|
+
}
|
|
603
|
+
current = current[idx];
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (typeof current !== 'object') {
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
current = current[part];
|
|
610
|
+
}
|
|
611
|
+
return current;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Internal write — like `writeAtPointer` but accepts arbitrary value types
|
|
615
|
+
* (not just strings) and returns the (possibly replaced) root.
|
|
616
|
+
*/
|
|
617
|
+
function writeRaw(target, pointer, value) {
|
|
618
|
+
if (pointer === '') {
|
|
619
|
+
return value;
|
|
620
|
+
}
|
|
621
|
+
const parts = pointer.slice(1).split('/').map(decodePointerSegment);
|
|
622
|
+
let root = target;
|
|
623
|
+
if (root === null || root === undefined) {
|
|
624
|
+
root = /^\d+$/.test(parts[0]) ? [] : {};
|
|
625
|
+
}
|
|
626
|
+
let current = root;
|
|
627
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
628
|
+
const key = parts[i];
|
|
629
|
+
const nextKey = parts[i + 1];
|
|
630
|
+
const nextIsIndex = /^\d+$/.test(nextKey);
|
|
631
|
+
if (Array.isArray(current)) {
|
|
632
|
+
const idx = Number(key);
|
|
633
|
+
if (current[idx] === null || current[idx] === undefined) {
|
|
634
|
+
current[idx] = nextIsIndex ? [] : {};
|
|
635
|
+
}
|
|
636
|
+
current = current[idx];
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
if (current && typeof current === 'object') {
|
|
640
|
+
const obj = current;
|
|
641
|
+
if (obj[key] === null || obj[key] === undefined) {
|
|
642
|
+
obj[key] = nextIsIndex ? [] : {};
|
|
643
|
+
}
|
|
644
|
+
current = obj[key];
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const last = parts[parts.length - 1];
|
|
648
|
+
if (Array.isArray(current)) {
|
|
649
|
+
current[Number(last)] = value;
|
|
650
|
+
}
|
|
651
|
+
else if (current && typeof current === 'object') {
|
|
652
|
+
current[last] = value;
|
|
653
|
+
}
|
|
654
|
+
return root;
|
|
655
|
+
}
|
|
656
|
+
function decodePointerSegment(seg) {
|
|
657
|
+
return seg.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
658
|
+
}
|
|
659
|
+
function coerceTranslationMap(raw) {
|
|
660
|
+
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
661
|
+
const out = {};
|
|
662
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
663
|
+
if (typeof v === 'string') {
|
|
664
|
+
out[k] = v;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
668
|
+
}
|
|
669
|
+
if (typeof raw === 'string') {
|
|
670
|
+
try {
|
|
671
|
+
const parsed = JSON.parse(raw);
|
|
672
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
673
|
+
const out = {};
|
|
674
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
675
|
+
if (typeof v === 'string') {
|
|
676
|
+
out[k] = v;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch {
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
/* -------------------------------------------------------------------------- */
|
|
689
|
+
/* Source-clone helpers (PrestaShop-style: clone source, overlay translations) */
|
|
690
|
+
/* -------------------------------------------------------------------------- */
|
|
691
|
+
/**
|
|
692
|
+
* Builds the base `updateData` for a translation insertion by deep-cloning
|
|
693
|
+
* each top-level localized field's value out of the source-locale doc. This
|
|
694
|
+
* guarantees required nested siblings (block structure, ids, non-localized
|
|
695
|
+
* subfields) are present in the update payload, even when Reversia only sent
|
|
696
|
+
* a subset of leaves.
|
|
697
|
+
*/
|
|
698
|
+
export function cloneLocalizedContainersFromSource(sourceDoc, fields) {
|
|
699
|
+
const out = {};
|
|
700
|
+
if (!sourceDoc || typeof sourceDoc !== 'object') {
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
const src = sourceDoc;
|
|
704
|
+
for (const field of fields) {
|
|
705
|
+
if (!(field.name in src)) {
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
const value = src[field.name];
|
|
709
|
+
if (value === undefined || value === null) {
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
out[field.name] = field.isContainer ? structuredClone(value) : value;
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
}
|