@justanarthur/payload-plugin-translator 3.1.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,7 +79,8 @@ export const myResolver: TranslateResolver = {
79
79
  | `globals` | `GlobalSlug[]` | yes | Globals to auto-translate. |
80
80
  | `resolvers` | `TranslateResolver[]` | yes | Tried in order; first to succeed wins per chunk. |
81
81
  | `autoTranslate` | `boolean` | no | Default `true`. Set `false` to opt out of the auto-translate afterChange hook — useful when you drive translation manually via the `translateOperation` export. |
82
- | `autoTranslateMode` | `'missing' \| 'all'` | no | Default `'missing'`: a publish in the default locale fills empty fields in other locales and keeps existing translations and slugs. `'all'` re-translates everything. |
82
+ | `autoTranslateMode` | `'missing' \| 'untranslated' \| 'all'` | no | Default `'missing'`: a publish in the default locale fills empty fields in other locales and keeps existing translations and slugs. `'untranslated'` also replaces fields that still hold the source copy. `'all'` re-translates everything. |
83
+ | `languageDetection` | `boolean` | no | Default `true`. The review view flags translated fields written in another locale's language, and `untranslated` mode re-translates them. |
83
84
  | `review` | `boolean` | no | Default `true`. Adds the `/admin/translations` review view, its endpoints and the hidden `translation-status` collection (needs a migration on postgres). |
84
85
  | `disabled` | `boolean` | no | Skip the plugin entirely (no overrides, no jobs). Useful in tests. |
85
86
  | `_options.additionalTraverseRichText` | function | no | Hook to extend rich-text traversal — see below. |
@@ -128,6 +129,19 @@ With `review` on, **Translations** appears in the admin nav (`/admin/translation
128
129
  last auto-translate job and its error. Actions: *Translate missing fields*, *Re-translate all*,
129
130
  *Mark reviewed*.
130
131
 
132
+ - **Wrong language**: translated fields are checked with [eld](https://github.com/nitotm/efficient-language-detector-js)
133
+ (small n-gram set, loaded lazily on the server, limited to the configured locales). A field is
134
+ flagged, shown as `⚑` in the overview and `xx detected` in the detail, only when it has at least 40
135
+ characters and 5 words after dropping emails, urls, numbers and slashed loanwords, is not a
136
+ list of short lines, and the detected language leads the expected one by a margin (wider for close
137
+ pairs such as cs/sk and es/pt, and for text under 80 characters). Flagged fields count against
138
+ coverage, so bulk and per-document fixes re-translate them.
139
+ - **Bulk fix**: *Translate untranslated* queues one translate workflow per incomplete document,
140
+ for all locales or one, in `untranslated` mode (empty fields and fields still holding the source
141
+ copy; existing translations and slugs stay). Locales a pending job already covers are skipped.
142
+ The overview shows queued, running and failed jobs, and *Run a batch now* processes a few without
143
+ waiting for the job runner. Needs `autoTranslate` on, since that registers the jobs.
144
+
131
145
  The view only lists collections and globals the signed-in user can read, and the endpoints behind
132
146
  the actions require a user.
133
147
 
package/dist/client.d.ts CHANGED
@@ -4,13 +4,25 @@ declare const CustomButtonWithTranslator: ({ type }: {
4
4
  type: "publish" | "save";
5
5
  }) => Element_12xgc;
6
6
  import { JSX as JSX_1kxb2 } from "react";
7
+ type Progress = {
8
+ pending: number;
9
+ running: number;
10
+ failed: number;
11
+ } | null;
7
12
  type Element_12xgc2 = JSX_1kxb2["Element"];
13
+ declare const BulkActions: ({ tab, locales, progress }: {
14
+ tab: string;
15
+ locales: string[];
16
+ progress: Progress;
17
+ }) => Element_12xgc2;
18
+ import { JSX as JSX_1kxb3 } from "react";
19
+ type Element_12xgc3 = JSX_1kxb3["Element"];
8
20
  declare const ReviewActions: ({ entity, locale, reviewed }: {
9
21
  entity: string;
10
22
  locale: string;
11
23
  reviewed: boolean;
12
- }) => Element_12xgc2;
13
- import { JSX as JSX_1kxb3 } from "react";
14
- type Element_12xgc3 = JSX_1kxb3["Element"];
15
- declare const TranslationsNavLink: () => Element_12xgc3;
16
- export { CustomButtonWithTranslator, ReviewActions, TranslationsNavLink, CustomButtonWithTranslator as default };
24
+ }) => Element_12xgc3;
25
+ import { JSX as JSX_1kxb4 } from "react";
26
+ type Element_12xgc4 = JSX_1kxb4["Element"];
27
+ declare const TranslationsNavLink: () => Element_12xgc4;
28
+ export { BulkActions, CustomButtonWithTranslator, ReviewActions, TranslationsNavLink, CustomButtonWithTranslator as default };
package/dist/client.js CHANGED
@@ -315,14 +315,125 @@ var CustomButtonWithTranslator = ({ type }) => {
315
315
  });
316
316
  };
317
317
 
318
- // src/client/components/Review/ReviewActions.tsx
318
+ // src/client/components/Review/BulkActions.tsx
319
319
  import { Button as Button3, toast as toast2, useConfig as useConfig3 } from "@payloadcms/ui";
320
320
  import { useState as useState2 } from "react";
321
321
  import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
322
322
 
323
- var ReviewActions = ({ entity, locale, reviewed }) => {
323
+ var BulkActions = ({ tab, locales, progress }) => {
324
324
  const { config: { routes: { api }, serverURL } } = useConfig3();
325
+ const [locale, setLocale] = useState2("");
325
326
  const [pending, setPending] = useState2(null);
327
+ const post = async (path, body) => {
328
+ const res = await fetch(`${serverURL ?? ""}${api}/translator/review/${path}`, {
329
+ method: "POST",
330
+ credentials: "include",
331
+ headers: { "Content-Type": "application/json" },
332
+ body: JSON.stringify(body)
333
+ });
334
+ const result = await res.json().catch(() => ({}));
335
+ if (!res.ok || !result.success)
336
+ throw new Error(result?.errors?.[0]?.message ?? "request failed");
337
+ return result;
338
+ };
339
+ const queue = async () => {
340
+ const scope = `${locale ? locale.toUpperCase() : "every locale"} of all ${tab}`;
341
+ if (!window.confirm(`Queue translation of empty, untranslated and wrong-language fields in ${scope}? Correct translations are kept.`))
342
+ return;
343
+ setPending("bulk");
344
+ try {
345
+ const { queuedDocuments, queuedLocales, skippedPending } = await post("bulk", { tab, locale, mode: "untranslated" });
346
+ toast2.success(queuedDocuments ? `Queued ${queuedDocuments} documents (${queuedLocales} locales)${skippedPending ? `, ${skippedPending} already queued` : ""}` : skippedPending ? "Everything incomplete is already queued" : "Nothing to translate");
347
+ window.location.reload();
348
+ } catch (error) {
349
+ toast2.error(`Could not queue: ${error instanceof Error ? error.message : String(error)}`);
350
+ } finally {
351
+ setPending(null);
352
+ }
353
+ };
354
+ const runNow = async () => {
355
+ setPending("run");
356
+ try {
357
+ await post("run-jobs", {});
358
+ toast2.success("Ran a batch of queued translations");
359
+ window.location.reload();
360
+ } catch (error) {
361
+ toast2.error(`Could not run jobs: ${error instanceof Error ? error.message : String(error)}`);
362
+ } finally {
363
+ setPending(null);
364
+ }
365
+ };
366
+ return /* @__PURE__ */ jsxs5("div", {
367
+ className: "tr__bulk",
368
+ children: [
369
+ /* @__PURE__ */ jsxs5("div", {
370
+ className: "tr__bulk-main",
371
+ children: [
372
+ /* @__PURE__ */ jsxs5("select", {
373
+ "aria-label": "Locale",
374
+ className: "tr__select",
375
+ onChange: (event) => setLocale(event.target.value),
376
+ value: locale,
377
+ children: [
378
+ /* @__PURE__ */ jsx7("option", {
379
+ value: "",
380
+ children: "All locales"
381
+ }),
382
+ locales.map((each) => /* @__PURE__ */ jsx7("option", {
383
+ value: each,
384
+ children: each.toUpperCase()
385
+ }, each))
386
+ ]
387
+ }),
388
+ /* @__PURE__ */ jsx7(Button3, {
389
+ buttonStyle: "primary",
390
+ disabled: Boolean(pending),
391
+ onClick: queue,
392
+ size: "small",
393
+ children: pending === "bulk" ? "Queuing…" : "Translate untranslated"
394
+ })
395
+ ]
396
+ }),
397
+ progress && (progress.pending > 0 || progress.failed > 0) && /* @__PURE__ */ jsxs5("div", {
398
+ className: "tr__bulk-progress",
399
+ children: [
400
+ progress.pending > 0 && /* @__PURE__ */ jsxs5("span", {
401
+ className: "tr__pill",
402
+ "data-tone": "warn",
403
+ children: [
404
+ progress.pending,
405
+ " queued",
406
+ progress.running ? `, ${progress.running} running` : ""
407
+ ]
408
+ }),
409
+ progress.failed > 0 && /* @__PURE__ */ jsxs5("span", {
410
+ className: "tr__pill",
411
+ "data-tone": "bad",
412
+ children: [
413
+ progress.failed,
414
+ " failed today"
415
+ ]
416
+ }),
417
+ progress.pending > 0 && /* @__PURE__ */ jsx7(Button3, {
418
+ buttonStyle: "secondary",
419
+ disabled: Boolean(pending),
420
+ onClick: runNow,
421
+ size: "small",
422
+ children: pending === "run" ? "Running…" : "Run a batch now"
423
+ })
424
+ ]
425
+ })
426
+ ]
427
+ });
428
+ };
429
+ // src/client/components/Review/ReviewActions.tsx
430
+ import { Button as Button4, toast as toast3, useConfig as useConfig4 } from "@payloadcms/ui";
431
+ import { useState as useState3 } from "react";
432
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
433
+
434
+ var ReviewActions = ({ entity, locale, reviewed }) => {
435
+ const { config: { routes: { api }, serverURL } } = useConfig4();
436
+ const [pending, setPending] = useState3(null);
326
437
  const run = async (action) => {
327
438
  if (action === "translate-all" && !window.confirm(`Re-translate every field in ${locale}? Existing ${locale} copy is replaced.`))
328
439
  return;
@@ -333,37 +444,37 @@ var ReviewActions = ({ entity, locale, reviewed }) => {
333
444
  method: "POST",
334
445
  credentials: "include",
335
446
  headers: { "Content-Type": "application/json" },
336
- body: JSON.stringify({ entity, locale, mode: action === "translate-all" ? "all" : "missing" })
447
+ body: JSON.stringify({ entity, locale, mode: action === "translate-all" ? "all" : "untranslated" })
337
448
  });
338
449
  const result = await res.json().catch(() => ({}));
339
450
  if (!res.ok || !result.success)
340
451
  throw new Error(result?.errors?.[0]?.message ?? "request failed");
341
- toast2.success(action === "mark-reviewed" ? `${locale} marked as reviewed` : `${locale} translated`);
452
+ toast3.success(action === "mark-reviewed" ? `${locale} marked as reviewed` : `${locale} translated`);
342
453
  window.location.reload();
343
454
  } catch (error) {
344
- toast2.error(`Could not ${action.replace("-", " ")}: ${error instanceof Error ? error.message : String(error)}`);
455
+ toast3.error(`Could not ${action.replace("-", " ")}: ${error instanceof Error ? error.message : String(error)}`);
345
456
  } finally {
346
457
  setPending(null);
347
458
  }
348
459
  };
349
- return /* @__PURE__ */ jsxs5("div", {
460
+ return /* @__PURE__ */ jsxs6("div", {
350
461
  className: "translator-review__actions",
351
462
  children: [
352
- /* @__PURE__ */ jsx7(Button3, {
463
+ /* @__PURE__ */ jsx8(Button4, {
353
464
  buttonStyle: "primary",
354
465
  disabled: Boolean(pending),
355
- onClick: () => run("translate-missing"),
466
+ onClick: () => run("translate-untranslated"),
356
467
  size: "small",
357
- children: pending === "translate-missing" ? "Translating…" : "Translate missing fields"
468
+ children: pending === "translate-untranslated" ? "Translating…" : "Translate untranslated fields"
358
469
  }),
359
- /* @__PURE__ */ jsx7(Button3, {
470
+ /* @__PURE__ */ jsx8(Button4, {
360
471
  buttonStyle: "secondary",
361
472
  disabled: Boolean(pending),
362
473
  onClick: () => run("translate-all"),
363
474
  size: "small",
364
475
  children: pending === "translate-all" ? "Translating…" : "Re-translate all"
365
476
  }),
366
- /* @__PURE__ */ jsx7(Button3, {
477
+ /* @__PURE__ */ jsx8(Button4, {
367
478
  buttonStyle: "secondary",
368
479
  disabled: Boolean(pending) || reviewed,
369
480
  onClick: () => run("mark-reviewed"),
@@ -374,18 +485,18 @@ var ReviewActions = ({ entity, locale, reviewed }) => {
374
485
  });
375
486
  };
376
487
  // src/client/components/Review/TranslationsNavLink.tsx
377
- import { useConfig as useConfig4 } from "@payloadcms/ui";
488
+ import { useConfig as useConfig5 } from "@payloadcms/ui";
378
489
  import { formatAdminURL } from "payload/shared";
379
- import { jsx as jsx8 } from "react/jsx-runtime";
490
+ import { jsx as jsx9 } from "react/jsx-runtime";
380
491
 
381
492
  var TranslationsNavLink = () => {
382
- const { config: { routes: { admin } } } = useConfig4();
493
+ const { config: { routes: { admin } } } = useConfig5();
383
494
  const href = formatAdminURL({ adminRoute: admin, path: REVIEW_VIEW_PATH });
384
- return /* @__PURE__ */ jsx8("a", {
495
+ return /* @__PURE__ */ jsx9("a", {
385
496
  className: "nav__link",
386
497
  href,
387
498
  id: "nav-translations",
388
- children: /* @__PURE__ */ jsx8("span", {
499
+ children: /* @__PURE__ */ jsx9("span", {
389
500
  className: "nav__link-label",
390
501
  children: "Translations"
391
502
  })
@@ -395,6 +506,7 @@ var TranslationsNavLink = () => {
395
506
  // src/exports/client.ts
396
507
  var client_default = CustomButtonWithTranslator;
397
508
  export {
509
+ BulkActions,
398
510
  CustomButtonWithTranslator,
399
511
  ReviewActions,
400
512
  TranslationsNavLink,
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ type TranslateArgs = {
6
6
  data?: Record<string, any>;
7
7
  emptyOnly?: boolean;
8
8
  globalSlug?: string;
9
+ /** with `emptyOnly`, also replace fields that still hold the source copy */
10
+ retranslateIdentical?: boolean;
9
11
  id?: number | string;
10
12
  locale: string;
11
13
  localeFrom: string;
@@ -18,6 +20,8 @@ type TranslateResult = {
18
20
  } | {
19
21
  success: true;
20
22
  translatedData: Record<string, any>;
23
+ /** values sent to the resolver; 0 means the target needed nothing */
24
+ translatedCount: number;
21
25
  dataFrom: Doc;
22
26
  };
23
27
  type TranslateOperationArgs = ({
@@ -45,6 +49,11 @@ type TranslateResolver = {
45
49
  key: string;
46
50
  resolve: (args: TranslateResolverArgs) => Promise<TranslateResolverResponse> | TranslateResolverResponse;
47
51
  };
52
+ /**
53
+ * `missing` fills empty fields, `untranslated` also replaces fields that still hold the
54
+ * source copy, `all` re-translates everything.
55
+ */
56
+ type TranslateMode = "all" | "missing" | "untranslated";
48
57
  type TranslatorConfig = {
49
58
  collections: CollectionSlug[];
50
59
  disabled?: boolean;
@@ -56,9 +65,11 @@ type TranslatorConfig = {
56
65
  * `missing` (default) fills empty fields and keeps existing translations and slugs;
57
66
  * `all` re-translates every field.
58
67
  */
59
- autoTranslateMode?: "all" | "missing";
68
+ autoTranslateMode?: TranslateMode;
60
69
  /** adds the /admin/translations review view, its endpoints and the status collection (default true) */
61
70
  review?: boolean;
71
+ /** flag translated fields written in another locale's language (default true) */
72
+ languageDetection?: boolean;
62
73
  _options?: {
63
74
  additionalTraverseRichText?: (args: {
64
75
  onText: (siblingData: Record<string, unknown>, attribute?: string) => void;
package/dist/index.js CHANGED
@@ -4,14 +4,19 @@ import {
4
4
  createAutoTranslateCollectionHook2,
5
5
  createAutoTranslateGlobalHook2,
6
6
  recordTranslationStatus,
7
+ translateModeArgs,
8
+ isTranslateMode,
7
9
  createTranslateTask2,
8
10
  createTranslateWorkflow2
9
- } from "./shared/chunk-p03jz82h.js";
10
- import"./shared/chunk-9d433kmv.js";
11
+ } from "./shared/chunk-z55qxmj2.js";
12
+ import"./shared/chunk-3rz1t943.js";
11
13
  import {
12
14
  readLocales,
13
- parseEntityKey
14
- } from "./shared/chunk-15n5q7wd.js";
15
+ parseEntityKey,
16
+ hasTranslateWorkflow,
17
+ queueBulkTranslation,
18
+ runQueuedTranslations
19
+ } from "./shared/chunk-gd0rak8x.js";
15
20
  import {
16
21
  TRANSLATION_STATUS_SLUG2,
17
22
  REVIEW_VIEW_PATH
@@ -171,7 +176,7 @@ var translateHandler = async (req) => {
171
176
  collectionSlug,
172
177
  globalSlug,
173
178
  id,
174
- emptyOnly: mode !== "all",
179
+ ...translateModeArgs(isTranslateMode(mode) ? mode : "untranslated"),
175
180
  locale,
176
181
  localeFrom: defaultLocale,
177
182
  overrideAccess: false,
@@ -208,7 +213,39 @@ var markReviewedHandler = async (req) => {
208
213
  });
209
214
  return Response.json({ success: true });
210
215
  };
216
+ var bulkHandler = async (req) => {
217
+ if (!req.user)
218
+ throw new APIError("Unauthorized", 401);
219
+ if (!req.json)
220
+ throw new APIError("Content-Type should be json");
221
+ if (!hasTranslateWorkflow(req))
222
+ throw new APIError("Bulk translation needs autoTranslate enabled, which registers the translate jobs", 400);
223
+ const body = await req.json();
224
+ const pluginConfig = req.payload.config.custom?.translator;
225
+ const { targetLocales } = readLocales(req);
226
+ const tabAllowed = body.tab === "globals" ? Boolean(pluginConfig?.globals.length) : pluginConfig?.collections.includes(body.tab);
227
+ if (!body.tab || !tabAllowed)
228
+ throw new APIError("Forbidden", 403);
229
+ if (body.locale && !targetLocales.includes(body.locale))
230
+ throw new APIError("Bad Request", 400);
231
+ const result = await queueBulkTranslation({
232
+ req,
233
+ tab: body.tab,
234
+ locale: body.locale || undefined,
235
+ mode: isTranslateMode(body.mode) ? body.mode : "untranslated"
236
+ });
237
+ return Response.json({ success: true, ...result });
238
+ };
239
+ var runJobsHandler = async (req) => {
240
+ if (!req.user)
241
+ throw new APIError("Unauthorized", 401);
242
+ if (!hasTranslateWorkflow(req))
243
+ throw new APIError("Translate jobs are not registered", 400);
244
+ return Response.json({ success: true, progress: await runQueuedTranslations(req) });
245
+ };
211
246
  var reviewEndpoints = [
247
+ { path: "/translator/review/bulk", method: "post", handler: bulkHandler },
248
+ { path: "/translator/review/run-jobs", method: "post", handler: runJobsHandler },
212
249
  { path: "/translator/review/translate", method: "post", handler: translateHandler },
213
250
  { path: "/translator/review/mark-reviewed", method: "post", handler: markReviewedHandler }
214
251
  ];
package/dist/jobs.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import {
2
- TRANSLATE_TASK_SLUG2,
3
- TRANSLATE_WORKFLOW_SLUG2,
4
2
  createAutoTranslateCollectionHook2,
5
3
  createAutoTranslateGlobalHook2,
6
4
  createTranslateTask2,
7
5
  createTranslateWorkflow2
8
- } from "./shared/chunk-p03jz82h.js";
9
- import"./shared/chunk-9d433kmv.js";
6
+ } from "./shared/chunk-z55qxmj2.js";
7
+ import {
8
+ TRANSLATE_TASK_SLUG2,
9
+ TRANSLATE_WORKFLOW_SLUG2
10
+ } from "./shared/chunk-3rz1t943.js";
10
11
 
11
12
  // src/exports/jobs.ts
12
13
  var jobs = {
package/dist/rsc.js CHANGED
@@ -1,10 +1,11 @@
1
- import"./shared/chunk-9d433kmv.js";
1
+ import"./shared/chunk-3rz1t943.js";
2
2
  import {
3
3
  readLocales,
4
4
  loadCollectionReview,
5
5
  loadGlobalsReview,
6
- loadEntityLocaleReview
7
- } from "./shared/chunk-15n5q7wd.js";
6
+ loadEntityLocaleReview,
7
+ loadJobsProgress
8
+ } from "./shared/chunk-gd0rak8x.js";
8
9
  import {
9
10
  REVIEW_VIEW_PATH
10
11
  } from "./shared/chunk-31qgv1zk.js";
@@ -12,6 +13,7 @@ import {
12
13
  // src/review/view/TranslationsView.tsx
13
14
  import { DefaultTemplate } from "@payloadcms/next/templates";
14
15
  import { Gutter } from "@payloadcms/ui";
16
+ import { BulkActions } from "@justanarthur/payload-plugin-translator/client";
15
17
 
16
18
  // src/review/view/Detail.tsx
17
19
  import { ReviewActions } from "@justanarthur/payload-plugin-translator/client";
@@ -33,7 +35,8 @@ var STATE = {
33
35
  identical: { label: "same as source", tone: "warn" },
34
36
  missing: { label: "missing", tone: "bad" },
35
37
  ok: { label: "translated", tone: "ok" },
36
- placeholders: { label: "placeholders differ", tone: "warn" }
38
+ placeholders: { label: "placeholders differ", tone: "warn" },
39
+ wrongLanguage: { label: "wrong language", tone: "bad" }
37
40
  };
38
41
  var preview = (text) => text.length > 600 ? `${text.slice(0, 597)}…` : text;
39
42
  var readablePath = (path) => path.replace(/\[(\d+)\]/g, (_, index) => ` ${Number(index) + 1}`).replace(/\(([^)]+)\)/g, " · $1").replace(/#(\d+)$/, (_, index) => ` (part ${Number(index) + 1})`).split(".").join(" › ");
@@ -45,6 +48,7 @@ var Detail = ({ adminRoute, review, onlyIssues, tab, page }) => {
45
48
  { label: "translated", value: `${summary.coverage}%`, note: `${summary.ok} of ${summary.total} fields`, bar: true },
46
49
  { label: "missing", value: summary.missing, note: "empty in this locale" },
47
50
  { label: "same as source", value: summary.identical, note: `identical to ${review.defaultLocale}` },
51
+ { label: "wrong language", value: summary.wrongLanguage, note: `not written in ${review.locale}` },
48
52
  { label: "placeholders", value: summary.placeholders, note: "differ from source" }
49
53
  ];
50
54
  return /* @__PURE__ */ jsxs(Fragment, {
@@ -217,7 +221,7 @@ var Detail = ({ adminRoute, review, onlyIssues, tab, page }) => {
217
221
  children: /* @__PURE__ */ jsx("span", {
218
222
  className: "tr__pill",
219
223
  "data-tone": STATE[field.state].tone,
220
- children: STATE[field.state].label
224
+ children: field.detectedLanguage ? `${field.detectedLanguage} detected` : STATE[field.state].label
221
225
  })
222
226
  }),
223
227
  /* @__PURE__ */ jsx("td", {
@@ -260,14 +264,15 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
260
264
  return {
261
265
  locale,
262
266
  coverage: fields ? Math.floor(ok / fields * 100) : 100,
263
- incomplete: reviews.filter(isIncomplete).length
267
+ incomplete: reviews.filter(isIncomplete).length,
268
+ wrongLanguage: reviews.filter((each) => each.summary.wrongLanguage > 0).length
264
269
  };
265
270
  });
266
271
  return /* @__PURE__ */ jsxs2(Fragment2, {
267
272
  children: [
268
273
  /* @__PURE__ */ jsx2("div", {
269
274
  className: "tr__stats",
270
- children: stats.map(({ locale, coverage, incomplete }) => /* @__PURE__ */ jsxs2("div", {
275
+ children: stats.map(({ locale, coverage, incomplete, wrongLanguage }) => /* @__PURE__ */ jsxs2("div", {
271
276
  className: "tr__stat",
272
277
  children: [
273
278
  /* @__PURE__ */ jsx2("span", {
@@ -291,6 +296,14 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
291
296
  /* @__PURE__ */ jsx2("span", {
292
297
  className: "tr__stat-note",
293
298
  children: incomplete ? `${incomplete} incomplete` : "all complete"
299
+ }),
300
+ wrongLanguage > 0 && /* @__PURE__ */ jsxs2("span", {
301
+ className: "tr__stat-note tr__lang",
302
+ children: [
303
+ "⚑ ",
304
+ wrongLanguage,
305
+ " in another language"
306
+ ]
294
307
  })
295
308
  ]
296
309
  }, locale))
@@ -337,6 +350,7 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
337
350
  summary.missing && `${summary.missing} missing`,
338
351
  summary.identical && `${summary.identical} same as ${defaultLocale}`,
339
352
  summary.placeholders && `${summary.placeholders} broken placeholders`,
353
+ summary.wrongLanguage && `${summary.wrongLanguage} written in another language`,
340
354
  summary.slugMissing && "no slug: the page 404s",
341
355
  review.stale && "source changed since translation",
342
356
  review.reviewed && "reviewed"
@@ -358,6 +372,13 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
358
372
  " ",
359
373
  [review.stale && "↻", review.reviewed && "✓"].filter(Boolean).join(" ")
360
374
  ]
375
+ }),
376
+ summary.wrongLanguage > 0 && /* @__PURE__ */ jsxs2("span", {
377
+ className: "tr__lang",
378
+ children: [
379
+ " ⚑",
380
+ summary.wrongLanguage
381
+ ]
361
382
  })
362
383
  ]
363
384
  }),
@@ -415,6 +436,10 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
415
436
  "under half, or 404 without a slug"
416
437
  ]
417
438
  }),
439
+ /* @__PURE__ */ jsx2("span", {
440
+ className: "tr__lang",
441
+ children: "⚑ fields written in another language"
442
+ }),
418
443
  /* @__PURE__ */ jsx2("span", {
419
444
  children: "↻ source changed since the last translation"
420
445
  }),
@@ -456,6 +481,10 @@ var reviewStyles = `
456
481
  .tr__toggle::before { content: ''; width: 26px; height: 14px; border-radius: 999px; background: var(--theme-elevation-200) radial-gradient(circle at 7px 50%, var(--theme-elevation-0) 4px, transparent 4.5px); }
457
482
  .tr__toggle[aria-pressed='true']::before { background: var(--tr-ok) radial-gradient(circle at 19px 50%, var(--theme-elevation-0) 4px, transparent 4.5px); }
458
483
 
484
+ .tr__bulk { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: calc(var(--base) * 1.25); padding: 10px 12px; border: 1px dashed var(--tr-line); border-radius: var(--style-radius-m); }
485
+ .tr__bulk-main, .tr__bulk-progress { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
486
+ .tr__bulk .btn { margin: 0; }
487
+ .tr__select { height: 28px; padding: 0 8px; border: 1px solid var(--tr-line); border-radius: var(--style-radius-s, 4px); background: var(--theme-input-bg, var(--theme-elevation-0)); color: var(--theme-text); font: inherit; font-size: 13px; }
459
488
  .tr__card { border: 1px solid var(--tr-line); border-radius: var(--style-radius-m); overflow: auto; background: var(--theme-elevation-0); }
460
489
  .tr table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; }
461
490
  .tr th { position: sticky; top: 0; z-index: 1; background: var(--tr-card); text-align: left; font-weight: 500; color: var(--tr-muted); padding: 10px 12px; border-bottom: 1px solid var(--tr-line); white-space: nowrap; }
@@ -475,6 +504,7 @@ var reviewStyles = `
475
504
  .tr__cell[data-tone='warn'] { --tr-tone: var(--tr-warn); color: var(--tr-warn); }
476
505
  .tr__cell[data-tone='bad'] { --tr-tone: var(--tr-bad); color: var(--tr-bad); }
477
506
  .tr__marks { font-size: 11px; color: var(--tr-muted); }
507
+ .tr__lang { color: var(--tr-bad); font-weight: 500; }
478
508
 
479
509
  .tr__legend { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-top: 12px; font-size: 12px; color: var(--tr-muted); }
480
510
  .tr__legend span { display: inline-flex; align-items: center; gap: 6px; }
@@ -628,6 +658,7 @@ var TranslationsView = async ({ initPageResult, params, searchParams }) => {
628
658
  });
629
659
  };
630
660
  const adminLocale = req.locale;
661
+ const progress = entity || !req.user ? null : await loadJobsProgress(req);
631
662
  const body = await content();
632
663
  req.locale = adminLocale;
633
664
  return /* @__PURE__ */ jsxs3(DefaultTemplate, {
@@ -685,6 +716,11 @@ var TranslationsView = async ({ initPageResult, params, searchParams }) => {
685
716
  children: "Only incomplete"
686
717
  })
687
718
  ]
719
+ }),
720
+ req.user && tab && /* @__PURE__ */ jsx3(BulkActions, {
721
+ locales: targetLocales,
722
+ progress,
723
+ tab
688
724
  })
689
725
  ]
690
726
  }),
@@ -1,7 +1,72 @@
1
+ // src/jobs/constants.ts
2
+ var TRANSLATE_TASK_SLUG2 = "translateEntityToLocale";
3
+ var TRANSLATE_WORKFLOW_SLUG2 = "translateEntityToLocales";
4
+
5
+ // src/utils/languageDetector.ts
6
+ var CLOSE_PAIRS = new Set(["cs|sk", "sk|cs", "es|pt", "pt|es", "es|it", "it|es", "nl|de", "de|nl"]);
7
+ var MIN_LENGTH = 40;
8
+ var MIN_WORDS = 5;
9
+ var MARGIN = 0.03;
10
+ var CLOSE_MARGIN = 0.06;
11
+ var SHORT_LENGTH = 80;
12
+ var SHORT_EXTRA_MARGIN = 0.03;
13
+ var base = (locale) => locale.split(/[-_]/)[0].toLowerCase();
14
+ var stripNonWords = (text) => text.replace(/\S+@\S+|https?:\/\/\S+|www\.\S+/gi, " ").replace(/\S*[/\d]\S*/g, " ").replace(/\s+/g, " ").trim();
15
+ var looksLikeList = (text) => {
16
+ const lines = text.split(`
17
+ `).map((line) => line.trim()).filter(Boolean);
18
+ if (lines.length < 3)
19
+ return false;
20
+ const lengths = lines.map((line) => line.length).sort((a, b) => a - b);
21
+ return lengths[Math.floor(lengths.length / 2)] < MIN_LENGTH;
22
+ };
23
+ var detector;
24
+ var loadWrongLanguageCheck = (locales) => {
25
+ detector ??= import("eld/small").then(({ eld }) => {
26
+ const languages = [...new Set(locales.map(base))];
27
+ eld.setLanguageSubset(languages);
28
+ return (text, expectedLocale) => {
29
+ const expected = base(expectedLocale);
30
+ if (looksLikeList(text))
31
+ return null;
32
+ const normalized = stripNonWords(text);
33
+ if (normalized.length < MIN_LENGTH || normalized.split(" ").length < MIN_WORDS)
34
+ return null;
35
+ const result = eld.detect(normalized.slice(0, 1000));
36
+ if (!result.language || result.language === expected || !result.isReliable())
37
+ return null;
38
+ const scores = result.getScores();
39
+ const lead = (scores[result.language] ?? 0) - (scores[expected] ?? 0);
40
+ const margin = (CLOSE_PAIRS.has(`${expected}|${result.language}`) ? CLOSE_MARGIN : MARGIN) + (normalized.length < SHORT_LENGTH ? SHORT_EXTRA_MARGIN : 0);
41
+ return lead >= margin ? result.language : null;
42
+ };
43
+ }).catch(() => null);
44
+ return detector;
45
+ };
46
+
1
47
  // src/utils/placeholders.ts
2
48
  var placeholdersOf = (value) => (value.match(/\{\s*[\w.]+\s*(?:,[^{}]*)?\}/g) ?? []).map((each) => each.replace(/\s+/g, "")).sort();
3
49
  var samePlaceholders = (source, translated) => placeholdersOf(source).join("|") === placeholdersOf(translated).join("|");
4
50
 
51
+ // src/utils/plainText.ts
52
+ var plainText = (value) => {
53
+ if (typeof value === "string")
54
+ return value;
55
+ if (!value || typeof value !== "object")
56
+ return "";
57
+ if (Array.isArray(value))
58
+ return value.map(plainText).filter(Boolean).join(" ");
59
+ const node = value;
60
+ if (typeof node.text === "string")
61
+ return node.text;
62
+ if ("root" in node)
63
+ return plainText(node.root);
64
+ if (Array.isArray(node.children))
65
+ return node.children.map(plainText).filter(Boolean).join(node.type === "root" ? `
66
+ ` : "");
67
+ return "";
68
+ };
69
+
5
70
  // src/translate/traverseFields.ts
6
71
  import ObjectID from "bson-objectid";
7
72
  import { tabHasName } from "payload/shared";
@@ -46,6 +111,13 @@ var isOpaqueText = (value) => {
46
111
  return /^([a-z][a-z0-9+.-]*:|\/\/|\/|#|www\.)/i.test(text) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) || !/\p{L}/u.test(text);
47
112
  };
48
113
 
114
+ // src/utils/looksUntranslated.ts
115
+ var normalize = (text) => text.replace(/\s+/g, " ").trim();
116
+ var looksUntranslated = (source, target) => {
117
+ const sourceText = normalize(plainText(source));
118
+ return sourceText.length > 3 && /\s/.test(sourceText) && !isOpaqueText(sourceText) && sourceText === normalize(plainText(target));
119
+ };
120
+
49
121
  // src/utils/sanitizeSlug.ts
50
122
  var sanitizeSlug = (text) => text.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
51
123
 
@@ -95,6 +167,8 @@ var traverseFields = (args) => {
95
167
  const {
96
168
  dataFrom,
97
169
  emptyOnly,
170
+ retranslateIdentical,
171
+ isWrongLanguage,
98
172
  fields,
99
173
  localizedParent,
100
174
  path,
@@ -106,6 +180,7 @@ var traverseFields = (args) => {
106
180
  const { additionalTraverseRichText } = _options ?? {};
107
181
  const siblingDataFrom = args.siblingDataFrom ?? dataFrom;
108
182
  const siblingDataTranslated = args.siblingDataTranslated ?? translatedData;
183
+ const keepsTarget = (source, target) => Boolean(emptyOnly) && hasText(target) && !(retranslateIdentical && (looksUntranslated(source, target) || Boolean(isWrongLanguage?.(target))));
109
184
  const recurse = (overrides) => traverseFields({ ...args, siblingDataFrom, siblingDataTranslated, ...overrides });
110
185
  for (const field of fields) {
111
186
  switch (field.type) {
@@ -226,7 +301,7 @@ var traverseFields = (args) => {
226
301
  onField?.({ path: keyPath, type: "json", source: value, target: previous });
227
302
  if (!value.trim())
228
303
  continue;
229
- if (emptyOnly && typeof previous === "string" && previous.trim())
304
+ if (keepsTarget(value, previous))
230
305
  continue;
231
306
  target[key] = value;
232
307
  if (isOpaqueText(value))
@@ -266,7 +341,7 @@ var traverseFields = (args) => {
266
341
  if (typeof value !== "string" || !value.trim())
267
342
  break;
268
343
  const hasCurrent = typeof current === "string" && current.trim().length > 0;
269
- if (hasCurrent && (emptyOnly || isSlug))
344
+ if (isSlug ? hasCurrent : keepsTarget(value, current))
270
345
  break;
271
346
  if (isOpaqueText(value)) {
272
347
  siblingDataTranslated[field.name] = value;
@@ -304,7 +379,7 @@ var traverseFields = (args) => {
304
379
  onField?.({ path: richTextPath, type: "richText", source: richTextDataFrom, target: current });
305
380
  if (!richTextDataFrom || !hasText(richTextDataFrom))
306
381
  break;
307
- if (emptyOnly && hasText(current))
382
+ if (keepsTarget(richTextDataFrom, current))
308
383
  break;
309
384
  const isSlate = Array.isArray(richTextDataFrom);
310
385
  const isLexical = typeof richTextDataFrom === "object" && "root" in richTextDataFrom;
@@ -356,4 +431,4 @@ var entityKey = ({ collectionSlug, globalSlug, id }) => globalSlug ? `global:${g
356
431
  import { createHash } from "node:crypto";
357
432
  var sourceHash = (fields) => createHash("sha1").update(JSON.stringify(fields.map(({ path, source }) => [path, source]))).digest("hex");
358
433
 
359
- export { samePlaceholders, isOpaqueText, traverseFields, collectTranslatableFields, entityKey, sourceHash };
434
+ export { loadWrongLanguageCheck, samePlaceholders, plainText, looksUntranslated, traverseFields, TRANSLATE_TASK_SLUG2, TRANSLATE_WORKFLOW_SLUG2, collectTranslatableFields, entityKey, sourceHash };
@@ -1,33 +1,17 @@
1
1
  import {
2
+ loadWrongLanguageCheck,
2
3
  samePlaceholders,
3
- isOpaqueText,
4
+ plainText,
5
+ looksUntranslated,
6
+ TRANSLATE_WORKFLOW_SLUG2,
4
7
  collectTranslatableFields,
5
8
  entityKey,
6
9
  sourceHash
7
- } from "./chunk-9d433kmv.js";
10
+ } from "./chunk-3rz1t943.js";
8
11
  import {
9
12
  TRANSLATION_STATUS_SLUG2
10
13
  } from "./chunk-31qgv1zk.js";
11
14
 
12
- // src/review/plainText.ts
13
- var plainText = (value) => {
14
- if (typeof value === "string")
15
- return value;
16
- if (!value || typeof value !== "object")
17
- return "";
18
- if (Array.isArray(value))
19
- return value.map(plainText).filter(Boolean).join(" ");
20
- const node = value;
21
- if (typeof node.text === "string")
22
- return node.text;
23
- if ("root" in node)
24
- return plainText(node.root);
25
- if (Array.isArray(node.children))
26
- return node.children.map(plainText).filter(Boolean).join(node.type === "root" ? `
27
- ` : "");
28
- return "";
29
- };
30
-
31
15
  // src/review/computeStatus.ts
32
16
  var normalize = (text) => text.replace(/\s+/g, " ").trim();
33
17
  var classify = (field, sourceText, targetText) => {
@@ -35,19 +19,26 @@ var classify = (field, sourceText, targetText) => {
35
19
  return "missing";
36
20
  if (field.type === "json" && !samePlaceholders(sourceText, targetText))
37
21
  return "placeholders";
38
- const same = normalize(sourceText) === normalize(targetText);
39
- if (same && field.type !== "slug" && normalize(sourceText).length > 3 && !isOpaqueText(sourceText) && /\s/.test(normalize(sourceText)))
22
+ if (field.type !== "slug" && looksUntranslated(sourceText, targetText))
40
23
  return "identical";
41
24
  return "ok";
42
25
  };
43
- var computeStatus = (fields) => {
26
+ var computeStatus = (fields, language) => {
44
27
  const statuses = [];
45
28
  for (const field of fields) {
46
29
  const sourceText = plainText(field.source);
47
30
  if (!normalize(sourceText))
48
31
  continue;
49
32
  const targetText = plainText(field.target);
50
- statuses.push({ ...field, sourceText, targetText, state: classify(field, sourceText, targetText) });
33
+ const state = classify(field, sourceText, targetText);
34
+ const detectedLanguage = state === "ok" && field.type !== "slug" && language?.check ? language.check(targetText, language.locale) ?? undefined : undefined;
35
+ statuses.push({
36
+ ...field,
37
+ sourceText,
38
+ targetText,
39
+ state: detectedLanguage ? "wrongLanguage" : state,
40
+ ...detectedLanguage ? { detectedLanguage } : {}
41
+ });
51
42
  }
52
43
  const count = (state) => statuses.filter((each) => each.state === state).length;
53
44
  const ok = count("ok");
@@ -59,6 +50,7 @@ var computeStatus = (fields) => {
59
50
  missing: count("missing"),
60
51
  identical: count("identical"),
61
52
  placeholders: count("placeholders"),
53
+ wrongLanguage: count("wrongLanguage"),
62
54
  coverage: statuses.length ? Math.floor(ok / statuses.length * 100) : 100,
63
55
  slugMissing: statuses.some((each) => each.type === "slug" && each.state === "missing")
64
56
  }
@@ -87,13 +79,14 @@ var loadStatusRows = async (req, keys) => {
87
79
  });
88
80
  return docs;
89
81
  };
90
- var reviewLocale = (config, source, target, hash, row, req) => {
82
+ var loadLanguageCheck = (req) => pluginOptions(req)?.languageDetection === false ? Promise.resolve(null) : loadWrongLanguageCheck(readLocales(req).targetLocales.concat(readLocales(req).defaultLocale));
83
+ var reviewLocale = (config, source, target, hash, row, req, locale, check) => {
91
84
  const { fields, summary } = computeStatus(collectTranslatableFields({
92
85
  config,
93
86
  dataFrom: source,
94
87
  dataTarget: target ?? {},
95
88
  options: pluginOptions(req)?._options
96
- }));
89
+ }), { locale, check });
97
90
  return {
98
91
  fields,
99
92
  summary,
@@ -112,6 +105,7 @@ var loadCollectionReview = async ({ req, collectionSlug, page = 1, limit = 25 })
112
105
  if (!config)
113
106
  throw new Error(`unknown collection ${collectionSlug}`);
114
107
  const { defaultLocale, targetLocales } = readLocales(req);
108
+ const check = await loadLanguageCheck(req);
115
109
  const sources = await req.payload.find({
116
110
  collection: collectionSlug,
117
111
  locale: defaultLocale,
@@ -150,7 +144,7 @@ var loadCollectionReview = async ({ req, collectionSlug, page = 1, limit = 25 })
150
144
  collectionSlug,
151
145
  id: source.id,
152
146
  locales: Object.fromEntries(targetLocales.map((locale) => {
153
- const { fields: _fields, ...review } = reviewLocale(config, source, targets[locale].get(String(source.id)), hash, rows.find((row) => row.entity === key && row.locale === locale), req);
147
+ const { fields: _fields, ...review } = reviewLocale(config, source, targets[locale].get(String(source.id)), hash, rows.find((row) => row.entity === key && row.locale === locale), req, locale, check);
154
148
  return [locale, review];
155
149
  }))
156
150
  };
@@ -165,6 +159,7 @@ var loadCollectionReview = async ({ req, collectionSlug, page = 1, limit = 25 })
165
159
  };
166
160
  var loadGlobalsReview = async ({ req, globalSlugs }) => {
167
161
  const { defaultLocale, targetLocales } = readLocales(req);
162
+ const check = await loadLanguageCheck(req);
168
163
  const rows = await loadStatusRows(req, globalSlugs.map((globalSlug) => entityKey({ globalSlug })));
169
164
  const entities = [];
170
165
  for (const globalSlug of globalSlugs) {
@@ -184,7 +179,7 @@ var loadGlobalsReview = async ({ req, globalSlugs }) => {
184
179
  const key = entityKey({ globalSlug });
185
180
  const locales = {};
186
181
  for (const locale of targetLocales) {
187
- const { fields: _fields, ...review } = reviewLocale(config, source, await read(locale), hash, rows.find((row) => row.entity === key && row.locale === locale), req);
182
+ const { fields: _fields, ...review } = reviewLocale(config, source, await read(locale), hash, rows.find((row) => row.entity === key && row.locale === locale), req, locale, check);
188
183
  locales[locale] = review;
189
184
  }
190
185
  entities.push({ key, label: globalLabel(config), globalSlug, locales });
@@ -207,7 +202,7 @@ var loadEntityLocaleReview = async ({ req, entity, locale }) => {
207
202
  const target = await read(locale);
208
203
  const rows = await loadStatusRows(req, [entity]);
209
204
  const row = rows.find((each) => each.locale === locale);
210
- const review = reviewLocale(config, source, target, hashOf(config, source, req), row, req);
205
+ const review = reviewLocale(config, source, target, hashOf(config, source, req), row, req, locale, await loadLanguageCheck(req));
211
206
  return {
212
207
  ...review,
213
208
  entity,
@@ -250,4 +245,108 @@ var loadLastJob = async (req, { collectionSlug, globalSlug, id }, locale) => {
250
245
  }
251
246
  };
252
247
 
253
- export { readLocales, loadCollectionReview, loadGlobalsReview, parseEntityKey, loadEntityLocaleReview };
248
+ // src/review/bulk.ts
249
+ var JOBS_SLUG = "payload-jobs";
250
+ var pendingWhere = {
251
+ and: [
252
+ { workflowSlug: { equals: TRANSLATE_WORKFLOW_SLUG2 } },
253
+ { completedAt: { exists: false } },
254
+ { hasError: { not_equals: true } }
255
+ ]
256
+ };
257
+ var hasTranslateWorkflow = (req) => Boolean(req.payload.config.jobs?.workflows?.some((workflow) => workflow.slug === TRANSLATE_WORKFLOW_SLUG2)) && Boolean(req.payload.collections[JOBS_SLUG]);
258
+ var loadJobsProgress = async (req) => {
259
+ if (!hasTranslateWorkflow(req))
260
+ return null;
261
+ const count = (where) => req.payload.count({ collection: JOBS_SLUG, where, overrideAccess: true, req }).then(({ totalDocs }) => totalDocs);
262
+ const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
263
+ const [pending, running, failed] = await Promise.all([
264
+ count(pendingWhere),
265
+ count({ and: [...pendingWhere.and ?? [], { processing: { equals: true } }] }),
266
+ count({ and: [{ workflowSlug: { equals: TRANSLATE_WORKFLOW_SLUG2 } }, { hasError: { equals: true } }, { updatedAt: { greater_than: dayAgo } }] })
267
+ ]);
268
+ return { pending, running, failed };
269
+ };
270
+ var loadPendingPairs = async (req) => {
271
+ const { docs } = await req.payload.find({
272
+ collection: JOBS_SLUG,
273
+ where: pendingWhere,
274
+ pagination: false,
275
+ depth: 0,
276
+ overrideAccess: true,
277
+ req
278
+ });
279
+ const pairs = new Set;
280
+ for (const { input } of docs) {
281
+ if (!input)
282
+ continue;
283
+ const key = entityKey({ collectionSlug: input.collection, globalSlug: input.global, id: input.id });
284
+ for (const locale of input.toLocales ?? (input.toLocale ? [input.toLocale] : []))
285
+ pairs.add(`${key}|${locale}`);
286
+ }
287
+ return pairs;
288
+ };
289
+ var incompleteLocales = (entity, locales) => locales.filter((locale) => {
290
+ const review = entity.locales[locale];
291
+ return review && (review.summary.coverage < 100 || review.summary.slugMissing);
292
+ });
293
+ var queueBulkTranslation = async ({ req, tab, locale, mode }) => {
294
+ const pluginConfig = req.payload.config.custom?.translator;
295
+ const { defaultLocale, targetLocales } = readLocales(req);
296
+ const locales = locale ? [locale] : targetLocales;
297
+ const resolver = pluginConfig.resolvers[0]?.key;
298
+ const pending = await loadPendingPairs(req);
299
+ let queuedDocuments = 0;
300
+ let queuedLocales = 0;
301
+ let skippedPending = 0;
302
+ const queue = async (entity) => {
303
+ const toLocales = incompleteLocales(entity, locales).filter((each) => {
304
+ const already = pending.has(`${entity.key}|${each}`);
305
+ if (already)
306
+ skippedPending++;
307
+ return !already;
308
+ });
309
+ if (!toLocales.length)
310
+ return;
311
+ await req.payload.jobs.queue({
312
+ req,
313
+ workflow: TRANSLATE_WORKFLOW_SLUG2,
314
+ input: {
315
+ id: entity.id,
316
+ collection: entity.collectionSlug,
317
+ global: entity.globalSlug,
318
+ updatedAt: new Date().toISOString(),
319
+ fromLocale: defaultLocale,
320
+ toLocales,
321
+ resolver,
322
+ mode
323
+ }
324
+ });
325
+ queuedDocuments++;
326
+ queuedLocales += toLocales.length;
327
+ };
328
+ if (tab === "globals") {
329
+ const { entities } = await loadGlobalsReview({ req, globalSlugs: pluginConfig.globals });
330
+ for (const entity of entities)
331
+ await queue(entity);
332
+ } else {
333
+ for (let page = 1;; page++) {
334
+ const review = await loadCollectionReview({ req, collectionSlug: tab, page, limit: 50 });
335
+ for (const entity of review.entities)
336
+ await queue(entity);
337
+ if (page >= review.totalPages)
338
+ break;
339
+ }
340
+ }
341
+ return { queuedDocuments, queuedLocales, skippedPending };
342
+ };
343
+ var runQueuedTranslations = async (req, limit = 3) => {
344
+ await req.payload.jobs.run({
345
+ limit,
346
+ where: { workflowSlug: { equals: TRANSLATE_WORKFLOW_SLUG2 } },
347
+ req
348
+ });
349
+ return loadJobsProgress(req);
350
+ };
351
+
352
+ export { readLocales, loadCollectionReview, loadGlobalsReview, parseEntityKey, loadEntityLocaleReview, hasTranslateWorkflow, loadJobsProgress, queueBulkTranslation, runQueuedTranslations };
@@ -1,10 +1,13 @@
1
1
  import {
2
+ loadWrongLanguageCheck,
2
3
  samePlaceholders,
4
+ plainText,
3
5
  traverseFields,
6
+ TRANSLATE_WORKFLOW_SLUG2,
4
7
  collectTranslatableFields,
5
8
  entityKey,
6
9
  sourceHash
7
- } from "./chunk-9d433kmv.js";
10
+ } from "./chunk-3rz1t943.js";
8
11
  import {
9
12
  TRANSLATION_STATUS_SLUG2
10
13
  } from "./chunk-31qgv1zk.js";
@@ -95,6 +98,7 @@ var preview = (value) => {
95
98
  const flat = (typeof value === "string" ? value : String(value ?? "")).replace(/\s+/g, " ").trim();
96
99
  return JSON.stringify(flat.length > 60 ? `${flat.slice(0, 57)}…` : flat);
97
100
  };
101
+ var localeCodes = (req) => (req.payload.config.localization ? req.payload.config.localization.locales : []).map((each) => typeof each === "string" ? each : each.code);
98
102
  var translateOperation2 = async (args) => {
99
103
  const req = "req" in args ? args.req : {
100
104
  payload: args.payload
@@ -124,9 +128,13 @@ var translateOperation2 = async (args) => {
124
128
  });
125
129
  translatedData = doc;
126
130
  }
131
+ const wrongLanguageCheck = args.retranslateIdentical && req.payload.config.custom?.translator?.languageDetection !== false ? await loadWrongLanguageCheck(localeCodes(req)) : null;
132
+ const isWrongLanguage = wrongLanguageCheck ? (target) => Boolean(wrongLanguageCheck(plainText(target), args.locale)) : undefined;
127
133
  traverseFields({
128
134
  dataFrom,
129
135
  emptyOnly: args.emptyOnly,
136
+ retranslateIdentical: args.retranslateIdentical,
137
+ isWrongLanguage,
130
138
  fields: config.fields,
131
139
  translatedData,
132
140
  valuesToTranslate,
@@ -178,7 +186,7 @@ ${valuesToTranslate.map((v) => ` ${v.path ?? "(unknown)"}: ${preview(v.value)}`
178
186
  ${summary.join(`
179
187
  `)}` : "")
180
188
  });
181
- if (args.update) {
189
+ if (args.update && valuesToTranslate.length > 0) {
182
190
  const { _locale, _parent_id, createdAt, updatedAt, ...data } = translatedData;
183
191
  await updateEntity({
184
192
  collectionSlug,
@@ -194,16 +202,13 @@ ${summary.join(`
194
202
  result = {
195
203
  success: true,
196
204
  translatedData,
205
+ translatedCount: valuesToTranslate.length,
197
206
  dataFrom
198
207
  };
199
208
  }
200
209
  return result;
201
210
  };
202
211
 
203
- // src/jobs/constants.ts
204
- var TRANSLATE_TASK_SLUG2 = "translateEntityToLocale";
205
- var TRANSLATE_WORKFLOW_SLUG2 = "translateEntityToLocales";
206
-
207
212
  // src/jobs/createAutoTranslateCollectionHook.ts
208
213
  function createAutoTranslateCollectionHook2(options) {
209
214
  const {
@@ -432,6 +437,13 @@ var recordTranslationStatus = async ({ req, collectionSlug, globalSlug, id, loca
432
437
  await upsertTranslationStatus(req, entityKey({ collectionSlug, globalSlug, id }), locale, reviewedBy ? { reviewedHash: hash, reviewedAt: now, reviewedBy } : { sourceHash: hash, translatedAt: now });
433
438
  };
434
439
 
440
+ // src/utils/translateMode.ts
441
+ var translateModeArgs = (mode) => ({
442
+ emptyOnly: mode !== "all",
443
+ retranslateIdentical: mode === "untranslated"
444
+ });
445
+ var isTranslateMode = (value) => value === "all" || value === "missing" || value === "untranslated";
446
+
435
447
  // src/jobs/createTranslateTask.ts
436
448
  function createTranslateTask2(options = {}) {
437
449
  const { slug = "translateEntityToLocale" } = options;
@@ -451,7 +463,7 @@ function createTranslateTask2(options = {}) {
451
463
  handler: async (args) => {
452
464
  const { input, job, req } = args;
453
465
  const { id, collection, global, fromLocale, toLocale, resolver: inputResolver } = input;
454
- const mode = input.mode ?? readAutoTranslateMode(req);
466
+ const mode = isTranslateMode(input.mode) ? input.mode : readAutoTranslateMode(req);
455
467
  if (!collection && !global) {
456
468
  throw new Error("translateTask: either `collection` or `global` must be provided");
457
469
  }
@@ -470,7 +482,7 @@ function createTranslateTask2(options = {}) {
470
482
  req,
471
483
  collectionSlug: collection,
472
484
  globalSlug: global,
473
- emptyOnly: mode !== "all",
485
+ ...translateModeArgs(mode),
474
486
  id,
475
487
  locale: toLocale,
476
488
  localeFrom: fromLocale,
@@ -507,6 +519,10 @@ function createTranslateTask2(options = {}) {
507
519
  });
508
520
  return { output: { success: true } };
509
521
  }
522
+ if (result.translatedCount === 0) {
523
+ req.payload.logger.info({ jobId: job.id, msg: `[translate] ${entityLabel}#${id ?? global} → ${toLocale}: nothing to translate` });
524
+ return { output: { success: true } };
525
+ }
510
526
  const translated = result.translatedData ?? {};
511
527
  const { _locale: _dropLocale, _parent_id: _dropParent, updatedAt: _dropUpdatedAt, createdAt: _dropCreatedAt, ...data } = translated;
512
528
  req.payload.logger.info({
@@ -558,7 +574,8 @@ function readFirstResolverKey3(req) {
558
574
  return typeof first?.key === "string" ? first.key : "";
559
575
  }
560
576
  function readAutoTranslateMode(req) {
561
- return req.payload.config.custom?.translator?.autoTranslateMode === "all" ? "all" : "missing";
577
+ const mode = req.payload.config.custom?.translator?.autoTranslateMode;
578
+ return isTranslateMode(mode) ? mode : "missing";
562
579
  }
563
580
 
564
581
  // src/jobs/createTranslateWorkflow.ts
@@ -574,11 +591,12 @@ function createTranslateWorkflow2(options = {}) {
574
591
  { name: "fromLocale", type: "text", required: true },
575
592
  { name: "toLocales", type: "json", required: false },
576
593
  { name: "toLocale", type: "text", required: false },
577
- { name: "resolver", type: "text", required: false }
594
+ { name: "resolver", type: "text", required: false },
595
+ { name: "mode", type: "text", required: false }
578
596
  ],
579
597
  handler: async (args) => {
580
598
  const { job, req, tasks } = args;
581
- const { id, collection, global, fromLocale, toLocales, toLocale, updatedAt, resolver } = job.input;
599
+ const { id, collection, global, fromLocale, toLocales, toLocale, updatedAt, resolver, mode } = job.input;
582
600
  if (!collection && !global) {
583
601
  throw new Error("translateWorkflow: either `collection` or `global` must be provided");
584
602
  }
@@ -612,7 +630,8 @@ function createTranslateWorkflow2(options = {}) {
612
630
  global,
613
631
  fromLocale,
614
632
  toLocale: target,
615
- resolver
633
+ resolver,
634
+ mode
616
635
  }
617
636
  });
618
637
  }
@@ -620,4 +639,4 @@ function createTranslateWorkflow2(options = {}) {
620
639
  };
621
640
  }
622
641
 
623
- export { findEntityWithConfig, translateOperation2, TRANSLATE_TASK_SLUG2, TRANSLATE_WORKFLOW_SLUG2, createAutoTranslateCollectionHook2, createAutoTranslateGlobalHook2, recordTranslationStatus, createTranslateTask2, createTranslateWorkflow2 };
642
+ export { findEntityWithConfig, translateOperation2, createAutoTranslateCollectionHook2, createAutoTranslateGlobalHook2, recordTranslationStatus, translateModeArgs, isTranslateMode, createTranslateTask2, createTranslateWorkflow2 };
package/dist/types.d.ts CHANGED
@@ -16,6 +16,11 @@ type TranslateResolver = {
16
16
  key: string;
17
17
  resolve: (args: TranslateResolverArgs) => Promise<TranslateResolverResponse> | TranslateResolverResponse;
18
18
  };
19
+ /**
20
+ * `missing` fills empty fields, `untranslated` also replaces fields that still hold the
21
+ * source copy, `all` re-translates everything.
22
+ */
23
+ type TranslateMode = "all" | "missing" | "untranslated";
19
24
  type TranslatorConfig = {
20
25
  collections: CollectionSlug[];
21
26
  disabled?: boolean;
@@ -27,9 +32,11 @@ type TranslatorConfig = {
27
32
  * `missing` (default) fills empty fields and keeps existing translations and slugs;
28
33
  * `all` re-translates every field.
29
34
  */
30
- autoTranslateMode?: "all" | "missing";
35
+ autoTranslateMode?: TranslateMode;
31
36
  /** adds the /admin/translations review view, its endpoints and the status collection (default true) */
32
37
  review?: boolean;
38
+ /** flag translated fields written in another locale's language (default true) */
39
+ languageDetection?: boolean;
33
40
  _options?: {
34
41
  additionalTraverseRichText?: (args: {
35
42
  onText: (siblingData: Record<string, unknown>, attribute?: string) => void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@justanarthur/payload-plugin-translator",
3
3
  "description": "Translator plugin for Payload CMS — automatic localization via Google, OpenAI, LibreTranslate, or custom resolvers.",
4
- "version": "3.1.1",
4
+ "version": "3.3.0",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "files": [
@@ -89,6 +89,7 @@
89
89
  },
90
90
  "dependencies": {
91
91
  "bson-objectid": "^2.0.4",
92
+ "eld": "^2.1.0",
92
93
  "he": "^1.2.0"
93
94
  },
94
95
  "devDependencies": {