@focus-reactive/payload-plugin-translator 0.10.4 → 0.11.1
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/README.md +199 -11
- package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.js +2 -2
- package/dist/client/widgets/translate-document/ui/TranslateDocument.server.js +2 -2
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.js +10 -7
- package/dist/core/translation-pipeline/stages/field-collector/FieldChunkCollector.js +0 -5
- package/dist/index.d.ts +8 -3
- package/dist/index.js +4 -3
- package/dist/server/features/translate-document/handler.d.ts +1 -0
- package/dist/server/features/translate-document/handler.js +55 -46
- package/dist/server/features/translate-document/targetLayer.d.ts +37 -0
- package/dist/server/features/translate-document/targetLayer.js +26 -0
- package/dist/server/features/translate-field/handler.js +4 -10
- package/dist/server/modules/auto-translate/AutoTranslateEnqueue.hook.js +2 -1
- package/dist/server/shared/guards/index.d.ts +0 -1
- package/dist/server/shared/guards/index.js +0 -1
- package/dist/server/shared/payload/sourceDocument.d.ts +2 -4
- package/dist/server/shared/payload/sourceDocument.js +10 -5
- package/dist/translation-providers/index.d.ts +1 -0
- package/dist/translation-providers/index.js +1 -7
- package/dist/translation-providers/openai/OpenAI.shapes.d.ts +52 -0
- package/dist/translation-providers/openai/OpenAI.shapes.js +13 -0
- package/dist/translation-providers/openai/OpenAITranslation.provider.d.ts +51 -112
- package/dist/translation-providers/openai/OpenAITranslation.provider.js +49 -137
- package/dist/translation-providers/openai/OpenAITranslationLegacy.provider.d.ts +11 -0
- package/dist/translation-providers/openai/OpenAITranslationLegacy.provider.js +15 -0
- package/dist/translation-providers/openai/index.d.ts +6 -1
- package/dist/translation-providers/openai/index.js +3 -3
- package/dist/translation-providers/openai/loadOpenAIClient.d.ts +24 -0
- package/dist/translation-providers/openai/loadOpenAIClient.js +79 -0
- package/dist/translation-providers/openai/openAIComplete.d.ts +39 -0
- package/dist/translation-providers/openai/openAIComplete.js +84 -0
- package/dist/translation-providers/shared/CompletionProvider.provider.d.ts +65 -0
- package/dist/translation-providers/shared/CompletionProvider.provider.js +93 -0
- package/dist/translation-providers/shared/buildResponseSchema.d.ts +13 -0
- package/dist/translation-providers/shared/buildResponseSchema.js +22 -0
- package/dist/translation-providers/shared/buildSystemPrompt.d.ts +28 -0
- package/dist/translation-providers/shared/buildSystemPrompt.js +25 -0
- package/dist/translation-providers/shared/errors/KeySetMismatchError.d.ts +15 -0
- package/dist/translation-providers/shared/errors/KeySetMismatchError.js +26 -0
- package/dist/translation-providers/shared/errors/NoContentError.d.ts +9 -0
- package/dist/translation-providers/shared/errors/NoContentError.js +10 -0
- package/dist/translation-providers/shared/errors/ProviderConfigurationError.d.ts +10 -0
- package/dist/translation-providers/shared/errors/ProviderConfigurationError.js +11 -0
- package/dist/translation-providers/shared/errors/TranslationProviderError.d.ts +18 -0
- package/dist/translation-providers/shared/errors/TranslationProviderError.js +21 -0
- package/dist/translation-providers/shared/errors/TransportError.d.ts +10 -0
- package/dist/translation-providers/shared/errors/TransportError.js +11 -0
- package/dist/translation-providers/shared/errors/UnparseableReplyError.d.ts +9 -0
- package/dist/translation-providers/shared/errors/UnparseableReplyError.js +10 -0
- package/dist/translation-providers/shared/errors/errorMessageLower.d.ts +1 -0
- package/dist/translation-providers/shared/errors/errorMessageLower.js +8 -0
- package/dist/translation-providers/shared/errors/index.d.ts +9 -0
- package/dist/translation-providers/shared/errors/index.js +10 -0
- package/dist/translation-providers/shared/errors/wrapTransportError.d.ts +7 -0
- package/dist/translation-providers/shared/errors/wrapTransportError.js +14 -0
- package/dist/translation-providers/shared/index.d.ts +8 -0
- package/dist/translation-providers/shared/index.js +5 -0
- package/dist/translation-providers/shared/parseAndValidateReply.d.ts +13 -0
- package/dist/translation-providers/shared/parseAndValidateReply.js +46 -0
- package/dist/translation-providers/shared/runDryRun.d.ts +20 -0
- package/dist/translation-providers/shared/runDryRun.js +17 -0
- package/package.json +1 -1
- package/dist/server/shared/guards/collection-guards.d.ts +0 -6
- package/dist/server/shared/guards/collection-guards.js +0 -16
package/README.md
CHANGED
|
@@ -205,6 +205,45 @@ translatorPlugin({
|
|
|
205
205
|
});
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
+
### Drafts and publishing
|
|
209
|
+
|
|
210
|
+
Applies to every way a translation is triggered — the admin controls, `POST /translate/enqueue`, and
|
|
211
|
+
auto-translate. It matters most if your collections have `versions.drafts` enabled.
|
|
212
|
+
|
|
213
|
+
**Without publish-on-translation**, the translation is written as a **draft version**. The document's
|
|
214
|
+
published state is left alone: a live page stays live, an unpublished one stays unpublished, and the
|
|
215
|
+
translated locale does not appear on the public site until someone publishes it.
|
|
216
|
+
|
|
217
|
+
**With publish-on-translation**, the translation is written as a draft and the target locale is then
|
|
218
|
+
published — a separate step, so it happens whether or not any field actually needed translating.
|
|
219
|
+
Only the locale that was translated is published; other locales keep whatever state they were in.
|
|
220
|
+
Translating a document that is not currently published does make the document live, with just that
|
|
221
|
+
locale's content in it.
|
|
222
|
+
|
|
223
|
+
A translation is taken from **the source locale's own current content** — the newer draft when one
|
|
224
|
+
exists, else the published row, never a value Payload substitutes from another locale. Translating
|
|
225
|
+
*from* a locale you have not filled in therefore translates nothing.
|
|
226
|
+
|
|
227
|
+
Two consequences worth knowing before you rely on them:
|
|
228
|
+
|
|
229
|
+
- **Publishing publishes the current draft, whatever is in it.** A translation is based on the
|
|
230
|
+
version the editor sees, and publishing puts that live — including pending edits nobody made for
|
|
231
|
+
the translation's sake, and including non-localized fields, which Payload stores once per document
|
|
232
|
+
and so cannot scope to a locale. That is what the flag asks for, but it is worth remembering
|
|
233
|
+
before running "translate and publish" over a long list of documents: every unpublished draft
|
|
234
|
+
among them goes live.
|
|
235
|
+
- **`skip_existing` counts anything non-empty as translated.** A translation waiting unpublished in
|
|
236
|
+
a draft counts, so a reviewer's corrected text is published as it stands rather than
|
|
237
|
+
re-translated. It has no notion of *reviewed*, and it does not consult stale-detection — a locale
|
|
238
|
+
the admin marks out of date is still skipped
|
|
239
|
+
([#118](https://github.com/focusreactive/payload-plugins/issues/118)).
|
|
240
|
+
|
|
241
|
+
> **Changed in 0.11.1.** Before this, translating one locale as a draft unpublished the document in
|
|
242
|
+
> every locale, and translating one locale with publishing pushed every other locale's unpublished
|
|
243
|
+
> draft live ([#102](https://github.com/focusreactive/payload-plugins/issues/102)). The source was
|
|
244
|
+
> also read from the published row with fallbacks, so translating from an empty locale translated
|
|
245
|
+
> the default locale's text.
|
|
246
|
+
|
|
208
247
|
### Stale-translation detection
|
|
209
248
|
|
|
210
249
|
_Since v0.8.0._
|
|
@@ -221,6 +260,10 @@ Dismiss acknowledges the drift without re-translating; the marker stays hidden u
|
|
|
221
260
|
changes again. When `provenance` is disabled nothing is shown. Note the fingerprint is text-only, so
|
|
222
261
|
formatting-only edits to rich text do not mark a locale stale.
|
|
223
262
|
|
|
263
|
+
> **Upgrading to 0.11.1.** Records written earlier fingerprinted the source differently, so a locale
|
|
264
|
+
> can read out of date once after upgrading with nothing actually needing re-translation. Dismissing
|
|
265
|
+
> the marker or re-translating settles it.
|
|
266
|
+
|
|
224
267
|
### Auto-translate on source change
|
|
225
268
|
|
|
226
269
|
_Since v0.9.0._
|
|
@@ -249,9 +292,12 @@ translatorPlugin({
|
|
|
249
292
|
Behaviour: fires only on a **published** source save (draft/autosave saves are ignored; a collection
|
|
250
293
|
without drafts treats every save as published); skips when no translatable content actually changed
|
|
251
294
|
(same fingerprint as stale-detection); coalesces rapid edits via `debounceMs`; the translation is saved
|
|
252
|
-
with the source document's status
|
|
295
|
+
with the source document's status, scoped to **only the translated locale**; never re-triggers on its
|
|
253
296
|
own translation writes; and never fails the editor's save (best-effort — failures are logged).
|
|
254
297
|
|
|
298
|
+
> See [Drafts and publishing](#drafts-and-publishing) for what a translation does to a document's
|
|
299
|
+
> published state.
|
|
300
|
+
|
|
255
301
|
> **Requires a working job runner.** Auto-translate only **enqueues** jobs — they run via the task
|
|
256
302
|
> runner (`createPayloadJobsRunner`) and its autorun loop. On serverless platforms such as **Vercel**,
|
|
257
303
|
> cron-based autorun may not run automatically, so enqueued translations can sit unexecuted until
|
|
@@ -288,21 +334,86 @@ translatorPlugin({
|
|
|
288
334
|
|
|
289
335
|
#### OpenAI (built in) — `createOpenAIProvider(config)`
|
|
290
336
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
|
294
|
-
|
|
|
295
|
-
| `
|
|
296
|
-
| `
|
|
297
|
-
| `
|
|
298
|
-
| `
|
|
337
|
+
Pass **either** an `apiKey` **or** a ready-made `client` — never both; the types enforce it.
|
|
338
|
+
|
|
339
|
+
| Property | Type | Required | Default | Description |
|
|
340
|
+
| -------------- | ------------------------- | ------------------ | -------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
341
|
+
| `apiKey` | `string` | Unless `client` | — | OpenAI API key. The `openai` package is loaded on first translation, not at config load. _Since v0.11.0: optional when `client` is given._ |
|
|
342
|
+
| `client` | `OpenAIClientShape` | Unless `apiKey` | — | Your own client — Azure OpenAI, a corporate proxy, OpenRouter, anything with a matching `chat.completions.create`. On this path the `openai` package is never loaded. _Since v0.11.0._ |
|
|
343
|
+
| `model` | `string` | No | `'gpt-4o'` | Model used for translation. **The default may change in a minor release** — pin it if you need reproducible output and cost. |
|
|
344
|
+
| `systemPrompt` | `SystemPromptBuilder` | No | Built-in prompt | Custom system-prompt builder. |
|
|
345
|
+
| `dryRun` | `boolean \| DryRunConfig` | No | `false` | **Deprecated** — see the note below. Simulates translations without API calls, but still writes, publishes and records provenance. |
|
|
346
|
+
| `sampling` | `OpenAISamplingParams` | No | not sent | `temperature`, `top_p`, `frequency_penalty`, `presence_penalty`. Omitted entirely unless set — several models reject them. **Before v0.11.0 this package always sent `temperature: 0, top_p: 1, frequency_penalty: 0, presence_penalty: 0`**, so translations were deterministic; they now follow the model's defaults. Set `{ temperature: 0 }` to restore that. _Since v0.11.0._ |
|
|
347
|
+
| `structuredOutput` | `"json_schema" \| "json_object"` | No | `"json_schema"` | Which structured-output envelope to send. See the note below — the two carry different risks. _Since v0.11.0._ |
|
|
348
|
+
| `timeout` | `number` | No | `60000` | Per-request timeout (ms) for the client this package builds. Ignored when you pass your own `client`. _Since v0.6.0; the default dropped from the SDK's 10 minutes to 60 s in v0.11.0._ |
|
|
349
|
+
| `maxRetries` | `number` | No | SDK default (2) | Max automatic retries on transient errors (429/5xx/network) for the client this package builds. `0` disables. Ignored when you pass your own `client`. _Since v0.6.0._ |
|
|
350
|
+
|
|
351
|
+
> **`createOpenAIProvider` is deprecated and goes away in the next major.** What it adds over
|
|
352
|
+
> `openAIComplete` is building the SDK client for you — and carrying `openai` as an optional
|
|
353
|
+
> dependency of ours to do it, which is where the cost is: a lazy import shaped around deployment
|
|
354
|
+
> file-tracers, and a classifier telling "not installed" from "installed but broken" across four
|
|
355
|
+
> runtimes. Construct the client yourself instead and keep everything else:
|
|
356
|
+
>
|
|
357
|
+
> ```typescript
|
|
358
|
+
> import OpenAI from "openai";
|
|
359
|
+
> import {
|
|
360
|
+
> createTranslationProvider,
|
|
361
|
+
> openAIComplete,
|
|
362
|
+
> } from "@focus-reactive/payload-plugin-translator";
|
|
363
|
+
>
|
|
364
|
+
> // The SDK's own default timeout is ten minutes — far too long for a live edit.
|
|
365
|
+
> const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, timeout: 60_000 });
|
|
366
|
+
>
|
|
367
|
+
> const translationProvider = createTranslationProvider({
|
|
368
|
+
> complete: openAIComplete({ client, model: "gpt-4o" }),
|
|
369
|
+
> });
|
|
370
|
+
> ```
|
|
371
|
+
>
|
|
372
|
+
> `openAIComplete` stays: the request body, the `structuredOutput` choice below, and the message
|
|
373
|
+
> naming that option when a gateway rejects a strict schema are all still ours. What becomes yours
|
|
374
|
+
> is the SDK version and the client's own settings, the timeout most of all.
|
|
375
|
+
> See [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md#openai-client-construction).
|
|
376
|
+
|
|
377
|
+
#### Choosing a structured-output envelope
|
|
378
|
+
|
|
379
|
+
`json_schema` (the default) sends the request with a schema the reply must satisfy, so a compliant
|
|
380
|
+
model **cannot** drop a requested field. That is what closed the silent half-translation defect. It
|
|
381
|
+
costs two things:
|
|
382
|
+
|
|
383
|
+
- Older models, and some gateways (OpenRouter with certain upstream models, older Azure
|
|
384
|
+
deployments, self-hosted proxies), reject it with a 400. You do not need to know which: the error
|
|
385
|
+
names this option as the fix.
|
|
386
|
+
- The schema has a size limit, so **one request carries a limited number of pieces of text**, and a
|
|
387
|
+
document past that ceiling fails as a whole. The schema names one property per translatable piece,
|
|
388
|
+
and rich text is split one piece per text node — a sentence with two emphasised spans is already
|
|
389
|
+
four pieces — so the count grows faster than "one per field" suggests, and a document is never
|
|
390
|
+
split across requests. The ceiling differs by model and moves over time; measure it against your
|
|
391
|
+
largest documents rather than assuming headroom.
|
|
392
|
+
|
|
393
|
+
`json_object` asks only for valid JSON. There is no schema, so no ceiling — but key preservation
|
|
394
|
+
falls back to this package's key-set check, which **detects** a dropped key instead of preventing
|
|
395
|
+
it. A reply missing one key out of two hundred still writes the other 199, the gap is reported to
|
|
396
|
+
the server log, and only a reply matching nothing at all fails. Your editors never see that log, so
|
|
397
|
+
a dropped field looks translated in the admin UI.
|
|
398
|
+
|
|
399
|
+
Pick by which risk you would rather carry: a hard failure on very large documents, or a quiet gap on
|
|
400
|
+
any document.
|
|
299
401
|
|
|
300
402
|
```typescript
|
|
403
|
+
// Quick start
|
|
301
404
|
createOpenAIProvider({
|
|
302
405
|
apiKey: process.env.OPENAI_API_KEY,
|
|
303
406
|
model: "gpt-4o-mini",
|
|
304
407
|
systemPrompt: ({ sourceLang, targetLang, defaultPrompt }) => `${defaultPrompt}\nUse formal language. Keep brand names unchanged.`,
|
|
305
408
|
});
|
|
409
|
+
|
|
410
|
+
// Your own client — Azure, a proxy, OpenRouter. Since v0.11.0.
|
|
411
|
+
import OpenAI from "openai";
|
|
412
|
+
|
|
413
|
+
createOpenAIProvider({
|
|
414
|
+
client: new OpenAI({ apiKey: process.env.AZURE_KEY, baseURL: process.env.AZURE_ENDPOINT }),
|
|
415
|
+
model: "gpt-4o",
|
|
416
|
+
});
|
|
306
417
|
```
|
|
307
418
|
|
|
308
419
|
`systemPrompt` receives `{ sourceLang, targetLang, defaultPrompt }` and returns the prompt string. When `dryRun` is an object it can transform text locally with an optional delay:
|
|
@@ -314,6 +425,81 @@ type DryRunConfig = {
|
|
|
314
425
|
};
|
|
315
426
|
```
|
|
316
427
|
|
|
428
|
+
> **`dryRun` is deprecated and will be removed in the next major.** It skips the network call and
|
|
429
|
+
> nothing else: the transformed strings are still written to the target locale, still published when
|
|
430
|
+
> `publishOnTranslation` is set, and still recorded as provenance — after which the locale reads as
|
|
431
|
+
> up to date and no re-translation is prompted. Use your own fake instead, which is explicit about
|
|
432
|
+
> being one:
|
|
433
|
+
>
|
|
434
|
+
> ```typescript
|
|
435
|
+
> import { createTranslationProvider } from "@focus-reactive/payload-plugin-translator";
|
|
436
|
+
>
|
|
437
|
+
> const fakeProvider = createTranslationProvider({
|
|
438
|
+
> complete: async ({ userContent }) => {
|
|
439
|
+
> const input = JSON.parse(userContent) as Record<string, string>;
|
|
440
|
+
> const reversed: Record<string, string> = {};
|
|
441
|
+
> for (const [key, value] of Object.entries(input)) {
|
|
442
|
+
> reversed[key] = value.trim() ? [...value].reverse().join("") : value;
|
|
443
|
+
> }
|
|
444
|
+
> return JSON.stringify(reversed);
|
|
445
|
+
> },
|
|
446
|
+
> });
|
|
447
|
+
> ```
|
|
448
|
+
>
|
|
449
|
+
> `complete` returns the reply as raw text, exactly as a service would; parsing and validation stay
|
|
450
|
+
> on our side. See [docs/DEPRECATIONS.md](docs/DEPRECATIONS.md#provider-dry-run).
|
|
451
|
+
|
|
452
|
+
#### Another service — `createTranslationProvider(config)`
|
|
453
|
+
|
|
454
|
+
_Since v0.11.0._
|
|
455
|
+
|
|
456
|
+
Need a different model provider, or full control of the request body? Supply one function — "send this text, give me the reply" — and keep everything else. The prompt, the response schema, reply parsing, key-set validation, dry-run simulation and the failure taxonomy all stay on our side, so you cannot accidentally skip them.
|
|
457
|
+
|
|
458
|
+
For OpenAI specifically you do not have to write that function: `openAIComplete({ client, model })` is one, built from a client you constructed. _Since v0.11.0._
|
|
459
|
+
|
|
460
|
+
```typescript
|
|
461
|
+
import { createTranslationProvider } from "@focus-reactive/payload-plugin-translator";
|
|
462
|
+
|
|
463
|
+
const provider = createTranslationProvider({
|
|
464
|
+
complete: async ({ systemPrompt, userContent, responseSchema }) => {
|
|
465
|
+
const reply = await myService.chat({
|
|
466
|
+
system: systemPrompt,
|
|
467
|
+
user: userContent,
|
|
468
|
+
schema: responseSchema, // hand this to whatever structured-output mechanism your service offers
|
|
469
|
+
});
|
|
470
|
+
return reply.text; // raw text, not a parsed object — we parse it
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
> `signal` is reserved and currently always `undefined` — the port does not carry cancellation yet, so wiring it into your client is harmless but has no effect today. It is in the request shape so that adding cancellation later is a pure addition rather than a breaking change.
|
|
476
|
+
|
|
477
|
+
| Property | Type | Required | Default | Description |
|
|
478
|
+
| -------------- | ------------------------- | -------- | --------------- | -------------------------------------------------- |
|
|
479
|
+
| `complete` | `CompletionFn` | Yes | — | Sends one request, returns the reply as raw text. |
|
|
480
|
+
| `systemPrompt` | `SystemPromptBuilder` | No | Built-in prompt | Custom system-prompt builder. |
|
|
481
|
+
| `dryRun` | `boolean \| DryRunConfig` | No | `false` | **Deprecated** — supply your own fake `complete`. |
|
|
482
|
+
|
|
483
|
+
Your `complete` owns the timeout, the retry policy and the credentials — this package adds no retry of its own and imposes no timeout on your call.
|
|
484
|
+
|
|
485
|
+
#### Failure causes
|
|
486
|
+
|
|
487
|
+
_Since v0.11.0._
|
|
488
|
+
|
|
489
|
+
Every built-in provider throws a typed error naming what went wrong, instead of returning `null`:
|
|
490
|
+
|
|
491
|
+
| Error | `code` | Means |
|
|
492
|
+
| --------------------------- | -------------------- | ---------------------------------------------------------------------- |
|
|
493
|
+
| `NoContentError` | `no-content` | The reply was empty, or the service filtered it. |
|
|
494
|
+
| `UnparseableReplyError` | `unparseable-reply` | The reply was not JSON, or not an object. |
|
|
495
|
+
| `KeySetMismatchError` | `key-set-mismatch` | The reply answered none of the requested fields. |
|
|
496
|
+
| `TransportError` | `transport` | The call failed — network, auth, rate limit, timeout. |
|
|
497
|
+
| `ProviderConfigurationError`| `config` | The provider cannot work as configured (usually a missing optional SDK). |
|
|
498
|
+
|
|
499
|
+
All extend `TranslationProviderError`, so one `catch` covers them. The original failure is on the standard `cause` property — never copied into `message`, because that text can reach an HTTP response body and a vendor error may carry your API key.
|
|
500
|
+
|
|
501
|
+
A **partial** reply is not an error: the fields that came back are applied, and the ones that did not are named in a warning. That is deliberate — dropping good translations because one field is missing helps nobody — but it does mean a partial translation still completes.
|
|
502
|
+
|
|
317
503
|
#### Custom provider
|
|
318
504
|
|
|
319
505
|
Implement the `TranslationProvider` interface — a single `translate` method:
|
|
@@ -341,8 +527,10 @@ class DeepLProvider implements TranslationProvider {
|
|
|
341
527
|
result[key] = data.translations[i].text;
|
|
342
528
|
});
|
|
343
529
|
return result;
|
|
344
|
-
} catch {
|
|
345
|
-
|
|
530
|
+
} catch (cause) {
|
|
531
|
+
// Throwing is preferred — the cause reaches the log and the editor sees why it failed.
|
|
532
|
+
// Returning `null` still works and still aborts the whole run, but it says nothing about why.
|
|
533
|
+
throw new Error("DeepL translation failed", { cause });
|
|
346
534
|
}
|
|
347
535
|
}
|
|
348
536
|
}
|
package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { headers as getHeaders } from "next/headers";
|
|
3
|
-
import {
|
|
3
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
4
4
|
import { resolveAutoTranslateSummary } from "../../../entities/translation/model/autoTranslateSummary";
|
|
5
5
|
import BulkTranslationDashboard from "./BulkTranslationDashboard";
|
|
6
6
|
const BulkTranslationDashboardServer = async (props)=>{
|
|
@@ -15,7 +15,7 @@ const BulkTranslationDashboardServer = async (props)=>{
|
|
|
15
15
|
if (!hasAccess) return null;
|
|
16
16
|
if (!props.collectionSlug) return null;
|
|
17
17
|
const collection = props.payload.collections[props.collectionSlug]?.config;
|
|
18
|
-
const hasDrafts = collection ?
|
|
18
|
+
const hasDrafts = collection ? hasDraftsEnabled(collection) : false;
|
|
19
19
|
const autoTranslate = resolveAutoTranslateSummary(collection, props.payload.config.localization ? props.payload.config.localization.defaultLocale : undefined);
|
|
20
20
|
return /*#__PURE__*/ _jsx(BulkTranslationDashboard, {
|
|
21
21
|
hasDrafts: hasDrafts,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { headers as getHeaders } from "next/headers";
|
|
3
|
-
import {
|
|
3
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
4
4
|
import { resolveAutoTranslateSummary } from "../../../entities/translation/model/autoTranslateSummary";
|
|
5
5
|
import TranslateDocument from "./TranslateDocument";
|
|
6
6
|
async function TranslateDocumentServer(props) {
|
|
@@ -14,7 +14,7 @@ async function TranslateDocumentServer(props) {
|
|
|
14
14
|
});
|
|
15
15
|
if (!hasAccess) return null;
|
|
16
16
|
if (!props.id) return null;
|
|
17
|
-
const hasDrafts =
|
|
17
|
+
const hasDrafts = hasDraftsEnabled(props.collection);
|
|
18
18
|
const autoTranslate = resolveAutoTranslateSummary(props.collection, props.payload.config.localization ? props.payload.config.localization.defaultLocale : undefined);
|
|
19
19
|
return /*#__PURE__*/ _jsx(TranslateDocument, {
|
|
20
20
|
hasDrafts: hasDrafts,
|
|
@@ -47,8 +47,11 @@ import styles from "./styles.module.scss";
|
|
|
47
47
|
const { mutateAsync, isPending } = useTranslateField();
|
|
48
48
|
// `config.localization` is `false | {…}`, so the truthy check is load-bearing (not just a nil guard).
|
|
49
49
|
const defaultLocale = config.localization ? config.localization.defaultLocale : undefined;
|
|
50
|
+
// `useLocale()` is typed `Locale` but actually returns `false | {} | Locale` until the admin's
|
|
51
|
+
// locale provider resolves (see the V4 TODO on the hook in @payloadcms/ui), so `code` can be absent.
|
|
52
|
+
const targetCode = locale?.code ?? "";
|
|
50
53
|
const [isOpen, popup] = useToggle();
|
|
51
|
-
const [sourceLng, setSourceLng] = useState(()=>defaultLocale && defaultLocale !==
|
|
54
|
+
const [sourceLng, setSourceLng] = useState(()=>defaultLocale && defaultLocale !== targetCode ? defaultLocale : "");
|
|
52
55
|
// `null` = no undo available; an object wraps the value so a legitimately `undefined` field value
|
|
53
56
|
// (a real pre-translation state) stays distinguishable from "nothing to undo".
|
|
54
57
|
const [undo, setUndo] = useState(null);
|
|
@@ -57,7 +60,7 @@ import styles from "./styles.module.scss";
|
|
|
57
60
|
// once translating the unsaved value in place is supported.) All hooks above run unconditionally.
|
|
58
61
|
if (id === undefined || id === null) return null;
|
|
59
62
|
// Source options: every locale except the one being edited (it's the fixed target).
|
|
60
|
-
const sourceLocaleOptions = localeOptions.filter((option)=>option.value !==
|
|
63
|
+
const sourceLocaleOptions = localeOptions.filter((option)=>option.value !== targetCode);
|
|
61
64
|
const canTranslate = sourceLng !== "" && !isPending;
|
|
62
65
|
// One write path for every supported field type: a form UPDATE bumping value AND initialValue.
|
|
63
66
|
// A Lexical (richText) editor re-mounts on the initialValue change so it shows the new content;
|
|
@@ -79,7 +82,7 @@ import styles from "./styles.module.scss";
|
|
|
79
82
|
const { data } = await mutateAsync({
|
|
80
83
|
collectionSlug,
|
|
81
84
|
fieldPath: path,
|
|
82
|
-
targetLng:
|
|
85
|
+
targetLng: targetCode,
|
|
83
86
|
sourceLng,
|
|
84
87
|
docId: id
|
|
85
88
|
});
|
|
@@ -132,7 +135,7 @@ import styles from "./styles.module.scss";
|
|
|
132
135
|
className: styles.row,
|
|
133
136
|
children: [
|
|
134
137
|
/*#__PURE__*/ _jsxs("div", {
|
|
135
|
-
"aria-label": `Translation direction: from ${sourceLng || "a source locale"} into ${
|
|
138
|
+
"aria-label": `Translation direction: from ${sourceLng || "a source locale"} into ${targetCode}`,
|
|
136
139
|
className: styles.direction,
|
|
137
140
|
role: "group",
|
|
138
141
|
children: [
|
|
@@ -161,9 +164,9 @@ import styles from "./styles.module.scss";
|
|
|
161
164
|
children: "→"
|
|
162
165
|
}),
|
|
163
166
|
/*#__PURE__*/ _jsx("span", {
|
|
164
|
-
"aria-label": `Target locale: ${
|
|
167
|
+
"aria-label": `Target locale: ${targetCode} (the locale you're editing)`,
|
|
165
168
|
className: styles.current,
|
|
166
|
-
children:
|
|
169
|
+
children: targetCode.toLowerCase()
|
|
167
170
|
})
|
|
168
171
|
]
|
|
169
172
|
}),
|
|
@@ -176,7 +179,7 @@ import styles from "./styles.module.scss";
|
|
|
176
179
|
$isIconButton: true,
|
|
177
180
|
$isLoading: isPending,
|
|
178
181
|
disabled: !canTranslate,
|
|
179
|
-
"aria-label": `Translate from ${sourceLng || "the selected locale"} into ${
|
|
182
|
+
"aria-label": `Translate from ${sourceLng || "the selected locale"} into ${targetCode}`,
|
|
180
183
|
onClick: handleTranslate,
|
|
181
184
|
children: /*#__PURE__*/ _jsx(SendIcon, {})
|
|
182
185
|
})
|
|
@@ -30,9 +30,6 @@ const asObject = (value)=>isObject(value) ? value : {};
|
|
|
30
30
|
this.strategy = strategy;
|
|
31
31
|
}
|
|
32
32
|
/** Collects translatable field chunks that need translation. */ collect() {
|
|
33
|
-
// The read walk SELECTS translatable leaves (via the shared selection core) and records, per
|
|
34
|
-
// selected leaf, the source value to translate plus its write target. The mutation is applied
|
|
35
|
-
// in a separate explicit pass below — read and write are no longer fused inside the walk.
|
|
36
33
|
const selected = [];
|
|
37
34
|
const chunks = [];
|
|
38
35
|
const { strategy } = this;
|
|
@@ -119,8 +116,6 @@ const asObject = (value)=>isObject(value) ? value : {};
|
|
|
119
116
|
target: this.targetData,
|
|
120
117
|
path: []
|
|
121
118
|
}, walker);
|
|
122
|
-
// Apply pass: write each selected leaf's source value into filteredData — this is what gets
|
|
123
|
-
// translated. Kept separate from the read walk above so selection stays read-only.
|
|
124
119
|
for (const { dataRef, key, sourceValue } of selected){
|
|
125
120
|
dataRef[key] = sourceValue;
|
|
126
121
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -5,8 +5,12 @@ export type { TranslationTask, TranslationLifecycleCallbacks } from "./server/mo
|
|
|
5
5
|
export type { TranslationProvenanceRecord } from "./core";
|
|
6
6
|
export type { AccessGuard, AccessGuardRequest } from "./types/AccessGuard";
|
|
7
7
|
export type { TranslationProvider, TranslationInput, TranslationOutput } from "./core";
|
|
8
|
-
export {
|
|
9
|
-
export type {
|
|
8
|
+
export { openAIComplete } from "./translation-providers";
|
|
9
|
+
export type { OpenAIClientShape, OpenAISamplingParams, OpenAIStructuredOutput, DryRunConfig, } from "./translation-providers";
|
|
10
|
+
export { createTranslationProvider } from "./translation-providers";
|
|
11
|
+
export type { CompletionFn, CompletionRequest, TranslationProviderConfig, JsonSchemaObject, SystemPromptBuilder, SystemPromptContext, } from "./translation-providers";
|
|
12
|
+
export { TranslationProviderError, NoContentError, UnparseableReplyError, KeySetMismatchError, TransportError, ProviderConfigurationError, } from "./translation-providers";
|
|
13
|
+
export type { TranslationFailureCode } from "./translation-providers";
|
|
10
14
|
export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
|
|
11
15
|
export type { TaskRunnerProvider, PayloadJobsRunnerOptions } from "./server/modules/task-runner";
|
|
12
16
|
export { documentLevel, collectionLevel, fieldLevel } from "./composition/levels";
|
|
@@ -17,6 +21,7 @@ export { withAutoTranslate } from "./auto-translate-config";
|
|
|
17
21
|
export type { AutoTranslateConfig, AutoTranslateStrategy } from "./auto-translate-config";
|
|
18
22
|
export { createTranslatePlugin, TranslateCollectionPlugin } from "./plugin";
|
|
19
23
|
export type { TranslateCollectionPluginConfig } from "./plugin";
|
|
20
|
-
export { OpenAITranslationProvider } from "./translation-providers";
|
|
24
|
+
export { createOpenAIProvider, OpenAITranslationProvider } from "./translation-providers";
|
|
25
|
+
export type { OpenAIProviderConfig } from "./translation-providers";
|
|
21
26
|
export { translateKitField } from "./field-config";
|
|
22
27
|
export type { TranslateKitFieldConfig } from "./field-config";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Main plugin
|
|
2
2
|
export { translatorPlugin } from "./plugin";
|
|
3
|
-
|
|
4
|
-
export {
|
|
3
|
+
export { openAIComplete } from "./translation-providers";
|
|
4
|
+
export { createTranslationProvider } from "./translation-providers";
|
|
5
|
+
export { TranslationProviderError, NoContentError, UnparseableReplyError, KeySetMismatchError, TransportError, ProviderConfigurationError } from "./translation-providers";
|
|
5
6
|
// Task runners
|
|
6
7
|
export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
|
|
7
8
|
// Translation levels
|
|
@@ -14,7 +15,7 @@ export { withFieldTranslation } from "./field-config";
|
|
|
14
15
|
export { withAutoTranslate } from "./auto-translate-config";
|
|
15
16
|
// Deprecated exports (for backwards compatibility)
|
|
16
17
|
export { createTranslatePlugin, TranslateCollectionPlugin } from "./plugin";
|
|
17
|
-
export { OpenAITranslationProvider } from "./translation-providers";
|
|
18
|
+
export { createOpenAIProvider, OpenAITranslationProvider } from "./translation-providers";
|
|
18
19
|
export { translateKitField } from "./field-config";
|
|
19
20
|
|
|
20
21
|
//# sourceMappingURL=index.js.map
|
|
@@ -16,4 +16,5 @@ export declare class TranslateDocumentHandler implements Handler<TranslateDocume
|
|
|
16
16
|
constructor(translationProvider: TranslationProvider, schemaMap: CollectionSchemaMap, provenanceServiceFactory?: ProvenanceServiceFactory);
|
|
17
17
|
handle(payload: Payload, input: TranslateDocumentInput): Promise<TranslateDocumentOutput>;
|
|
18
18
|
private saveTranslatedDocument;
|
|
19
|
+
private publishTargetLocale;
|
|
19
20
|
}
|
|
@@ -2,6 +2,10 @@ import { APIError } from "payload";
|
|
|
2
2
|
import { translateContent } from "../../../core/translation-pipeline";
|
|
3
3
|
import { fetchSourceDocument } from "../../shared/payload/sourceDocument";
|
|
4
4
|
import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext";
|
|
5
|
+
import { resolveTargetLayer } from "./targetLayer";
|
|
6
|
+
/** Loop guard: the auto-translate afterChange hook (#51) skips writes carrying this key. */ const translatorWriteContext = ()=>({
|
|
7
|
+
[AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true
|
|
8
|
+
});
|
|
5
9
|
/**
|
|
6
10
|
* Translates a single document from source language to target language. Provenance is delegated to
|
|
7
11
|
* {@link ProvenanceService}: this handler only decides *when* to capture the source fingerprint
|
|
@@ -17,71 +21,76 @@ import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateCon
|
|
|
17
21
|
}
|
|
18
22
|
async handle(payload, input) {
|
|
19
23
|
const { collection, collectionId, sourceLng, targetLng, strategy, publishOnTranslation } = input;
|
|
20
|
-
// Get original schema (preserves localized: true on nested fields)
|
|
21
24
|
const schema = this.schemaMap.get(collection);
|
|
22
25
|
if (!schema) throw new APIError(`Collection "${collection}" not found in schemaMap`, 400);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
26
|
+
const layer = resolveTargetLayer({
|
|
27
|
+
versions: payload.collections[collection].config.versions,
|
|
28
|
+
targetLng
|
|
29
|
+
});
|
|
30
|
+
// `draft: true` is unconditional: on a collection without drafts Payload has no version to
|
|
31
|
+
// substitute, so it returns the only row. The WRITE cannot be so relaxed — the `no-drafts`
|
|
32
|
+
// layer omits `draft` entirely, because that is the argument shape `main` sent.
|
|
33
|
+
const [sourceData, currentTargetVersion] = await Promise.all([
|
|
34
|
+
fetchSourceDocument(payload, collection, collectionId, sourceLng),
|
|
35
|
+
payload.findByID({
|
|
36
|
+
collection,
|
|
37
|
+
id: collectionId,
|
|
38
|
+
locale: targetLng,
|
|
39
|
+
fallbackLocale: false,
|
|
40
|
+
depth: 0,
|
|
41
|
+
draft: true
|
|
42
|
+
})
|
|
43
|
+
]);
|
|
28
44
|
const provenance = this.provenanceServiceFactory?.(payload);
|
|
29
45
|
const sourceFingerprint = provenance?.captureFingerprint(collection, sourceData) ?? null;
|
|
30
|
-
const targetData = await payload.findByID({
|
|
31
|
-
collection,
|
|
32
|
-
id: collectionId,
|
|
33
|
-
locale: targetLng,
|
|
34
|
-
fallbackLocale: false,
|
|
35
|
-
depth: 0
|
|
36
|
-
});
|
|
37
46
|
const translatedData = await translateContent({
|
|
38
47
|
schema,
|
|
39
48
|
sourceData,
|
|
40
|
-
targetData,
|
|
49
|
+
targetData: currentTargetVersion,
|
|
41
50
|
sourceLng,
|
|
42
51
|
targetLng,
|
|
43
52
|
translationProvider: this.translationProvider,
|
|
44
53
|
strategy
|
|
45
54
|
});
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
if (translatedData) {
|
|
56
|
+
await this.saveTranslatedDocument(payload, input, translatedData, layer.write);
|
|
57
|
+
if (provenance && sourceFingerprint !== null) {
|
|
58
|
+
await provenance.record({
|
|
59
|
+
collectionSlug: collection,
|
|
60
|
+
documentId: String(collectionId),
|
|
61
|
+
targetLocale: targetLng,
|
|
62
|
+
sourceLocale: sourceLng
|
|
63
|
+
}, sourceFingerprint);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (publishOnTranslation && layer.kind === "drafts") {
|
|
67
|
+
await this.publishTargetLocale(payload, input, layer.publish);
|
|
58
68
|
}
|
|
59
69
|
return {
|
|
60
70
|
success: true
|
|
61
71
|
};
|
|
62
72
|
}
|
|
63
|
-
async saveTranslatedDocument(payload,
|
|
64
|
-
let isAutosaveEnabled = false;
|
|
65
|
-
const versions = collectionConfig.versions;
|
|
66
|
-
if (versions && versions.drafts) {
|
|
67
|
-
translatedData["_status"] = publishOnTranslation ? "published" : "draft";
|
|
68
|
-
const drafts = versions.drafts;
|
|
69
|
-
if (!publishOnTranslation && drafts.autosave) isAutosaveEnabled = true;
|
|
70
|
-
}
|
|
73
|
+
async saveTranslatedDocument(payload, input, translatedData, write) {
|
|
71
74
|
await payload.update({
|
|
72
|
-
collection: collection,
|
|
73
|
-
id: collectionId,
|
|
75
|
+
collection: input.collection,
|
|
76
|
+
id: input.collectionId,
|
|
74
77
|
data: translatedData,
|
|
75
|
-
|
|
76
|
-
locale: targetLng,
|
|
77
|
-
fallbackLocale: sourceLng,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
...write,
|
|
79
|
+
locale: input.targetLng,
|
|
80
|
+
fallbackLocale: input.sourceLng,
|
|
81
|
+
context: translatorWriteContext()
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async publishTargetLocale(payload, input, publish) {
|
|
85
|
+
await payload.update({
|
|
86
|
+
collection: input.collection,
|
|
87
|
+
id: input.collectionId,
|
|
88
|
+
data: {
|
|
89
|
+
_status: publish.status
|
|
90
|
+
},
|
|
91
|
+
publishSpecificLocale: publish.publishSpecificLocale,
|
|
92
|
+
locale: publish.publishSpecificLocale,
|
|
93
|
+
context: translatorWriteContext()
|
|
85
94
|
});
|
|
86
95
|
}
|
|
87
96
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CollectionConfig } from "payload";
|
|
2
|
+
export type VersionsSlice = CollectionConfig["versions"];
|
|
3
|
+
/** The arguments that publish one locale, once a collection is known to have a draft layer. */
|
|
4
|
+
export type PublishScope = {
|
|
5
|
+
publishSpecificLocale: string;
|
|
6
|
+
/**
|
|
7
|
+
* Merged into the published write's data. Looks redundant beside `publishSpecificLocale`
|
|
8
|
+
* and is not: without it, any other locale holding a pending draft drags the whole
|
|
9
|
+
* document back to `draft` (#102).
|
|
10
|
+
*/
|
|
11
|
+
status: "published";
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Where a translation is written, and — separately — how the locale is published afterwards.
|
|
15
|
+
*
|
|
16
|
+
* A union, not one shape with optional fields: `publishSpecificLocale` on a collection with
|
|
17
|
+
* versions but no drafts drops every other locale from the live row (Payload 3.84.1, silent).
|
|
18
|
+
* `publish` exists only on the `drafts` variant, so that pair is unbuildable — see
|
|
19
|
+
* `targetLayer.contract.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
export type TargetLayer = {
|
|
22
|
+
kind: "no-drafts";
|
|
23
|
+
write: {
|
|
24
|
+
autosave: false;
|
|
25
|
+
};
|
|
26
|
+
} | {
|
|
27
|
+
kind: "drafts";
|
|
28
|
+
write: {
|
|
29
|
+
draft: true;
|
|
30
|
+
autosave: boolean;
|
|
31
|
+
};
|
|
32
|
+
publish: PublishScope;
|
|
33
|
+
};
|
|
34
|
+
export declare function resolveTargetLayer(args: {
|
|
35
|
+
versions: VersionsSlice;
|
|
36
|
+
targetLng: string;
|
|
37
|
+
}): TargetLayer;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { hasAutosaveEnabled, hasDraftsEnabled } from "payload/shared";
|
|
2
|
+
export function resolveTargetLayer(args) {
|
|
3
|
+
const { versions, targetLng } = args;
|
|
4
|
+
const config = {
|
|
5
|
+
versions
|
|
6
|
+
};
|
|
7
|
+
if (!hasDraftsEnabled(config)) return {
|
|
8
|
+
kind: "no-drafts",
|
|
9
|
+
write: {
|
|
10
|
+
autosave: false
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
kind: "drafts",
|
|
15
|
+
write: {
|
|
16
|
+
draft: true,
|
|
17
|
+
autosave: hasAutosaveEnabled(config)
|
|
18
|
+
},
|
|
19
|
+
publish: {
|
|
20
|
+
publishSpecificLocale: targetLng,
|
|
21
|
+
status: "published"
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//# sourceMappingURL=targetLayer.js.map
|