@elytracms/studio 0.0.102 → 0.0.104
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/convex/ingest.test.ts +251 -23
- package/convex/ingest.ts +77 -19
- package/dist/components/ai/translate-document-dialog.d.ts +43 -0
- package/dist/components/ai/translate-document-dialog.js +104 -36
- package/dist/components/ai/translate-document-dialog.js.map +1 -1
- package/dist/components/ai/translate-skipped.d.ts +19 -3
- package/dist/components/ai/translate-skipped.js +33 -7
- package/dist/components/ai/translate-skipped.js.map +1 -1
- package/dist/components/builder/canvas-theme-notice.js +4 -1
- package/dist/components/builder/canvas-theme-notice.js.map +1 -1
- package/dist/components/components/components-index.d.ts +0 -7
- package/dist/components/components/components-index.js +9 -2
- package/dist/components/components/components-index.js.map +1 -1
- package/dist/lib/ai/translate-apply.d.ts +69 -0
- package/dist/lib/ai/translate-apply.js +36 -0
- package/dist/lib/ai/translate-apply.js.map +1 -0
- package/dist/lib/ai/translate-document.d.ts +95 -7
- package/dist/lib/ai/translate-document.js +195 -57
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +33 -2
- package/dist/lib/ai/writer-form.js +188 -27
- package/dist/lib/ai/writer-form.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +56 -10
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +41 -1
- package/dist/lib/i18n/messages.en.js +61 -10
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/package.json +3 -3
|
@@ -2,21 +2,30 @@
|
|
|
2
2
|
* `translateDocument` — the full-document AI translation engine (EC-332).
|
|
3
3
|
*
|
|
4
4
|
* ONE implementation behind two surfaces (the detail-editor "Translate document" header
|
|
5
|
-
* action + the chat tool). It walks a document's LOCALIZED fields,
|
|
6
|
-
*
|
|
7
|
-
* translates
|
|
8
|
-
* returns a NEW draft with the translations
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* action + the chat tool). It walks a document's LOCALIZED fields, plans the field ×
|
|
6
|
+
* locale values to write ({@link planTranslation} — the ONE pre-provider judge, EC-752),
|
|
7
|
+
* translates their units with bounded client-side concurrency over the EC-331 provider,
|
|
8
|
+
* and returns a NEW draft with the translations in. In the default mode (`fill-empty`)
|
|
9
|
+
* only values still **connected to base** are filled (EC-318 — a value with its own
|
|
10
|
+
* translation is never overwritten); `retranslate-all` (EC-752, the dialog's "Re-translate
|
|
11
|
+
* everything", behind a confirm that lists what it replaces) also REPLACES a value with its
|
|
12
|
+
* own translation, rebuilt from the base. The caller writes that draft through the
|
|
13
|
+
* operations layer with the user's session (provenance-tagged) — the engine itself has no
|
|
14
|
+
* data access, so the deterministic FakeProvider drives the whole flow.
|
|
11
15
|
*
|
|
12
|
-
* Unit-walk semantics
|
|
16
|
+
* Unit-walk semantics:
|
|
13
17
|
* - target locales = configured locales minus the document's base locale;
|
|
14
18
|
* - only LOCALIZED, writer-unlocked fields participate (a non-localized field is shared
|
|
15
19
|
* across locales — there is nothing per-locale to fill; EC-716: an `ai.exclude` or
|
|
16
20
|
* `form.readOnly` position, at any depth, is no unit — and no derived slug either);
|
|
17
|
-
* - a field/locale is translated only when it is CONNECTED (no per-locale
|
|
18
|
-
* the base value is non-empty — so writing the translation DIVERGES it,
|
|
19
|
-
* exactly what makes a re-run an idempotent no-op
|
|
21
|
+
* - `fill-empty`: a field/locale is translated only when it is CONNECTED (no per-locale
|
|
22
|
+
* key yet) and the base value is non-empty — so writing the translation DIVERGES it,
|
|
23
|
+
* which is exactly what makes a re-run an idempotent no-op. `retranslate-all`: also when
|
|
24
|
+
* it holds its own value — and then a field/locale is written only WHOLE, when every
|
|
25
|
+
* PLANNED unit of it landed (a dropped unit, a rejected block, a cancel or an unreadable
|
|
26
|
+
* position leaves the target's value as it was, reported); its locked content is kept
|
|
27
|
+
* (EC-738). A text position the base leaves empty is no unit and no part of that judgement
|
|
28
|
+
* (EC-759): the rebuilt value takes the base's state there, as it does for a number;
|
|
20
29
|
* - EVERY text position of the base value is a unit (EC-593, ratified 4e): the walk is
|
|
21
30
|
* core's ONE value-position traversal, so a `text` position is one unit and a
|
|
22
31
|
* `richText` / `blocks` sequence one unit per translatable block (structure preserved
|
|
@@ -28,10 +37,14 @@
|
|
|
28
37
|
* - the target bucket is the BASE value with its text translated — every other value
|
|
29
38
|
* (an image, a select, a number) carried from it, the displayed-value rule (2c);
|
|
30
39
|
* - whatever cannot be translated is REPORTED (`skipped`), never silently left: a
|
|
31
|
-
* position whose value is unreadable
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
40
|
+
* position whose value is unreadable (EC-752: text inside a value the traversal cannot
|
|
41
|
+
* read too), a unit the provider did not return, a rich-text block whose translation did
|
|
42
|
+
* not decode token-faithfully (fill-empty keeps the source's block in the fill;
|
|
43
|
+
* retranslate-all leaves the whole target value as it was);
|
|
44
|
+
* - SLUG fields are never model-translated: after the text fills land, each per-locale
|
|
45
|
+
* slug that still FOLLOWS the base is derived (EC-315 `autoDeriveSlug`) from the
|
|
46
|
+
* translated source — in both modes: a slug the editor set is never touched (EC-752 — a
|
|
47
|
+
* changed slug moves the page's address, and nothing redirects it).
|
|
35
48
|
*
|
|
36
49
|
* Batching (improves on the Sanity precedent's context-blind per-block calls): units are
|
|
37
50
|
* grouped per target locale and chunked into batches; each batch is ONE `generate` call
|
|
@@ -39,7 +52,7 @@
|
|
|
39
52
|
* translates with cross-block context. Bounded concurrency (default 6) keeps a realistic
|
|
40
53
|
* document well under the route's per-minute rate limit.
|
|
41
54
|
*/
|
|
42
|
-
import { autoDeriveSlug, createCollectionRegistry, fieldValuePosition, isFieldLocalized, isWriterLocked, resolveField, slugFieldOf, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
|
|
55
|
+
import { autoDeriveSlug, childPositions, createCollectionRegistry, declarationMayHold, fieldValuePosition, isFieldLocalized, isOpaquePosition, isWriterLocked, resolveField, slugFieldOf, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
|
|
43
56
|
import { introducedErrors, warningsOf } from './proposal-validation.js';
|
|
44
57
|
import { fieldWritePatch } from '../cms/field-patch.js';
|
|
45
58
|
import { fieldConnection, hasLocaleVariant } from '../locale/index.js';
|
|
@@ -88,6 +101,34 @@ function positionId(locale, field, position) {
|
|
|
88
101
|
const key = positionKey(position);
|
|
89
102
|
return key === '' ? `${locale}|${field}` : `${locale}|${field}|${key}`;
|
|
90
103
|
}
|
|
104
|
+
/** A declaration whose value is text a writer may translate (a unit, or a sequence of them). */
|
|
105
|
+
function isTranslatableDeclaration(field) {
|
|
106
|
+
return (field.type === 'text' || field.type === 'richText' || field.type === 'blocks') && !isWriterLocked(field);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* EC-752 (the EC-748 traversal contract, read by translate): every value at or below
|
|
110
|
+
* `root` the traversal cannot read (an {@link OpaquePosition} — a holder stored in a shape
|
|
111
|
+
* its declaration no longer reads) whose declaration may hold translatable text. Its text
|
|
112
|
+
* is no unit — it cannot be found — so the caller REPORTS it (`unreadable`), never leaves
|
|
113
|
+
* it untranslated with no word. A writer-locked position is not descended (no unit, never
|
|
114
|
+
* a skip — EC-716), exactly as {@link textPositionsOf} does.
|
|
115
|
+
*/
|
|
116
|
+
function unreadableTextUnder(root, ctx) {
|
|
117
|
+
const out = [];
|
|
118
|
+
const visit = (position) => {
|
|
119
|
+
if (isWriterLocked(position.field) || position.field.type === 'text')
|
|
120
|
+
return;
|
|
121
|
+
for (const child of childPositions(position, ctx)) {
|
|
122
|
+
if (!isOpaquePosition(child))
|
|
123
|
+
visit(child);
|
|
124
|
+
else if (!isWriterLocked(child.declaration) && declarationMayHold(child.declaration, isTranslatableDeclaration)) {
|
|
125
|
+
out.push(child);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
visit(root);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
91
132
|
/** A minimal bounded-concurrency limiter (the precedent's `createLimiter`). */
|
|
92
133
|
function createLimiter(max) {
|
|
93
134
|
let active = 0;
|
|
@@ -196,31 +237,39 @@ function chunk(items, size) {
|
|
|
196
237
|
out.push(items.slice(i, i + size));
|
|
197
238
|
return out;
|
|
198
239
|
}
|
|
199
|
-
|
|
200
|
-
|
|
240
|
+
/** A skip entry — the component it sits in, when it does. */
|
|
241
|
+
function skipOf(field, locale, site, reason, at = site.at) {
|
|
242
|
+
return { field, locale, at, ...(site.component ? { component: { ...site.component } } : {}), reason };
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* EC-752 — THE pre-provider judge: which field × locale values a translation of `input`
|
|
246
|
+
* writes when every unit lands (`fill` / `replace`), and what it already knows it will not
|
|
247
|
+
* translate (`locked`, `unreadable`). Pure: no provider, no store, no clock. The studio's
|
|
248
|
+
* confirm lists it; {@link translateDocument} runs exactly it — one judge, never a second
|
|
249
|
+
* count. See {@link TranslatePlan} for why it is an upper bound.
|
|
250
|
+
*/
|
|
251
|
+
export function planTranslation(input) {
|
|
252
|
+
return planRun(input).plan;
|
|
253
|
+
}
|
|
254
|
+
function planRun(input) {
|
|
255
|
+
const { collection, document, localeConfig } = input;
|
|
201
256
|
const mode = input.mode ?? 'fill-empty';
|
|
257
|
+
// EC-752 rule 2: a re-translation writes a field × locale WHOLE or not at all — it
|
|
258
|
+
// replaces a value someone made, so a part of it must never become the base's text.
|
|
259
|
+
const whole = mode === 'retranslate-all';
|
|
202
260
|
// EC-580: the base is the project's default locale — the only default there is,
|
|
203
261
|
// and the one the translate dialog names (MODE #12: the engine used to translate
|
|
204
262
|
// FROM a per-document default while the dialog said "from EN").
|
|
205
263
|
const base = localeConfig.default;
|
|
206
264
|
const targets = (input.targetLocales ?? localeConfig.locales).filter((l) => l !== base);
|
|
207
|
-
const provenance = aiProvenance(provider, input.now ?? new Date().toISOString());
|
|
208
265
|
// EC-593: the traversal reaches component props through the validator's own lookup.
|
|
209
266
|
const lookupManifest = input.validateOptions?.lookupManifest;
|
|
210
267
|
const positionCtx = lookupManifest ? { lookupManifest } : {};
|
|
211
|
-
//
|
|
268
|
+
// Walk every text position of each eligible bucket's base value → units + plans.
|
|
212
269
|
const units = [];
|
|
213
|
-
const
|
|
270
|
+
const richPlans = new Map();
|
|
214
271
|
const pending = [];
|
|
215
272
|
const skipped = [];
|
|
216
|
-
const slugFields = collection.fields.filter((f) => isSlugField(f, collection) && !isWriterLocked(f) && isFieldLocalized(collection, f.name));
|
|
217
|
-
const skip = (field, locale, position, reason, at = position.at) => skipped.push({
|
|
218
|
-
field,
|
|
219
|
-
locale,
|
|
220
|
-
at,
|
|
221
|
-
...(position.component ? { component: { ...position.component } } : {}),
|
|
222
|
-
reason,
|
|
223
|
-
});
|
|
224
273
|
for (const field of collection.fields) {
|
|
225
274
|
if (isWriterLocked(field))
|
|
226
275
|
continue;
|
|
@@ -246,35 +295,38 @@ export async function translateDocument(input) {
|
|
|
246
295
|
const kept = withLockedFrom(root, current, positionCtx);
|
|
247
296
|
const changes = lockedChanges(fieldValuePosition(field, current, { locale }), kept, positionCtx);
|
|
248
297
|
if (changes.lost.length > 0 || changes.created.length > 0) {
|
|
249
|
-
|
|
298
|
+
skipped.push(skipOf(field.name, locale, root, 'locked'));
|
|
250
299
|
continue;
|
|
251
300
|
}
|
|
252
|
-
const
|
|
301
|
+
const bucketUnits = [];
|
|
302
|
+
const bucketPlans = new Map();
|
|
303
|
+
const bucketSkips = [];
|
|
304
|
+
const unreadable = (site) => bucketSkips.push(skipOf(field.name, locale, site, 'unreadable'));
|
|
253
305
|
// EC-593: every text position of the base value — the converter module's fold
|
|
254
306
|
// over core's ONE value-position traversal.
|
|
255
|
-
for (const position of textPositionsOf(
|
|
307
|
+
for (const position of textPositionsOf(root, positionCtx)) {
|
|
256
308
|
const { value } = position;
|
|
257
309
|
if (value === undefined || value === null)
|
|
258
310
|
continue;
|
|
259
311
|
if (position.field.type === 'text') {
|
|
260
312
|
if (typeof value !== 'string')
|
|
261
|
-
|
|
313
|
+
unreadable(position);
|
|
262
314
|
else if (value.trim().length > 0) {
|
|
263
|
-
|
|
315
|
+
bucketUnits.push({ kind: 'text', id: positionId(locale, field.name, position), locale, field: field.name, text: value });
|
|
264
316
|
}
|
|
265
317
|
continue;
|
|
266
318
|
}
|
|
267
319
|
const extracted = extractRichTextUnits(value);
|
|
268
320
|
if (!extracted.readable) {
|
|
269
|
-
|
|
321
|
+
unreadable(position);
|
|
270
322
|
continue;
|
|
271
323
|
}
|
|
272
324
|
if (extracted.units.length === 0)
|
|
273
325
|
continue;
|
|
274
326
|
const planId = positionId(locale, field.name, position);
|
|
275
|
-
|
|
327
|
+
bucketPlans.set(planId, extracted.plan);
|
|
276
328
|
for (const block of extracted.units) {
|
|
277
|
-
|
|
329
|
+
bucketUnits.push({
|
|
278
330
|
kind: 'richText',
|
|
279
331
|
id: `${planId}|${block.id}`,
|
|
280
332
|
locale,
|
|
@@ -285,10 +337,53 @@ export async function translateDocument(input) {
|
|
|
285
337
|
});
|
|
286
338
|
}
|
|
287
339
|
}
|
|
288
|
-
|
|
289
|
-
|
|
340
|
+
// EC-752 (8b): text inside a value the traversal cannot read is no unit — reported, not
|
|
341
|
+
// silent (once per location: an unreadable sequence was named by the walk above).
|
|
342
|
+
for (const opaque of unreadableTextUnder(root, positionCtx)) {
|
|
343
|
+
const at = JSON.stringify(opaque.at);
|
|
344
|
+
if (!bucketSkips.some((entry) => JSON.stringify(entry.at) === at))
|
|
345
|
+
unreadable(opaque);
|
|
346
|
+
}
|
|
347
|
+
skipped.push(...bucketSkips);
|
|
348
|
+
// Rule 2, known before the provider: a value with text that cannot be translated is
|
|
349
|
+
// never re-translated in part — its whole bucket stays as it was (and spends no call).
|
|
350
|
+
if (whole && bucketSkips.length > 0)
|
|
351
|
+
continue;
|
|
352
|
+
if (bucketUnits.length === 0)
|
|
353
|
+
continue;
|
|
354
|
+
units.push(...bucketUnits);
|
|
355
|
+
for (const [id, plan] of bucketPlans)
|
|
356
|
+
richPlans.set(id, plan);
|
|
357
|
+
pending.push({ field, locale, base: conn.baseValue, current, kind: conn.connected ? 'fill' : 'replace' });
|
|
290
358
|
}
|
|
291
359
|
}
|
|
360
|
+
return {
|
|
361
|
+
plan: {
|
|
362
|
+
mode,
|
|
363
|
+
base,
|
|
364
|
+
targets,
|
|
365
|
+
buckets: pending.map(({ field, locale, kind }) => ({ field: field.name, locale, kind })),
|
|
366
|
+
skipped,
|
|
367
|
+
total: units.length,
|
|
368
|
+
},
|
|
369
|
+
units,
|
|
370
|
+
richPlans,
|
|
371
|
+
pending,
|
|
372
|
+
positionCtx,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
export async function translateDocument(input) {
|
|
376
|
+
const { provider, collection, document, localeConfig } = input;
|
|
377
|
+
// EC-752 (rule 4): the run executes the ONE pre-provider plan — the list the studio's
|
|
378
|
+
// confirm showed is the list the units come from.
|
|
379
|
+
const { plan, units, richPlans: plans, pending, positionCtx } = planRun(input);
|
|
380
|
+
const { mode, base, targets } = plan;
|
|
381
|
+
const whole = mode === 'retranslate-all';
|
|
382
|
+
const provenance = aiProvenance(provider, input.now ?? new Date().toISOString());
|
|
383
|
+
const lookupManifest = input.validateOptions?.lookupManifest;
|
|
384
|
+
const skipped = [...plan.skipped];
|
|
385
|
+
const skip = (field, locale, position, reason, at = position.at) => skipped.push(skipOf(field, locale, position, reason, at));
|
|
386
|
+
const slugFields = collection.fields.filter((f) => isSlugField(f, collection) && !isWriterLocked(f) && isFieldLocalized(collection, f.name));
|
|
292
387
|
const total = units.length;
|
|
293
388
|
// 2. Batch per locale, translate with bounded concurrency, honour the abort signal.
|
|
294
389
|
const batchSize = Math.max(1, input.batchSize ?? DEFAULT_TRANSLATE_BATCH_SIZE);
|
|
@@ -331,11 +426,12 @@ export async function translateDocument(input) {
|
|
|
331
426
|
// text position replaced (the traversal's writing fold), then slug derivation.
|
|
332
427
|
// `translated` / `perLocale` count applied UNITS (comparable to `total`).
|
|
333
428
|
let next = document;
|
|
334
|
-
// EC-595 A2: every bucket this run writes, emitted as its patch at the end
|
|
429
|
+
// EC-595 A2: every bucket this run writes, emitted as its patch at the end (EC-752:
|
|
430
|
+
// with what the write was — the outcome the studio reports).
|
|
335
431
|
const written = new Map();
|
|
336
|
-
const write = (field, locale, value) => {
|
|
432
|
+
const write = (field, locale, value, kind) => {
|
|
337
433
|
next = writeField(collection, next, field, locale, value, lookupManifest ? { lookupManifest } : {});
|
|
338
|
-
written.set(`${locale}|${field}`, { field, locale });
|
|
434
|
+
written.set(`${locale}|${field}`, { field, locale, kind });
|
|
339
435
|
};
|
|
340
436
|
const perLocale = {};
|
|
341
437
|
let translated = 0;
|
|
@@ -343,10 +439,15 @@ export async function translateDocument(input) {
|
|
|
343
439
|
perLocale[locale] = (perLocale[locale] ?? 0) + count;
|
|
344
440
|
translated += count;
|
|
345
441
|
};
|
|
442
|
+
// The plan's units by the position they translate — the rebuild's ONE reader of what was
|
|
443
|
+
// planned (EC-759): a text unit by its position id, a rich-text position's blocks by its plan.
|
|
444
|
+
const textUnitAt = new Map();
|
|
346
445
|
const blocksOf = new Map();
|
|
347
446
|
for (const unit of units) {
|
|
348
|
-
if (unit.kind
|
|
447
|
+
if (unit.kind === 'text') {
|
|
448
|
+
textUnitAt.set(unit.id, unit);
|
|
349
449
|
continue;
|
|
450
|
+
}
|
|
350
451
|
const list = blocksOf.get(unit.plan) ?? new Map();
|
|
351
452
|
list.set(unit.blockId, unit);
|
|
352
453
|
blocksOf.set(unit.plan, list);
|
|
@@ -357,18 +458,35 @@ export async function translateDocument(input) {
|
|
|
357
458
|
'content',
|
|
358
459
|
...blockId.split('.').map(Number),
|
|
359
460
|
];
|
|
360
|
-
for (const { field, locale, base: baseValue, current } of pending) {
|
|
461
|
+
for (const { field, locale, base: baseValue, current, kind } of pending) {
|
|
361
462
|
let applied = 0;
|
|
463
|
+
// EC-752 rule 2: whether every PLANNED unit of the bucket landed — a planned unit never
|
|
464
|
+
// sent (a cancel), not returned, or a block whose tokens did not survive makes it
|
|
465
|
+
// incomplete. A position the plan made no unit of is no part of it (EC-759).
|
|
466
|
+
let complete = true;
|
|
362
467
|
// EC-593: the base value with each translated text position replaced — the
|
|
363
468
|
// converter module's writing fold; every other value is carried from the base.
|
|
364
469
|
const value = rebuildTextPositions(fieldValuePosition(field, baseValue, { locale: base }), positionCtx, (position) => {
|
|
365
470
|
const id = positionId(locale, field.name, position);
|
|
366
471
|
if (position.field.type === 'text') {
|
|
367
|
-
|
|
472
|
+
// EC-759 (Codex r10 F48): the PLANNED unit at this position, read as the rich-text
|
|
473
|
+
// branch reads its plan. None — the base leaves the position empty (absent, `null`,
|
|
474
|
+
// `''`, whitespace), or its value is unreadable and reported by the plan — means
|
|
475
|
+
// the base's value is carried, as every non-text value is, and completeness is
|
|
476
|
+
// untouched. Judging "every text position" by the units whose batch ran read a
|
|
477
|
+
// position never planned as a cancel, and the whole-bucket rule then dropped a
|
|
478
|
+
// listed replacement with no skip.
|
|
479
|
+
const unit = textUnitAt.get(id);
|
|
480
|
+
if (!unit)
|
|
481
|
+
return position.value;
|
|
482
|
+
if (!attempted.has(unit.id)) {
|
|
483
|
+
complete = false;
|
|
368
484
|
return position.value;
|
|
369
|
-
|
|
485
|
+
}
|
|
486
|
+
const text = returned(unit.id);
|
|
370
487
|
if (text === undefined) {
|
|
371
488
|
skip(field.name, locale, position, 'not-returned');
|
|
489
|
+
complete = false;
|
|
372
490
|
return position.value;
|
|
373
491
|
}
|
|
374
492
|
applied += 1;
|
|
@@ -379,11 +497,15 @@ export async function translateDocument(input) {
|
|
|
379
497
|
return position.value;
|
|
380
498
|
const blockTranslations = new Map();
|
|
381
499
|
for (const [blockId, unit] of blocksOf.get(id) ?? []) {
|
|
382
|
-
if (!attempted.has(unit.id))
|
|
500
|
+
if (!attempted.has(unit.id)) {
|
|
501
|
+
complete = false;
|
|
383
502
|
continue;
|
|
503
|
+
}
|
|
384
504
|
const text = returned(unit.id);
|
|
385
|
-
if (text === undefined)
|
|
505
|
+
if (text === undefined) {
|
|
386
506
|
skip(field.name, locale, position, 'not-returned', blockAt(position, blockId));
|
|
507
|
+
complete = false;
|
|
508
|
+
}
|
|
387
509
|
else
|
|
388
510
|
blockTranslations.set(blockId, text);
|
|
389
511
|
}
|
|
@@ -392,6 +514,7 @@ export async function translateDocument(input) {
|
|
|
392
514
|
const rebuilt = rebuildRichTextReport(plan, blockTranslations);
|
|
393
515
|
for (const blockId of rebuilt.rejected) {
|
|
394
516
|
skip(field.name, locale, position, 'structure-changed', blockAt(position, blockId));
|
|
517
|
+
complete = false;
|
|
395
518
|
}
|
|
396
519
|
if (rebuilt.applied.length === 0)
|
|
397
520
|
return position.value;
|
|
@@ -399,15 +522,22 @@ export async function translateDocument(input) {
|
|
|
399
522
|
return rebuilt.value;
|
|
400
523
|
});
|
|
401
524
|
if (applied === 0)
|
|
402
|
-
continue; // nothing landed — the bucket stays
|
|
403
|
-
// EC-
|
|
404
|
-
|
|
525
|
+
continue; // nothing landed — the bucket stays as it was
|
|
526
|
+
// EC-752 rule 2: a re-translation writes a field × locale only WHOLE. A part of it is
|
|
527
|
+
// never replaced by the base's text: the target keeps its value, the skips say why.
|
|
528
|
+
if (whole && !complete)
|
|
529
|
+
continue;
|
|
530
|
+
// EC-738: the target's locked positions, by key (judged in the plan).
|
|
531
|
+
write(field.name, locale, withLockedFrom(fieldValuePosition(field, value, { locale }), current, positionCtx), kind);
|
|
405
532
|
bump(locale, applied);
|
|
406
533
|
}
|
|
407
|
-
// 4. Slugs: derive each
|
|
408
|
-
// model-translated; only for a still-connected slug whose
|
|
409
|
-
//
|
|
410
|
-
// divergence, and an aborted run with no translations derives
|
|
534
|
+
// 4. Slugs: derive each per-locale slug that still follows the base from the (now
|
|
535
|
+
// translated) source. Never model-translated; only for a still-connected slug whose
|
|
536
|
+
// SOURCE field holds its own value in that locale (so the derived slug differs from the
|
|
537
|
+
// base — no pointless divergence, and an aborted run with no translations derives
|
|
538
|
+
// nothing). EC-752 rule 1 — in BOTH modes: a slug the editor set is never touched. A
|
|
539
|
+
// changed slug moves the page's address and nothing redirects the old one, so a
|
|
540
|
+
// re-translation silently changing the URL was the measured ship-stopper.
|
|
411
541
|
let slugsDerived = 0;
|
|
412
542
|
for (const field of slugFields) {
|
|
413
543
|
const sourceField = slugSourceField(field, collection);
|
|
@@ -415,7 +545,7 @@ export async function translateDocument(input) {
|
|
|
415
545
|
const conn = fieldConnection(collection, next, field.name, locale, localeConfig);
|
|
416
546
|
if (!conn)
|
|
417
547
|
continue;
|
|
418
|
-
if (
|
|
548
|
+
if (!conn.connected)
|
|
419
549
|
continue;
|
|
420
550
|
const sourceTranslated = sourceField
|
|
421
551
|
? hasLocaleVariant(next, sourceField, locale)
|
|
@@ -426,7 +556,7 @@ export async function translateDocument(input) {
|
|
|
426
556
|
const derived = autoDeriveSlug(field.slug, collection, valuesInLocale(collection, next, locale, localeConfig));
|
|
427
557
|
if (!derived || derived === conn.baseValue)
|
|
428
558
|
continue;
|
|
429
|
-
write(field.name, locale, derived);
|
|
559
|
+
write(field.name, locale, derived, 'slug');
|
|
430
560
|
slugsDerived += 1;
|
|
431
561
|
}
|
|
432
562
|
}
|
|
@@ -442,9 +572,17 @@ export async function translateDocument(input) {
|
|
|
442
572
|
...(input.validateOptions ?? {}),
|
|
443
573
|
};
|
|
444
574
|
const producedIssues = validateDocument(registry, next, validateOptions);
|
|
575
|
+
const writes = [...written.values()].map(({ field, locale, kind }) => ({
|
|
576
|
+
field,
|
|
577
|
+
locale,
|
|
578
|
+
kind,
|
|
579
|
+
patch: fieldWritePatch(collection, next, field, locale),
|
|
580
|
+
}));
|
|
445
581
|
return {
|
|
446
582
|
document: next,
|
|
447
|
-
|
|
583
|
+
plan,
|
|
584
|
+
writes,
|
|
585
|
+
patches: writes.map((entry) => entry.patch),
|
|
448
586
|
total,
|
|
449
587
|
translated,
|
|
450
588
|
cancelled,
|