@justanarthur/payload-plugin-translator 3.2.0 → 3.3.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 CHANGED
@@ -80,6 +80,7 @@ export const myResolver: TranslateResolver = {
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
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,13 @@ 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.
131
139
  - **Bulk fix**: *Translate untranslated* queues one translate workflow per incomplete document,
132
140
  for all locales or one, in `untranslated` mode (empty fields and fields still holding the source
133
141
  copy; existing translations and slugs stay). Locales a pending job already covers are skipped.
package/dist/client.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  } from "./shared/chunk-31qgv1zk.js";
5
5
 
6
6
  // src/client/components/CustomButton/CustomButtonWithTranslator.tsx
7
- import { PublishButton, SaveButton, useConfig as useConfig2, useDocumentInfo as useDocumentInfo2 } from "@payloadcms/ui";
7
+ import { PublishButton, SaveButton, useConfig as useConfig2, useDocumentInfo as useDocumentInfo2, useLocale as useLocale2 } from "@payloadcms/ui";
8
8
 
9
9
  // src/client/providers/Translator/TranslatorProvider.tsx
10
10
  import {
@@ -298,9 +298,14 @@ var CustomButtonWithTranslator = ({ type }) => {
298
298
  const { config } = useConfig2();
299
299
  const DefaultButton = type === "publish" ? PublishButton : SaveButton;
300
300
  const { globalSlug, id } = useDocumentInfo2();
301
+ const locale = useLocale2();
301
302
  const resolvers = config.admin?.custom?.translator?.resolvers ?? [];
302
303
  if (!id && !globalSlug)
303
304
  return /* @__PURE__ */ jsx6(DefaultButton, {});
305
+ const { localization } = config;
306
+ const isSourceLocale = Boolean(localization) && locale.code === localization.defaultLocale;
307
+ if (isSourceLocale)
308
+ return /* @__PURE__ */ jsx6(DefaultButton, {});
304
309
  return /* @__PURE__ */ jsx6(TranslatorProvider, {
305
310
  children: /* @__PURE__ */ jsxs4("div", {
306
311
  className: "translator__custom-save-button",
@@ -338,7 +343,7 @@ var BulkActions = ({ tab, locales, progress }) => {
338
343
  };
339
344
  const queue = async () => {
340
345
  const scope = `${locale ? locale.toUpperCase() : "every locale"} of all ${tab}`;
341
- if (!window.confirm(`Queue translation of empty and untranslated fields in ${scope}? Existing translations are kept.`))
346
+ if (!window.confirm(`Queue translation of empty, untranslated and wrong-language fields in ${scope}? Correct translations are kept.`))
342
347
  return;
343
348
  setPending("bulk");
344
349
  try {
package/dist/index.d.ts CHANGED
@@ -68,6 +68,8 @@ type TranslatorConfig = {
68
68
  autoTranslateMode?: TranslateMode;
69
69
  /** adds the /admin/translations review view, its endpoints and the status collection (default true) */
70
70
  review?: boolean;
71
+ /** flag translated fields written in another locale's language (default true) */
72
+ languageDetection?: boolean;
71
73
  _options?: {
72
74
  additionalTraverseRichText?: (args: {
73
75
  onText: (siblingData: Record<string, unknown>, attribute?: string) => void;
package/dist/index.js CHANGED
@@ -8,15 +8,15 @@ import {
8
8
  isTranslateMode,
9
9
  createTranslateTask2,
10
10
  createTranslateWorkflow2
11
- } from "./shared/chunk-hkx0bzb6.js";
12
- import"./shared/chunk-d0n3f8ad.js";
11
+ } from "./shared/chunk-hg50cgsw.js";
12
+ import"./shared/chunk-3rz1t943.js";
13
13
  import {
14
14
  readLocales,
15
15
  parseEntityKey,
16
16
  hasTranslateWorkflow,
17
17
  queueBulkTranslation,
18
18
  runQueuedTranslations
19
- } from "./shared/chunk-4ftfmxgs.js";
19
+ } from "./shared/chunk-gd0rak8x.js";
20
20
  import {
21
21
  TRANSLATION_STATUS_SLUG2,
22
22
  REVIEW_VIEW_PATH
package/dist/jobs.js CHANGED
@@ -3,11 +3,11 @@ import {
3
3
  createAutoTranslateGlobalHook2,
4
4
  createTranslateTask2,
5
5
  createTranslateWorkflow2
6
- } from "./shared/chunk-hkx0bzb6.js";
6
+ } from "./shared/chunk-hg50cgsw.js";
7
7
  import {
8
8
  TRANSLATE_TASK_SLUG2,
9
9
  TRANSLATE_WORKFLOW_SLUG2
10
- } from "./shared/chunk-d0n3f8ad.js";
10
+ } from "./shared/chunk-3rz1t943.js";
11
11
 
12
12
  // src/exports/jobs.ts
13
13
  var jobs = {
package/dist/rsc.js CHANGED
@@ -1,11 +1,11 @@
1
- import"./shared/chunk-d0n3f8ad.js";
1
+ import"./shared/chunk-3rz1t943.js";
2
2
  import {
3
3
  readLocales,
4
4
  loadCollectionReview,
5
5
  loadGlobalsReview,
6
6
  loadEntityLocaleReview,
7
7
  loadJobsProgress
8
- } from "./shared/chunk-4ftfmxgs.js";
8
+ } from "./shared/chunk-gd0rak8x.js";
9
9
  import {
10
10
  REVIEW_VIEW_PATH
11
11
  } from "./shared/chunk-31qgv1zk.js";
@@ -35,7 +35,8 @@ var STATE = {
35
35
  identical: { label: "same as source", tone: "warn" },
36
36
  missing: { label: "missing", tone: "bad" },
37
37
  ok: { label: "translated", tone: "ok" },
38
- placeholders: { label: "placeholders differ", tone: "warn" }
38
+ placeholders: { label: "placeholders differ", tone: "warn" },
39
+ wrongLanguage: { label: "wrong language", tone: "bad" }
39
40
  };
40
41
  var preview = (text) => text.length > 600 ? `${text.slice(0, 597)}…` : text;
41
42
  var readablePath = (path) => path.replace(/\[(\d+)\]/g, (_, index) => ` ${Number(index) + 1}`).replace(/\(([^)]+)\)/g, " · $1").replace(/#(\d+)$/, (_, index) => ` (part ${Number(index) + 1})`).split(".").join(" › ");
@@ -47,6 +48,7 @@ var Detail = ({ adminRoute, review, onlyIssues, tab, page }) => {
47
48
  { label: "translated", value: `${summary.coverage}%`, note: `${summary.ok} of ${summary.total} fields`, bar: true },
48
49
  { label: "missing", value: summary.missing, note: "empty in this locale" },
49
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}` },
50
52
  { label: "placeholders", value: summary.placeholders, note: "differ from source" }
51
53
  ];
52
54
  return /* @__PURE__ */ jsxs(Fragment, {
@@ -219,7 +221,7 @@ var Detail = ({ adminRoute, review, onlyIssues, tab, page }) => {
219
221
  children: /* @__PURE__ */ jsx("span", {
220
222
  className: "tr__pill",
221
223
  "data-tone": STATE[field.state].tone,
222
- children: STATE[field.state].label
224
+ children: field.detectedLanguage ? `${field.detectedLanguage} detected` : STATE[field.state].label
223
225
  })
224
226
  }),
225
227
  /* @__PURE__ */ jsx("td", {
@@ -262,14 +264,15 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
262
264
  return {
263
265
  locale,
264
266
  coverage: fields ? Math.floor(ok / fields * 100) : 100,
265
- incomplete: reviews.filter(isIncomplete).length
267
+ incomplete: reviews.filter(isIncomplete).length,
268
+ wrongLanguage: reviews.filter((each) => each.summary.wrongLanguage > 0).length
266
269
  };
267
270
  });
268
271
  return /* @__PURE__ */ jsxs2(Fragment2, {
269
272
  children: [
270
273
  /* @__PURE__ */ jsx2("div", {
271
274
  className: "tr__stats",
272
- children: stats.map(({ locale, coverage, incomplete }) => /* @__PURE__ */ jsxs2("div", {
275
+ children: stats.map(({ locale, coverage, incomplete, wrongLanguage }) => /* @__PURE__ */ jsxs2("div", {
273
276
  className: "tr__stat",
274
277
  children: [
275
278
  /* @__PURE__ */ jsx2("span", {
@@ -293,6 +296,14 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
293
296
  /* @__PURE__ */ jsx2("span", {
294
297
  className: "tr__stat-note",
295
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
+ ]
296
307
  })
297
308
  ]
298
309
  }, locale))
@@ -339,6 +350,7 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
339
350
  summary.missing && `${summary.missing} missing`,
340
351
  summary.identical && `${summary.identical} same as ${defaultLocale}`,
341
352
  summary.placeholders && `${summary.placeholders} broken placeholders`,
353
+ summary.wrongLanguage && `${summary.wrongLanguage} written in another language`,
342
354
  summary.slugMissing && "no slug: the page 404s",
343
355
  review.stale && "source changed since translation",
344
356
  review.reviewed && "reviewed"
@@ -360,6 +372,13 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
360
372
  " ",
361
373
  [review.stale && "↻", review.reviewed && "✓"].filter(Boolean).join(" ")
362
374
  ]
375
+ }),
376
+ summary.wrongLanguage > 0 && /* @__PURE__ */ jsxs2("span", {
377
+ className: "tr__lang",
378
+ children: [
379
+ " ⚑",
380
+ summary.wrongLanguage
381
+ ]
363
382
  })
364
383
  ]
365
384
  }),
@@ -417,6 +436,10 @@ var Overview = ({ adminRoute, defaultLocale, entities, locales, onlyIssues, tab,
417
436
  "under half, or 404 without a slug"
418
437
  ]
419
438
  }),
439
+ /* @__PURE__ */ jsx2("span", {
440
+ className: "tr__lang",
441
+ children: "⚑ fields written in another language"
442
+ }),
420
443
  /* @__PURE__ */ jsx2("span", {
421
444
  children: "↻ source changed since the last translation"
422
445
  }),
@@ -481,6 +504,7 @@ var reviewStyles = `
481
504
  .tr__cell[data-tone='warn'] { --tr-tone: var(--tr-warn); color: var(--tr-warn); }
482
505
  .tr__cell[data-tone='bad'] { --tr-tone: var(--tr-bad); color: var(--tr-bad); }
483
506
  .tr__marks { font-size: 11px; color: var(--tr-muted); }
507
+ .tr__lang { color: var(--tr-bad); font-weight: 500; }
484
508
 
485
509
  .tr__legend { display: flex; flex-wrap: wrap; gap: 8px 18px; margin-top: 12px; font-size: 12px; color: var(--tr-muted); }
486
510
  .tr__legend span { display: inline-flex; align-items: center; gap: 6px; }
@@ -2,10 +2,71 @@
2
2
  var TRANSLATE_TASK_SLUG2 = "translateEntityToLocale";
3
3
  var TRANSLATE_WORKFLOW_SLUG2 = "translateEntityToLocales";
4
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
+
5
47
  // src/utils/placeholders.ts
6
48
  var placeholdersOf = (value) => (value.match(/\{\s*[\w.]+\s*(?:,[^{}]*)?\}/g) ?? []).map((each) => each.replace(/\s+/g, "")).sort();
7
49
  var samePlaceholders = (source, translated) => placeholdersOf(source).join("|") === placeholdersOf(translated).join("|");
8
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
+
9
70
  // src/translate/traverseFields.ts
10
71
  import ObjectID from "bson-objectid";
11
72
  import { tabHasName } from "payload/shared";
@@ -50,25 +111,6 @@ var isOpaqueText = (value) => {
50
111
  return /^([a-z][a-z0-9+.-]*:|\/\/|\/|#|www\.)/i.test(text) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) || !/\p{L}/u.test(text);
51
112
  };
52
113
 
53
- // src/utils/plainText.ts
54
- var plainText = (value) => {
55
- if (typeof value === "string")
56
- return value;
57
- if (!value || typeof value !== "object")
58
- return "";
59
- if (Array.isArray(value))
60
- return value.map(plainText).filter(Boolean).join(" ");
61
- const node = value;
62
- if (typeof node.text === "string")
63
- return node.text;
64
- if ("root" in node)
65
- return plainText(node.root);
66
- if (Array.isArray(node.children))
67
- return node.children.map(plainText).filter(Boolean).join(node.type === "root" ? `
68
- ` : "");
69
- return "";
70
- };
71
-
72
114
  // src/utils/looksUntranslated.ts
73
115
  var normalize = (text) => text.replace(/\s+/g, " ").trim();
74
116
  var looksUntranslated = (source, target) => {
@@ -126,6 +168,7 @@ var traverseFields = (args) => {
126
168
  dataFrom,
127
169
  emptyOnly,
128
170
  retranslateIdentical,
171
+ isWrongLanguage,
129
172
  fields,
130
173
  localizedParent,
131
174
  path,
@@ -137,7 +180,7 @@ var traverseFields = (args) => {
137
180
  const { additionalTraverseRichText } = _options ?? {};
138
181
  const siblingDataFrom = args.siblingDataFrom ?? dataFrom;
139
182
  const siblingDataTranslated = args.siblingDataTranslated ?? translatedData;
140
- const keepsTarget = (source, target) => Boolean(emptyOnly) && hasText(target) && !(retranslateIdentical && looksUntranslated(source, target));
183
+ const keepsTarget = (source, target) => Boolean(emptyOnly) && hasText(target) && !(retranslateIdentical && (looksUntranslated(source, target) || Boolean(isWrongLanguage?.(target))));
141
184
  const recurse = (overrides) => traverseFields({ ...args, siblingDataFrom, siblingDataTranslated, ...overrides });
142
185
  for (const field of fields) {
143
186
  switch (field.type) {
@@ -388,4 +431,4 @@ var entityKey = ({ collectionSlug, globalSlug, id }) => globalSlug ? `global:${g
388
431
  import { createHash } from "node:crypto";
389
432
  var sourceHash = (fields) => createHash("sha1").update(JSON.stringify(fields.map(({ path, source }) => [path, source]))).digest("hex");
390
433
 
391
- export { samePlaceholders, plainText, looksUntranslated, traverseFields, TRANSLATE_TASK_SLUG2, TRANSLATE_WORKFLOW_SLUG2, collectTranslatableFields, entityKey, sourceHash };
434
+ export { loadWrongLanguageCheck, samePlaceholders, plainText, looksUntranslated, traverseFields, TRANSLATE_TASK_SLUG2, TRANSLATE_WORKFLOW_SLUG2, collectTranslatableFields, entityKey, sourceHash };
@@ -1,4 +1,5 @@
1
1
  import {
2
+ loadWrongLanguageCheck,
2
3
  samePlaceholders,
3
4
  plainText,
4
5
  looksUntranslated,
@@ -6,7 +7,7 @@ import {
6
7
  collectTranslatableFields,
7
8
  entityKey,
8
9
  sourceHash
9
- } from "./chunk-d0n3f8ad.js";
10
+ } from "./chunk-3rz1t943.js";
10
11
  import {
11
12
  TRANSLATION_STATUS_SLUG2
12
13
  } from "./chunk-31qgv1zk.js";
@@ -22,14 +23,22 @@ var classify = (field, sourceText, targetText) => {
22
23
  return "identical";
23
24
  return "ok";
24
25
  };
25
- var computeStatus = (fields) => {
26
+ var computeStatus = (fields, language) => {
26
27
  const statuses = [];
27
28
  for (const field of fields) {
28
29
  const sourceText = plainText(field.source);
29
30
  if (!normalize(sourceText))
30
31
  continue;
31
32
  const targetText = plainText(field.target);
32
- 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
+ });
33
42
  }
34
43
  const count = (state) => statuses.filter((each) => each.state === state).length;
35
44
  const ok = count("ok");
@@ -41,6 +50,7 @@ var computeStatus = (fields) => {
41
50
  missing: count("missing"),
42
51
  identical: count("identical"),
43
52
  placeholders: count("placeholders"),
53
+ wrongLanguage: count("wrongLanguage"),
44
54
  coverage: statuses.length ? Math.floor(ok / statuses.length * 100) : 100,
45
55
  slugMissing: statuses.some((each) => each.type === "slug" && each.state === "missing")
46
56
  }
@@ -69,13 +79,14 @@ var loadStatusRows = async (req, keys) => {
69
79
  });
70
80
  return docs;
71
81
  };
72
- 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) => {
73
84
  const { fields, summary } = computeStatus(collectTranslatableFields({
74
85
  config,
75
86
  dataFrom: source,
76
87
  dataTarget: target ?? {},
77
88
  options: pluginOptions(req)?._options
78
- }));
89
+ }), { locale, check });
79
90
  return {
80
91
  fields,
81
92
  summary,
@@ -94,6 +105,7 @@ var loadCollectionReview = async ({ req, collectionSlug, page = 1, limit = 25 })
94
105
  if (!config)
95
106
  throw new Error(`unknown collection ${collectionSlug}`);
96
107
  const { defaultLocale, targetLocales } = readLocales(req);
108
+ const check = await loadLanguageCheck(req);
97
109
  const sources = await req.payload.find({
98
110
  collection: collectionSlug,
99
111
  locale: defaultLocale,
@@ -132,7 +144,7 @@ var loadCollectionReview = async ({ req, collectionSlug, page = 1, limit = 25 })
132
144
  collectionSlug,
133
145
  id: source.id,
134
146
  locales: Object.fromEntries(targetLocales.map((locale) => {
135
- 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);
136
148
  return [locale, review];
137
149
  }))
138
150
  };
@@ -147,6 +159,7 @@ var loadCollectionReview = async ({ req, collectionSlug, page = 1, limit = 25 })
147
159
  };
148
160
  var loadGlobalsReview = async ({ req, globalSlugs }) => {
149
161
  const { defaultLocale, targetLocales } = readLocales(req);
162
+ const check = await loadLanguageCheck(req);
150
163
  const rows = await loadStatusRows(req, globalSlugs.map((globalSlug) => entityKey({ globalSlug })));
151
164
  const entities = [];
152
165
  for (const globalSlug of globalSlugs) {
@@ -166,7 +179,7 @@ var loadGlobalsReview = async ({ req, globalSlugs }) => {
166
179
  const key = entityKey({ globalSlug });
167
180
  const locales = {};
168
181
  for (const locale of targetLocales) {
169
- 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);
170
183
  locales[locale] = review;
171
184
  }
172
185
  entities.push({ key, label: globalLabel(config), globalSlug, locales });
@@ -189,7 +202,7 @@ var loadEntityLocaleReview = async ({ req, entity, locale }) => {
189
202
  const target = await read(locale);
190
203
  const rows = await loadStatusRows(req, [entity]);
191
204
  const row = rows.find((each) => each.locale === locale);
192
- 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));
193
206
  return {
194
207
  ...review,
195
208
  entity,
@@ -1,11 +1,13 @@
1
1
  import {
2
+ loadWrongLanguageCheck,
2
3
  samePlaceholders,
4
+ plainText,
3
5
  traverseFields,
4
6
  TRANSLATE_WORKFLOW_SLUG2,
5
7
  collectTranslatableFields,
6
8
  entityKey,
7
9
  sourceHash
8
- } from "./chunk-d0n3f8ad.js";
10
+ } from "./chunk-3rz1t943.js";
9
11
  import {
10
12
  TRANSLATION_STATUS_SLUG2
11
13
  } from "./chunk-31qgv1zk.js";
@@ -15,7 +17,8 @@ import he from "he";
15
17
  import { APIError as APIError3 } from "payload";
16
18
 
17
19
  // src/translate/findEntityWithConfig.ts
18
- import { APIError } from "payload";
20
+ import { APIError, isolateObjectProperty } from "payload";
21
+ var isolateReqLocale = (req) => isolateObjectProperty(req, ["locale", "fallbackLocale"]);
19
22
  var findConfigBySlug = (slug, enities) => enities.find((entity) => entity.slug === slug);
20
23
  var findEntityWithConfig = async (args) => {
21
24
  const { collectionSlug, globalSlug, id, locale, overrideAccess, req } = args;
@@ -29,12 +32,13 @@ var findEntityWithConfig = async (args) => {
29
32
  const entityConfig = isGlobal ? findConfigBySlug(globalSlug, config.globals) : findConfigBySlug(collectionSlug, config.collections);
30
33
  if (!entityConfig)
31
34
  throw new APIError("Bad Request", 400);
35
+ const isolatedReq = isolateReqLocale(req);
32
36
  const docPromise = isGlobal ? payload.findGlobal({
33
37
  depth: 0,
34
38
  fallbackLocale: false,
35
39
  locale,
36
40
  overrideAccess,
37
- req,
41
+ req: isolatedReq,
38
42
  slug: args.globalSlug
39
43
  }) : payload.findByID({
40
44
  collection: collectionSlug,
@@ -43,7 +47,7 @@ var findEntityWithConfig = async (args) => {
43
47
  id,
44
48
  locale,
45
49
  overrideAccess,
46
- req
50
+ req: isolatedReq
47
51
  });
48
52
  const doc = await docPromise;
49
53
  return {
@@ -70,13 +74,14 @@ var updateEntity = ({
70
74
  if (!isGlobal && !id)
71
75
  throw new APIError2("Bad Request", 400);
72
76
  const depth = incomingDepth ?? req.payload.config.defaultDepth;
77
+ const isolatedReq = isolateReqLocale(req);
73
78
  const promise = isGlobal ? req.payload.updateGlobal({
74
79
  data,
75
80
  depth,
76
81
  context: { disableAutoTranslate: true },
77
82
  locale,
78
83
  overrideAccess,
79
- req,
84
+ req: isolatedReq,
80
85
  slug: globalSlug
81
86
  }) : req.payload.update({
82
87
  collection: collectionSlug,
@@ -86,7 +91,7 @@ var updateEntity = ({
86
91
  id,
87
92
  locale,
88
93
  overrideAccess,
89
- req
94
+ req: isolatedReq
90
95
  });
91
96
  return promise;
92
97
  };
@@ -96,11 +101,17 @@ var preview = (value) => {
96
101
  const flat = (typeof value === "string" ? value : String(value ?? "")).replace(/\s+/g, " ").trim();
97
102
  return JSON.stringify(flat.length > 60 ? `${flat.slice(0, 57)}…` : flat);
98
103
  };
104
+ var localeCodes = (req) => (req.payload.config.localization ? req.payload.config.localization.locales : []).map((each) => typeof each === "string" ? each : each.code);
99
105
  var translateOperation2 = async (args) => {
100
106
  const req = "req" in args ? args.req : {
101
107
  payload: args.payload
102
108
  };
103
109
  const { collectionSlug, globalSlug, id, locale, localeFrom, overrideAccess } = args;
110
+ const localization = req.payload.config.localization;
111
+ const defaultLocale = localization ? localization.defaultLocale : undefined;
112
+ if (defaultLocale && locale === defaultLocale) {
113
+ throw new APIError3(`Refusing to translate into the default locale "${defaultLocale}" — it is the source of truth. Translate from it into a target locale instead.`, 400);
114
+ }
104
115
  const { config, doc: dataFrom } = await findEntityWithConfig({
105
116
  collectionSlug,
106
117
  globalSlug,
@@ -125,10 +136,13 @@ var translateOperation2 = async (args) => {
125
136
  });
126
137
  translatedData = doc;
127
138
  }
139
+ const wrongLanguageCheck = args.retranslateIdentical && req.payload.config.custom?.translator?.languageDetection !== false ? await loadWrongLanguageCheck(localeCodes(req)) : null;
140
+ const isWrongLanguage = wrongLanguageCheck ? (target) => Boolean(wrongLanguageCheck(plainText(target), args.locale)) : undefined;
128
141
  traverseFields({
129
142
  dataFrom,
130
143
  emptyOnly: args.emptyOnly,
131
144
  retranslateIdentical: args.retranslateIdentical,
145
+ isWrongLanguage,
132
146
  fields: config.fields,
133
147
  translatedData,
134
148
  valuesToTranslate,
package/dist/types.d.ts CHANGED
@@ -35,6 +35,8 @@ type TranslatorConfig = {
35
35
  autoTranslateMode?: TranslateMode;
36
36
  /** adds the /admin/translations review view, its endpoints and the status collection (default true) */
37
37
  review?: boolean;
38
+ /** flag translated fields written in another locale's language (default true) */
39
+ languageDetection?: boolean;
38
40
  _options?: {
39
41
  additionalTraverseRichText?: (args: {
40
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.2.0",
4
+ "version": "3.3.1",
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": {