@elytracms/studio 0.0.101 → 0.0.103
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/asset-meta-patch.test.ts +7 -27
- package/convex/assets.ts +6 -41
- package/convex/cms.ts +2 -29
- package/convex/delivery.ts +2 -2
- package/convex/ingest.test.ts +54 -21
- package/convex/ingest.ts +52 -15
- package/convex/record-functions.ts +15 -82
- package/convex/redirects.ts +11 -116
- package/convex/schema.ts +4 -23
- package/convex/triggers.ts +7 -14
- package/dist/components/ai/ai-chat-panel.js +8 -5
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- 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 +34 -7
- package/dist/components/ai/translate-skipped.js.map +1 -1
- package/dist/components/assets/asset-detail.js +1 -2
- package/dist/components/assets/asset-detail.js.map +1 -1
- package/dist/components/assets/asset-metadata.d.ts +2 -9
- package/dist/components/assets/asset-metadata.js +3 -10
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/asset-search.js +1 -1
- package/dist/components/assets/asset-search.js.map +1 -1
- package/dist/components/assets/asset-selection.js +2 -2
- package/dist/components/assets/asset-selection.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +2 -3
- package/dist/components/assets/assets-collection-view-source.js +2 -3
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/builder/__fixtures__/reuse-race.d.ts +30 -0
- package/dist/components/builder/__fixtures__/reuse-race.js +77 -0
- package/dist/components/builder/__fixtures__/reuse-race.js.map +1 -0
- package/dist/components/builder/composition-field-editor.js +3 -3
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/reuse-context.d.ts +2 -1
- package/dist/components/builder/reuse-context.js +5 -20
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/builder/reuse.d.ts +18 -11
- package/dist/components/builder/reuse.js +12 -14
- package/dist/components/builder/reuse.js.map +1 -1
- package/dist/components/cms/asset-field.js +1 -3
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/cell.d.ts +2 -2
- package/dist/components/cms/cell.js +8 -18
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/detail-editor.js +2 -1
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/field-control.js +13 -1
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/lib/ai/chat-context.d.ts +6 -2
- package/dist/lib/ai/chat-context.js +11 -5
- package/dist/lib/ai/chat-context.js.map +1 -1
- package/dist/lib/ai/knowledge.d.ts +9 -9
- package/dist/lib/ai/knowledge.js +22 -0
- package/dist/lib/ai/knowledge.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +8 -1
- package/dist/lib/ai/tools.js +33 -15
- package/dist/lib/ai/tools.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 +101 -7
- package/dist/lib/ai/translate-document.js +189 -54
- package/dist/lib/ai/translate-document.js.map +1 -1
- package/dist/lib/ai/update-fields.js +5 -12
- package/dist/lib/ai/update-fields.js.map +1 -1
- package/dist/lib/ai/writer-form.d.ts +93 -1
- package/dist/lib/ai/writer-form.js +389 -37
- package/dist/lib/ai/writer-form.js.map +1 -1
- package/dist/lib/assets/upload.d.ts +1 -2
- package/dist/lib/assets/upload.js +1 -2
- package/dist/lib/assets/upload.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 +54 -4
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +44 -1
- package/dist/lib/i18n/messages.en.js +58 -4
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/cms-persistence.d.ts +20 -10
- package/dist/lib/operations/cms-persistence.js +11 -3
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +7 -2
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/package.json +3 -3
- package/convex/asset-alt-migration.test.ts +0 -127
- package/convex/redirect-migration.test.ts +0 -205
- package/convex/version-kind-stamping.test.ts +0 -118
|
@@ -6,7 +6,10 @@ export declare const DEFAULT_TRANSLATE_BATCH_SIZE = 12;
|
|
|
6
6
|
export declare const DEFAULT_TRANSLATE_CONCURRENCY = 6;
|
|
7
7
|
/** The schema name the batch call carries — how a FakeProvider recognises a translate call. */
|
|
8
8
|
export declare const TRANSLATE_SCHEMA_NAME = "translate_units";
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Fill only still-connected locale values (default), or re-translate every localized field
|
|
11
|
+
* — replacing a value with its own translation, rebuilt from the base (EC-752).
|
|
12
|
+
*/
|
|
10
13
|
export type TranslateMode = 'fill-empty' | 'retranslate-all';
|
|
11
14
|
export interface TranslateDocumentInput {
|
|
12
15
|
provider: AiProvider;
|
|
@@ -18,7 +21,12 @@ export interface TranslateDocumentInput {
|
|
|
18
21
|
targetLocales?: readonly Locale[];
|
|
19
22
|
/** Assembled project knowledge (glossary / voice) sent as context with every batch. */
|
|
20
23
|
knowledge?: ResolvedKnowledge;
|
|
21
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* `fill-empty` (default) or `retranslate-all` — which REPLACES a value with its own
|
|
26
|
+
* translation, whole or not at all, never a slug the editor set (EC-752). The studio runs
|
|
27
|
+
* it only behind a confirm listing {@link planTranslation}'s replacements, after a version
|
|
28
|
+
* snapshot of the draft.
|
|
29
|
+
*/
|
|
22
30
|
mode?: TranslateMode;
|
|
23
31
|
batchSize?: number;
|
|
24
32
|
concurrency?: number;
|
|
@@ -52,12 +60,28 @@ export interface TranslateProgress {
|
|
|
52
60
|
}
|
|
53
61
|
/** Why a text position was not translated. */
|
|
54
62
|
export type TranslateSkipReason =
|
|
55
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* The stored value at the position is not readable as its type (e.g. a pre-convergence rich
|
|
65
|
+
* text) — or (EC-752) it is a value the traversal cannot read at all (`OpaquePosition`: a
|
|
66
|
+
* holder stored in a shape its declaration no longer reads) that may hold text.
|
|
67
|
+
*/
|
|
56
68
|
'unreadable'
|
|
57
69
|
/** The provider returned no (or empty) text for the unit. */
|
|
58
70
|
| 'not-returned'
|
|
59
|
-
/**
|
|
60
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The translation did not keep the formatting / link / embed tokens: `fill-empty` keeps
|
|
73
|
+
* the source's block in the fill; `retranslate-all` leaves the whole target value as it was.
|
|
74
|
+
*/
|
|
75
|
+
| 'structure-changed'
|
|
76
|
+
/**
|
|
77
|
+
* EC-738: the bucket's locked content (`form.readOnly` / `ai.exclude`) differs in shape
|
|
78
|
+
* from the source's — writing the translation would lose a locked value the target holds or
|
|
79
|
+
* add one it does not (a repeater with more or fewer items, a composition with other
|
|
80
|
+
* nodes) — or (EC-748) either side stores it in a shape the declaration no longer reads.
|
|
81
|
+
* The whole bucket was left as it is: only code, a seed, an import or a save in the editor
|
|
82
|
+
* changes it.
|
|
83
|
+
*/
|
|
84
|
+
| 'locked';
|
|
61
85
|
/**
|
|
62
86
|
* One text position the run could NOT translate (EC-593, ratified 4e): reported in the
|
|
63
87
|
* result and shown in the dialog — never a silent skip.
|
|
@@ -75,9 +99,71 @@ export interface TranslateSkip {
|
|
|
75
99
|
};
|
|
76
100
|
reason: TranslateSkipReason;
|
|
77
101
|
}
|
|
102
|
+
/** One `(field, locale)` value — the unit a translation writes, plans and reports (EC-752). */
|
|
103
|
+
export interface TranslateBucket {
|
|
104
|
+
readonly field: string;
|
|
105
|
+
readonly locale: Locale;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* What a planned bucket is (EC-752): `fill` — the target FOLLOWS the base (EC-318 connected)
|
|
109
|
+
* and gets its own translation; `replace` — the target holds its own value (an earlier
|
|
110
|
+
* translation, an editor's edit), which a re-translation rebuilds from the base — its text
|
|
111
|
+
* translated, every other value (a number, an image, a select) the base's, its locked
|
|
112
|
+
* positions kept (EC-738). `replace` exists only in `retranslate-all`.
|
|
113
|
+
*/
|
|
114
|
+
export type TranslateBucketKind = 'fill' | 'replace';
|
|
115
|
+
export interface TranslatePlanBucket extends TranslateBucket {
|
|
116
|
+
readonly kind: TranslateBucketKind;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* EC-752 — what a run will do, judged BEFORE any provider call: the eligibility, the locked
|
|
120
|
+
* judgement (EC-738), the units and the unreadable positions. ONE judge
|
|
121
|
+
* ({@link planTranslation}) gives both the studio's confirm and the run's input, so the list
|
|
122
|
+
* an editor confirms is the list the run works from.
|
|
123
|
+
*
|
|
124
|
+
* It is an UPPER bound ("up to"): what is known only after the provider answered — a unit
|
|
125
|
+
* not returned, a block whose tokens did not survive, a cancel, the introduced-errors gate
|
|
126
|
+
* refusing the run, a colleague's write refused at its revision — can only take a bucket
|
|
127
|
+
* away. {@link TranslateDocumentResult.writes} says what was written.
|
|
128
|
+
*/
|
|
129
|
+
export interface TranslatePlan {
|
|
130
|
+
readonly mode: TranslateMode;
|
|
131
|
+
readonly base: Locale;
|
|
132
|
+
/** Every configured locale but the base (or the caller's `targetLocales`). */
|
|
133
|
+
readonly targets: readonly Locale[];
|
|
134
|
+
/** The buckets the run writes when every unit lands — walk order (field, then locale). */
|
|
135
|
+
readonly buckets: readonly TranslatePlanBucket[];
|
|
136
|
+
/**
|
|
137
|
+
* What the run will NOT translate, known now: a bucket left as it is for its locked
|
|
138
|
+
* content (`locked`), an unreadable position (`unreadable` — in `retranslate-all` its whole
|
|
139
|
+
* bucket is left as it was, so it is not in {@link buckets}).
|
|
140
|
+
*/
|
|
141
|
+
readonly skipped: readonly TranslateSkip[];
|
|
142
|
+
/** Translatable units — the run's {@link TranslateDocumentResult.total}. */
|
|
143
|
+
readonly total: number;
|
|
144
|
+
}
|
|
145
|
+
/** What {@link planTranslation} reads — the run's own inputs, minus the provider. */
|
|
146
|
+
export type TranslatePlanInput = Pick<TranslateDocumentInput, 'collection' | 'document' | 'localeConfig' | 'targetLocales' | 'mode' | 'validateOptions'>;
|
|
147
|
+
/**
|
|
148
|
+
* One bucket this run WROTE (EC-752): a planned `fill` / `replace` whose every unit landed
|
|
149
|
+
* (`fill-empty`: at least one), or a `slug` derived from the translated source (only where
|
|
150
|
+
* the target's slug followed the base). `patch` is the one the caller persists
|
|
151
|
+
* ({@link TranslateDocumentResult.patches}, same order).
|
|
152
|
+
*/
|
|
153
|
+
export interface TranslateWrite extends TranslateBucket {
|
|
154
|
+
readonly kind: TranslateBucketKind | 'slug';
|
|
155
|
+
readonly patch: DocumentPatch;
|
|
156
|
+
}
|
|
78
157
|
export interface TranslateDocumentResult {
|
|
79
|
-
/** The new draft with the translations
|
|
158
|
+
/** The new draft with the translations in. */
|
|
80
159
|
document: CmsDocument;
|
|
160
|
+
/** EC-752 — the pre-provider plan this run executed ({@link planTranslation} on its input). */
|
|
161
|
+
plan: TranslatePlan;
|
|
162
|
+
/**
|
|
163
|
+
* EC-752 — what this run wrote, one entry per bucket, in {@link patches}' order: compare
|
|
164
|
+
* with {@link plan} for what was planned but not written.
|
|
165
|
+
*/
|
|
166
|
+
writes: readonly TranslateWrite[];
|
|
81
167
|
/**
|
|
82
168
|
* EC-595 A2 — the positions this run wrote, as patches (one per `(field, locale)`
|
|
83
169
|
* bucket it filled — "localized fields patch per bucket"): what the caller persists,
|
|
@@ -94,7 +180,7 @@ export interface TranslateDocumentResult {
|
|
|
94
180
|
changed: boolean;
|
|
95
181
|
/** Translatable units filled per target locale. */
|
|
96
182
|
perLocale: Record<Locale, number>;
|
|
97
|
-
/** Slug fields derived
|
|
183
|
+
/** Slug fields derived (not model-translated) — the `slug` entries of {@link writes}. */
|
|
98
184
|
slugsDerived: number;
|
|
99
185
|
/** Every text position the run could not translate (EC-593) — empty when all landed. */
|
|
100
186
|
skipped: readonly TranslateSkip[];
|
|
@@ -118,4 +204,12 @@ export interface TranslateDocumentResult {
|
|
|
118
204
|
/** Surviving non-error findings on the produced draft — carried onto a proposal. */
|
|
119
205
|
issues: readonly CmsValidationIssue[];
|
|
120
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* EC-752 — THE pre-provider judge: which field × locale values a translation of `input`
|
|
209
|
+
* writes when every unit lands (`fill` / `replace`), and what it already knows it will not
|
|
210
|
+
* translate (`locked`, `unreadable`). Pure: no provider, no store, no clock. The studio's
|
|
211
|
+
* confirm lists it; {@link translateDocument} runs exactly it — one judge, never a second
|
|
212
|
+
* count. See {@link TranslatePlan} for why it is an upper bound.
|
|
213
|
+
*/
|
|
214
|
+
export declare function planTranslation(input: TranslatePlanInput): TranslatePlan;
|
|
121
215
|
export declare function translateDocument(input: TranslateDocumentInput): Promise<TranslateDocumentResult>;
|
|
@@ -2,21 +2,28 @@
|
|
|
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 unit
|
|
25
|
+
* of it landed (a dropped unit, a rejected block, a cancel or an unreadable position
|
|
26
|
+
* leaves the target's value as it was, reported); its locked content is kept (EC-738);
|
|
20
27
|
* - EVERY text position of the base value is a unit (EC-593, ratified 4e): the walk is
|
|
21
28
|
* core's ONE value-position traversal, so a `text` position is one unit and a
|
|
22
29
|
* `richText` / `blocks` sequence one unit per translatable block (structure preserved
|
|
@@ -28,10 +35,14 @@
|
|
|
28
35
|
* - the target bucket is the BASE value with its text translated — every other value
|
|
29
36
|
* (an image, a select, a number) carried from it, the displayed-value rule (2c);
|
|
30
37
|
* - whatever cannot be translated is REPORTED (`skipped`), never silently left: a
|
|
31
|
-
* position whose value is unreadable
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
38
|
+
* position whose value is unreadable (EC-752: text inside a value the traversal cannot
|
|
39
|
+
* read too), a unit the provider did not return, a rich-text block whose translation did
|
|
40
|
+
* not decode token-faithfully (fill-empty keeps the source's block in the fill;
|
|
41
|
+
* retranslate-all leaves the whole target value as it was);
|
|
42
|
+
* - SLUG fields are never model-translated: after the text fills land, each per-locale
|
|
43
|
+
* slug that still FOLLOWS the base is derived (EC-315 `autoDeriveSlug`) from the
|
|
44
|
+
* translated source — in both modes: a slug the editor set is never touched (EC-752 — a
|
|
45
|
+
* changed slug moves the page's address, and nothing redirects it).
|
|
35
46
|
*
|
|
36
47
|
* Batching (improves on the Sanity precedent's context-blind per-block calls): units are
|
|
37
48
|
* grouped per target locale and chunked into batches; each batch is ONE `generate` call
|
|
@@ -39,14 +50,14 @@
|
|
|
39
50
|
* translates with cross-block context. Bounded concurrency (default 6) keeps a realistic
|
|
40
51
|
* document well under the route's per-minute rate limit.
|
|
41
52
|
*/
|
|
42
|
-
import { autoDeriveSlug, createCollectionRegistry, fieldValuePosition, isFieldLocalized, isWriterLocked, slugFieldOf, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
|
|
53
|
+
import { autoDeriveSlug, childPositions, createCollectionRegistry, declarationMayHold, fieldValuePosition, isFieldLocalized, isOpaquePosition, isWriterLocked, resolveField, slugFieldOf, validateDocument, valuesInLocale, writeField, } from '@elytracms/core/cms-core';
|
|
43
54
|
import { introducedErrors, warningsOf } from './proposal-validation.js';
|
|
44
55
|
import { fieldWritePatch } from '../cms/field-patch.js';
|
|
45
56
|
import { fieldConnection, hasLocaleVariant } from '../locale/index.js';
|
|
46
57
|
import { assembleKnowledgeBlock } from './knowledge.js';
|
|
47
58
|
import { aiProvenance } from './provenance.js';
|
|
48
59
|
import { extractRichTextUnits, rebuildRichTextReport } from './rich-text-translate.js';
|
|
49
|
-
import { rebuildTextPositions, textPositionsOf } from './writer-form.js';
|
|
60
|
+
import { lockedChanges, rebuildTextPositions, textPositionsOf, withLockedFrom } from './writer-form.js';
|
|
50
61
|
/** Default translatable units per `generate` call. */
|
|
51
62
|
export const DEFAULT_TRANSLATE_BATCH_SIZE = 12;
|
|
52
63
|
/** Default number of `generate` calls in flight at once (mirrors the precedent's limiter of 6). */
|
|
@@ -88,6 +99,34 @@ function positionId(locale, field, position) {
|
|
|
88
99
|
const key = positionKey(position);
|
|
89
100
|
return key === '' ? `${locale}|${field}` : `${locale}|${field}|${key}`;
|
|
90
101
|
}
|
|
102
|
+
/** A declaration whose value is text a writer may translate (a unit, or a sequence of them). */
|
|
103
|
+
function isTranslatableDeclaration(field) {
|
|
104
|
+
return (field.type === 'text' || field.type === 'richText' || field.type === 'blocks') && !isWriterLocked(field);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* EC-752 (the EC-748 traversal contract, read by translate): every value at or below
|
|
108
|
+
* `root` the traversal cannot read (an {@link OpaquePosition} — a holder stored in a shape
|
|
109
|
+
* its declaration no longer reads) whose declaration may hold translatable text. Its text
|
|
110
|
+
* is no unit — it cannot be found — so the caller REPORTS it (`unreadable`), never leaves
|
|
111
|
+
* it untranslated with no word. A writer-locked position is not descended (no unit, never
|
|
112
|
+
* a skip — EC-716), exactly as {@link textPositionsOf} does.
|
|
113
|
+
*/
|
|
114
|
+
function unreadableTextUnder(root, ctx) {
|
|
115
|
+
const out = [];
|
|
116
|
+
const visit = (position) => {
|
|
117
|
+
if (isWriterLocked(position.field) || position.field.type === 'text')
|
|
118
|
+
return;
|
|
119
|
+
for (const child of childPositions(position, ctx)) {
|
|
120
|
+
if (!isOpaquePosition(child))
|
|
121
|
+
visit(child);
|
|
122
|
+
else if (!isWriterLocked(child.declaration) && declarationMayHold(child.declaration, isTranslatableDeclaration)) {
|
|
123
|
+
out.push(child);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
visit(root);
|
|
128
|
+
return out;
|
|
129
|
+
}
|
|
91
130
|
/** A minimal bounded-concurrency limiter (the precedent's `createLimiter`). */
|
|
92
131
|
function createLimiter(max) {
|
|
93
132
|
let active = 0;
|
|
@@ -196,31 +235,39 @@ function chunk(items, size) {
|
|
|
196
235
|
out.push(items.slice(i, i + size));
|
|
197
236
|
return out;
|
|
198
237
|
}
|
|
199
|
-
|
|
200
|
-
|
|
238
|
+
/** A skip entry — the component it sits in, when it does. */
|
|
239
|
+
function skipOf(field, locale, site, reason, at = site.at) {
|
|
240
|
+
return { field, locale, at, ...(site.component ? { component: { ...site.component } } : {}), reason };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* EC-752 — THE pre-provider judge: which field × locale values a translation of `input`
|
|
244
|
+
* writes when every unit lands (`fill` / `replace`), and what it already knows it will not
|
|
245
|
+
* translate (`locked`, `unreadable`). Pure: no provider, no store, no clock. The studio's
|
|
246
|
+
* confirm lists it; {@link translateDocument} runs exactly it — one judge, never a second
|
|
247
|
+
* count. See {@link TranslatePlan} for why it is an upper bound.
|
|
248
|
+
*/
|
|
249
|
+
export function planTranslation(input) {
|
|
250
|
+
return planRun(input).plan;
|
|
251
|
+
}
|
|
252
|
+
function planRun(input) {
|
|
253
|
+
const { collection, document, localeConfig } = input;
|
|
201
254
|
const mode = input.mode ?? 'fill-empty';
|
|
255
|
+
// EC-752 rule 2: a re-translation writes a field × locale WHOLE or not at all — it
|
|
256
|
+
// replaces a value someone made, so a part of it must never become the base's text.
|
|
257
|
+
const whole = mode === 'retranslate-all';
|
|
202
258
|
// EC-580: the base is the project's default locale — the only default there is,
|
|
203
259
|
// and the one the translate dialog names (MODE #12: the engine used to translate
|
|
204
260
|
// FROM a per-document default while the dialog said "from EN").
|
|
205
261
|
const base = localeConfig.default;
|
|
206
262
|
const targets = (input.targetLocales ?? localeConfig.locales).filter((l) => l !== base);
|
|
207
|
-
const provenance = aiProvenance(provider, input.now ?? new Date().toISOString());
|
|
208
263
|
// EC-593: the traversal reaches component props through the validator's own lookup.
|
|
209
264
|
const lookupManifest = input.validateOptions?.lookupManifest;
|
|
210
265
|
const positionCtx = lookupManifest ? { lookupManifest } : {};
|
|
211
|
-
//
|
|
266
|
+
// Walk every text position of each eligible bucket's base value → units + plans.
|
|
212
267
|
const units = [];
|
|
213
|
-
const
|
|
268
|
+
const richPlans = new Map();
|
|
214
269
|
const pending = [];
|
|
215
270
|
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
271
|
for (const field of collection.fields) {
|
|
225
272
|
if (isWriterLocked(field))
|
|
226
273
|
continue;
|
|
@@ -235,32 +282,49 @@ export async function translateDocument(input) {
|
|
|
235
282
|
const eligible = mode === 'retranslate-all' ? conn.canFillFromBase : conn.connected && conn.canFillFromBase;
|
|
236
283
|
if (!eligible)
|
|
237
284
|
continue;
|
|
238
|
-
|
|
285
|
+
// EC-738: the target keeps its locked content. The translation is the base value with
|
|
286
|
+
// its text replaced, and every locked position then taken from the target's DISPLAYED
|
|
287
|
+
// value by key; a bucket where that still loses or creates a locked value (the two
|
|
288
|
+
// differ in shape around it) is not written. Judged here, before any unit is sent:
|
|
289
|
+
// the text replacement changes neither the locked positions nor the shape around them.
|
|
290
|
+
// Fill-empty is unchanged by construction — a connected bucket displays the base.
|
|
291
|
+
const current = resolveField(collection, document, field.name, locale, localeConfig).value;
|
|
292
|
+
const root = fieldValuePosition(field, conn.baseValue, { locale: base });
|
|
293
|
+
const kept = withLockedFrom(root, current, positionCtx);
|
|
294
|
+
const changes = lockedChanges(fieldValuePosition(field, current, { locale }), kept, positionCtx);
|
|
295
|
+
if (changes.lost.length > 0 || changes.created.length > 0) {
|
|
296
|
+
skipped.push(skipOf(field.name, locale, root, 'locked'));
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
const bucketUnits = [];
|
|
300
|
+
const bucketPlans = new Map();
|
|
301
|
+
const bucketSkips = [];
|
|
302
|
+
const unreadable = (site) => bucketSkips.push(skipOf(field.name, locale, site, 'unreadable'));
|
|
239
303
|
// EC-593: every text position of the base value — the converter module's fold
|
|
240
304
|
// over core's ONE value-position traversal.
|
|
241
|
-
for (const position of textPositionsOf(
|
|
305
|
+
for (const position of textPositionsOf(root, positionCtx)) {
|
|
242
306
|
const { value } = position;
|
|
243
307
|
if (value === undefined || value === null)
|
|
244
308
|
continue;
|
|
245
309
|
if (position.field.type === 'text') {
|
|
246
310
|
if (typeof value !== 'string')
|
|
247
|
-
|
|
311
|
+
unreadable(position);
|
|
248
312
|
else if (value.trim().length > 0) {
|
|
249
|
-
|
|
313
|
+
bucketUnits.push({ kind: 'text', id: positionId(locale, field.name, position), locale, field: field.name, text: value });
|
|
250
314
|
}
|
|
251
315
|
continue;
|
|
252
316
|
}
|
|
253
317
|
const extracted = extractRichTextUnits(value);
|
|
254
318
|
if (!extracted.readable) {
|
|
255
|
-
|
|
319
|
+
unreadable(position);
|
|
256
320
|
continue;
|
|
257
321
|
}
|
|
258
322
|
if (extracted.units.length === 0)
|
|
259
323
|
continue;
|
|
260
324
|
const planId = positionId(locale, field.name, position);
|
|
261
|
-
|
|
325
|
+
bucketPlans.set(planId, extracted.plan);
|
|
262
326
|
for (const block of extracted.units) {
|
|
263
|
-
|
|
327
|
+
bucketUnits.push({
|
|
264
328
|
kind: 'richText',
|
|
265
329
|
id: `${planId}|${block.id}`,
|
|
266
330
|
locale,
|
|
@@ -271,10 +335,53 @@ export async function translateDocument(input) {
|
|
|
271
335
|
});
|
|
272
336
|
}
|
|
273
337
|
}
|
|
274
|
-
|
|
275
|
-
|
|
338
|
+
// EC-752 (8b): text inside a value the traversal cannot read is no unit — reported, not
|
|
339
|
+
// silent (once per location: an unreadable sequence was named by the walk above).
|
|
340
|
+
for (const opaque of unreadableTextUnder(root, positionCtx)) {
|
|
341
|
+
const at = JSON.stringify(opaque.at);
|
|
342
|
+
if (!bucketSkips.some((entry) => JSON.stringify(entry.at) === at))
|
|
343
|
+
unreadable(opaque);
|
|
344
|
+
}
|
|
345
|
+
skipped.push(...bucketSkips);
|
|
346
|
+
// Rule 2, known before the provider: a value with text that cannot be translated is
|
|
347
|
+
// never re-translated in part — its whole bucket stays as it was (and spends no call).
|
|
348
|
+
if (whole && bucketSkips.length > 0)
|
|
349
|
+
continue;
|
|
350
|
+
if (bucketUnits.length === 0)
|
|
351
|
+
continue;
|
|
352
|
+
units.push(...bucketUnits);
|
|
353
|
+
for (const [id, plan] of bucketPlans)
|
|
354
|
+
richPlans.set(id, plan);
|
|
355
|
+
pending.push({ field, locale, base: conn.baseValue, current, kind: conn.connected ? 'fill' : 'replace' });
|
|
276
356
|
}
|
|
277
357
|
}
|
|
358
|
+
return {
|
|
359
|
+
plan: {
|
|
360
|
+
mode,
|
|
361
|
+
base,
|
|
362
|
+
targets,
|
|
363
|
+
buckets: pending.map(({ field, locale, kind }) => ({ field: field.name, locale, kind })),
|
|
364
|
+
skipped,
|
|
365
|
+
total: units.length,
|
|
366
|
+
},
|
|
367
|
+
units,
|
|
368
|
+
richPlans,
|
|
369
|
+
pending,
|
|
370
|
+
positionCtx,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
export async function translateDocument(input) {
|
|
374
|
+
const { provider, collection, document, localeConfig } = input;
|
|
375
|
+
// EC-752 (rule 4): the run executes the ONE pre-provider plan — the list the studio's
|
|
376
|
+
// confirm showed is the list the units come from.
|
|
377
|
+
const { plan, units, richPlans: plans, pending, positionCtx } = planRun(input);
|
|
378
|
+
const { mode, base, targets } = plan;
|
|
379
|
+
const whole = mode === 'retranslate-all';
|
|
380
|
+
const provenance = aiProvenance(provider, input.now ?? new Date().toISOString());
|
|
381
|
+
const lookupManifest = input.validateOptions?.lookupManifest;
|
|
382
|
+
const skipped = [...plan.skipped];
|
|
383
|
+
const skip = (field, locale, position, reason, at = position.at) => skipped.push(skipOf(field, locale, position, reason, at));
|
|
384
|
+
const slugFields = collection.fields.filter((f) => isSlugField(f, collection) && !isWriterLocked(f) && isFieldLocalized(collection, f.name));
|
|
278
385
|
const total = units.length;
|
|
279
386
|
// 2. Batch per locale, translate with bounded concurrency, honour the abort signal.
|
|
280
387
|
const batchSize = Math.max(1, input.batchSize ?? DEFAULT_TRANSLATE_BATCH_SIZE);
|
|
@@ -317,11 +424,12 @@ export async function translateDocument(input) {
|
|
|
317
424
|
// text position replaced (the traversal's writing fold), then slug derivation.
|
|
318
425
|
// `translated` / `perLocale` count applied UNITS (comparable to `total`).
|
|
319
426
|
let next = document;
|
|
320
|
-
// EC-595 A2: every bucket this run writes, emitted as its patch at the end
|
|
427
|
+
// EC-595 A2: every bucket this run writes, emitted as its patch at the end (EC-752:
|
|
428
|
+
// with what the write was — the outcome the studio reports).
|
|
321
429
|
const written = new Map();
|
|
322
|
-
const write = (field, locale, value) => {
|
|
430
|
+
const write = (field, locale, value, kind) => {
|
|
323
431
|
next = writeField(collection, next, field, locale, value, lookupManifest ? { lookupManifest } : {});
|
|
324
|
-
written.set(`${locale}|${field}`, { field, locale });
|
|
432
|
+
written.set(`${locale}|${field}`, { field, locale, kind });
|
|
325
433
|
};
|
|
326
434
|
const perLocale = {};
|
|
327
435
|
let translated = 0;
|
|
@@ -343,18 +451,24 @@ export async function translateDocument(input) {
|
|
|
343
451
|
'content',
|
|
344
452
|
...blockId.split('.').map(Number),
|
|
345
453
|
];
|
|
346
|
-
for (const { field, locale, base: baseValue } of pending) {
|
|
454
|
+
for (const { field, locale, base: baseValue, current, kind } of pending) {
|
|
347
455
|
let applied = 0;
|
|
456
|
+
// EC-752 rule 2: whether every unit of the bucket landed — a unit never sent (a cancel),
|
|
457
|
+
// not returned, or a block whose tokens did not survive makes it incomplete.
|
|
458
|
+
let complete = true;
|
|
348
459
|
// EC-593: the base value with each translated text position replaced — the
|
|
349
460
|
// converter module's writing fold; every other value is carried from the base.
|
|
350
461
|
const value = rebuildTextPositions(fieldValuePosition(field, baseValue, { locale: base }), positionCtx, (position) => {
|
|
351
462
|
const id = positionId(locale, field.name, position);
|
|
352
463
|
if (position.field.type === 'text') {
|
|
353
|
-
if (!attempted.has(id))
|
|
464
|
+
if (!attempted.has(id)) {
|
|
465
|
+
complete = false;
|
|
354
466
|
return position.value;
|
|
467
|
+
}
|
|
355
468
|
const text = returned(id);
|
|
356
469
|
if (text === undefined) {
|
|
357
470
|
skip(field.name, locale, position, 'not-returned');
|
|
471
|
+
complete = false;
|
|
358
472
|
return position.value;
|
|
359
473
|
}
|
|
360
474
|
applied += 1;
|
|
@@ -365,11 +479,15 @@ export async function translateDocument(input) {
|
|
|
365
479
|
return position.value;
|
|
366
480
|
const blockTranslations = new Map();
|
|
367
481
|
for (const [blockId, unit] of blocksOf.get(id) ?? []) {
|
|
368
|
-
if (!attempted.has(unit.id))
|
|
482
|
+
if (!attempted.has(unit.id)) {
|
|
483
|
+
complete = false;
|
|
369
484
|
continue;
|
|
485
|
+
}
|
|
370
486
|
const text = returned(unit.id);
|
|
371
|
-
if (text === undefined)
|
|
487
|
+
if (text === undefined) {
|
|
372
488
|
skip(field.name, locale, position, 'not-returned', blockAt(position, blockId));
|
|
489
|
+
complete = false;
|
|
490
|
+
}
|
|
373
491
|
else
|
|
374
492
|
blockTranslations.set(blockId, text);
|
|
375
493
|
}
|
|
@@ -378,6 +496,7 @@ export async function translateDocument(input) {
|
|
|
378
496
|
const rebuilt = rebuildRichTextReport(plan, blockTranslations);
|
|
379
497
|
for (const blockId of rebuilt.rejected) {
|
|
380
498
|
skip(field.name, locale, position, 'structure-changed', blockAt(position, blockId));
|
|
499
|
+
complete = false;
|
|
381
500
|
}
|
|
382
501
|
if (rebuilt.applied.length === 0)
|
|
383
502
|
return position.value;
|
|
@@ -385,14 +504,22 @@ export async function translateDocument(input) {
|
|
|
385
504
|
return rebuilt.value;
|
|
386
505
|
});
|
|
387
506
|
if (applied === 0)
|
|
388
|
-
continue; // nothing landed — the bucket stays
|
|
389
|
-
|
|
507
|
+
continue; // nothing landed — the bucket stays as it was
|
|
508
|
+
// EC-752 rule 2: a re-translation writes a field × locale only WHOLE. A part of it is
|
|
509
|
+
// never replaced by the base's text: the target keeps its value, the skips say why.
|
|
510
|
+
if (whole && !complete)
|
|
511
|
+
continue;
|
|
512
|
+
// EC-738: the target's locked positions, by key (judged in the plan).
|
|
513
|
+
write(field.name, locale, withLockedFrom(fieldValuePosition(field, value, { locale }), current, positionCtx), kind);
|
|
390
514
|
bump(locale, applied);
|
|
391
515
|
}
|
|
392
|
-
// 4. Slugs: derive each
|
|
393
|
-
// model-translated; only for a still-connected slug whose
|
|
394
|
-
//
|
|
395
|
-
// divergence, and an aborted run with no translations derives
|
|
516
|
+
// 4. Slugs: derive each per-locale slug that still follows the base from the (now
|
|
517
|
+
// translated) source. Never model-translated; only for a still-connected slug whose
|
|
518
|
+
// SOURCE field holds its own value in that locale (so the derived slug differs from the
|
|
519
|
+
// base — no pointless divergence, and an aborted run with no translations derives
|
|
520
|
+
// nothing). EC-752 rule 1 — in BOTH modes: a slug the editor set is never touched. A
|
|
521
|
+
// changed slug moves the page's address and nothing redirects the old one, so a
|
|
522
|
+
// re-translation silently changing the URL was the measured ship-stopper.
|
|
396
523
|
let slugsDerived = 0;
|
|
397
524
|
for (const field of slugFields) {
|
|
398
525
|
const sourceField = slugSourceField(field, collection);
|
|
@@ -400,7 +527,7 @@ export async function translateDocument(input) {
|
|
|
400
527
|
const conn = fieldConnection(collection, next, field.name, locale, localeConfig);
|
|
401
528
|
if (!conn)
|
|
402
529
|
continue;
|
|
403
|
-
if (
|
|
530
|
+
if (!conn.connected)
|
|
404
531
|
continue;
|
|
405
532
|
const sourceTranslated = sourceField
|
|
406
533
|
? hasLocaleVariant(next, sourceField, locale)
|
|
@@ -411,7 +538,7 @@ export async function translateDocument(input) {
|
|
|
411
538
|
const derived = autoDeriveSlug(field.slug, collection, valuesInLocale(collection, next, locale, localeConfig));
|
|
412
539
|
if (!derived || derived === conn.baseValue)
|
|
413
540
|
continue;
|
|
414
|
-
write(field.name, locale, derived);
|
|
541
|
+
write(field.name, locale, derived, 'slug');
|
|
415
542
|
slugsDerived += 1;
|
|
416
543
|
}
|
|
417
544
|
}
|
|
@@ -427,9 +554,17 @@ export async function translateDocument(input) {
|
|
|
427
554
|
...(input.validateOptions ?? {}),
|
|
428
555
|
};
|
|
429
556
|
const producedIssues = validateDocument(registry, next, validateOptions);
|
|
557
|
+
const writes = [...written.values()].map(({ field, locale, kind }) => ({
|
|
558
|
+
field,
|
|
559
|
+
locale,
|
|
560
|
+
kind,
|
|
561
|
+
patch: fieldWritePatch(collection, next, field, locale),
|
|
562
|
+
}));
|
|
430
563
|
return {
|
|
431
564
|
document: next,
|
|
432
|
-
|
|
565
|
+
plan,
|
|
566
|
+
writes,
|
|
567
|
+
patches: writes.map((entry) => entry.patch),
|
|
433
568
|
total,
|
|
434
569
|
translated,
|
|
435
570
|
cancelled,
|