@justanarthur/payload-plugin-translator 3.1.0 → 3.2.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.
@@ -1,33 +1,16 @@
1
1
  import {
2
2
  samePlaceholders,
3
- isOpaqueText,
3
+ plainText,
4
+ looksUntranslated,
5
+ TRANSLATE_WORKFLOW_SLUG2,
4
6
  collectTranslatableFields,
5
7
  entityKey,
6
8
  sourceHash
7
- } from "./chunk-9d433kmv.js";
9
+ } from "./chunk-d0n3f8ad.js";
8
10
  import {
9
11
  TRANSLATION_STATUS_SLUG2
10
12
  } from "./chunk-31qgv1zk.js";
11
13
 
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
14
  // src/review/computeStatus.ts
32
15
  var normalize = (text) => text.replace(/\s+/g, " ").trim();
33
16
  var classify = (field, sourceText, targetText) => {
@@ -35,8 +18,7 @@ var classify = (field, sourceText, targetText) => {
35
18
  return "missing";
36
19
  if (field.type === "json" && !samePlaceholders(sourceText, targetText))
37
20
  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)))
21
+ if (field.type !== "slug" && looksUntranslated(sourceText, targetText))
40
22
  return "identical";
41
23
  return "ok";
42
24
  };
@@ -250,4 +232,108 @@ var loadLastJob = async (req, { collectionSlug, globalSlug, id }, locale) => {
250
232
  }
251
233
  };
252
234
 
253
- export { readLocales, loadCollectionReview, loadGlobalsReview, parseEntityKey, loadEntityLocaleReview };
235
+ // src/review/bulk.ts
236
+ var JOBS_SLUG = "payload-jobs";
237
+ var pendingWhere = {
238
+ and: [
239
+ { workflowSlug: { equals: TRANSLATE_WORKFLOW_SLUG2 } },
240
+ { completedAt: { exists: false } },
241
+ { hasError: { not_equals: true } }
242
+ ]
243
+ };
244
+ var hasTranslateWorkflow = (req) => Boolean(req.payload.config.jobs?.workflows?.some((workflow) => workflow.slug === TRANSLATE_WORKFLOW_SLUG2)) && Boolean(req.payload.collections[JOBS_SLUG]);
245
+ var loadJobsProgress = async (req) => {
246
+ if (!hasTranslateWorkflow(req))
247
+ return null;
248
+ const count = (where) => req.payload.count({ collection: JOBS_SLUG, where, overrideAccess: true, req }).then(({ totalDocs }) => totalDocs);
249
+ const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
250
+ const [pending, running, failed] = await Promise.all([
251
+ count(pendingWhere),
252
+ count({ and: [...pendingWhere.and ?? [], { processing: { equals: true } }] }),
253
+ count({ and: [{ workflowSlug: { equals: TRANSLATE_WORKFLOW_SLUG2 } }, { hasError: { equals: true } }, { updatedAt: { greater_than: dayAgo } }] })
254
+ ]);
255
+ return { pending, running, failed };
256
+ };
257
+ var loadPendingPairs = async (req) => {
258
+ const { docs } = await req.payload.find({
259
+ collection: JOBS_SLUG,
260
+ where: pendingWhere,
261
+ pagination: false,
262
+ depth: 0,
263
+ overrideAccess: true,
264
+ req
265
+ });
266
+ const pairs = new Set;
267
+ for (const { input } of docs) {
268
+ if (!input)
269
+ continue;
270
+ const key = entityKey({ collectionSlug: input.collection, globalSlug: input.global, id: input.id });
271
+ for (const locale of input.toLocales ?? (input.toLocale ? [input.toLocale] : []))
272
+ pairs.add(`${key}|${locale}`);
273
+ }
274
+ return pairs;
275
+ };
276
+ var incompleteLocales = (entity, locales) => locales.filter((locale) => {
277
+ const review = entity.locales[locale];
278
+ return review && (review.summary.coverage < 100 || review.summary.slugMissing);
279
+ });
280
+ var queueBulkTranslation = async ({ req, tab, locale, mode }) => {
281
+ const pluginConfig = req.payload.config.custom?.translator;
282
+ const { defaultLocale, targetLocales } = readLocales(req);
283
+ const locales = locale ? [locale] : targetLocales;
284
+ const resolver = pluginConfig.resolvers[0]?.key;
285
+ const pending = await loadPendingPairs(req);
286
+ let queuedDocuments = 0;
287
+ let queuedLocales = 0;
288
+ let skippedPending = 0;
289
+ const queue = async (entity) => {
290
+ const toLocales = incompleteLocales(entity, locales).filter((each) => {
291
+ const already = pending.has(`${entity.key}|${each}`);
292
+ if (already)
293
+ skippedPending++;
294
+ return !already;
295
+ });
296
+ if (!toLocales.length)
297
+ return;
298
+ await req.payload.jobs.queue({
299
+ req,
300
+ workflow: TRANSLATE_WORKFLOW_SLUG2,
301
+ input: {
302
+ id: entity.id,
303
+ collection: entity.collectionSlug,
304
+ global: entity.globalSlug,
305
+ updatedAt: new Date().toISOString(),
306
+ fromLocale: defaultLocale,
307
+ toLocales,
308
+ resolver,
309
+ mode
310
+ }
311
+ });
312
+ queuedDocuments++;
313
+ queuedLocales += toLocales.length;
314
+ };
315
+ if (tab === "globals") {
316
+ const { entities } = await loadGlobalsReview({ req, globalSlugs: pluginConfig.globals });
317
+ for (const entity of entities)
318
+ await queue(entity);
319
+ } else {
320
+ for (let page = 1;; page++) {
321
+ const review = await loadCollectionReview({ req, collectionSlug: tab, page, limit: 50 });
322
+ for (const entity of review.entities)
323
+ await queue(entity);
324
+ if (page >= review.totalPages)
325
+ break;
326
+ }
327
+ }
328
+ return { queuedDocuments, queuedLocales, skippedPending };
329
+ };
330
+ var runQueuedTranslations = async (req, limit = 3) => {
331
+ await req.payload.jobs.run({
332
+ limit,
333
+ where: { workflowSlug: { equals: TRANSLATE_WORKFLOW_SLUG2 } },
334
+ req
335
+ });
336
+ return loadJobsProgress(req);
337
+ };
338
+
339
+ export { readLocales, loadCollectionReview, loadGlobalsReview, parseEntityKey, loadEntityLocaleReview, hasTranslateWorkflow, loadJobsProgress, queueBulkTranslation, runQueuedTranslations };
@@ -1,3 +1,7 @@
1
+ // src/jobs/constants.ts
2
+ var TRANSLATE_TASK_SLUG2 = "translateEntityToLocale";
3
+ var TRANSLATE_WORKFLOW_SLUG2 = "translateEntityToLocales";
4
+
1
5
  // src/utils/placeholders.ts
2
6
  var placeholdersOf = (value) => (value.match(/\{\s*[\w.]+\s*(?:,[^{}]*)?\}/g) ?? []).map((each) => each.replace(/\s+/g, "")).sort();
3
7
  var samePlaceholders = (source, translated) => placeholdersOf(source).join("|") === placeholdersOf(translated).join("|");
@@ -46,6 +50,32 @@ var isOpaqueText = (value) => {
46
50
  return /^([a-z][a-z0-9+.-]*:|\/\/|\/|#|www\.)/i.test(text) || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(text) || !/\p{L}/u.test(text);
47
51
  };
48
52
 
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
+ // src/utils/looksUntranslated.ts
73
+ var normalize = (text) => text.replace(/\s+/g, " ").trim();
74
+ var looksUntranslated = (source, target) => {
75
+ const sourceText = normalize(plainText(source));
76
+ return sourceText.length > 3 && /\s/.test(sourceText) && !isOpaqueText(sourceText) && sourceText === normalize(plainText(target));
77
+ };
78
+
49
79
  // src/utils/sanitizeSlug.ts
50
80
  var sanitizeSlug = (text) => text.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9-_]+/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "");
51
81
 
@@ -95,6 +125,7 @@ var traverseFields = (args) => {
95
125
  const {
96
126
  dataFrom,
97
127
  emptyOnly,
128
+ retranslateIdentical,
98
129
  fields,
99
130
  localizedParent,
100
131
  path,
@@ -106,6 +137,7 @@ var traverseFields = (args) => {
106
137
  const { additionalTraverseRichText } = _options ?? {};
107
138
  const siblingDataFrom = args.siblingDataFrom ?? dataFrom;
108
139
  const siblingDataTranslated = args.siblingDataTranslated ?? translatedData;
140
+ const keepsTarget = (source, target) => Boolean(emptyOnly) && hasText(target) && !(retranslateIdentical && looksUntranslated(source, target));
109
141
  const recurse = (overrides) => traverseFields({ ...args, siblingDataFrom, siblingDataTranslated, ...overrides });
110
142
  for (const field of fields) {
111
143
  switch (field.type) {
@@ -226,7 +258,7 @@ var traverseFields = (args) => {
226
258
  onField?.({ path: keyPath, type: "json", source: value, target: previous });
227
259
  if (!value.trim())
228
260
  continue;
229
- if (emptyOnly && typeof previous === "string" && previous.trim())
261
+ if (keepsTarget(value, previous))
230
262
  continue;
231
263
  target[key] = value;
232
264
  if (isOpaqueText(value))
@@ -266,7 +298,7 @@ var traverseFields = (args) => {
266
298
  if (typeof value !== "string" || !value.trim())
267
299
  break;
268
300
  const hasCurrent = typeof current === "string" && current.trim().length > 0;
269
- if (hasCurrent && (emptyOnly || isSlug))
301
+ if (isSlug ? hasCurrent : keepsTarget(value, current))
270
302
  break;
271
303
  if (isOpaqueText(value)) {
272
304
  siblingDataTranslated[field.name] = value;
@@ -304,7 +336,7 @@ var traverseFields = (args) => {
304
336
  onField?.({ path: richTextPath, type: "richText", source: richTextDataFrom, target: current });
305
337
  if (!richTextDataFrom || !hasText(richTextDataFrom))
306
338
  break;
307
- if (emptyOnly && hasText(current))
339
+ if (keepsTarget(richTextDataFrom, current))
308
340
  break;
309
341
  const isSlate = Array.isArray(richTextDataFrom);
310
342
  const isLexical = typeof richTextDataFrom === "object" && "root" in richTextDataFrom;
@@ -356,4 +388,4 @@ var entityKey = ({ collectionSlug, globalSlug, id }) => globalSlug ? `global:${g
356
388
  import { createHash } from "node:crypto";
357
389
  var sourceHash = (fields) => createHash("sha1").update(JSON.stringify(fields.map(({ path, source }) => [path, source]))).digest("hex");
358
390
 
359
- export { samePlaceholders, isOpaqueText, traverseFields, collectTranslatableFields, entityKey, sourceHash };
391
+ export { samePlaceholders, plainText, looksUntranslated, traverseFields, TRANSLATE_TASK_SLUG2, TRANSLATE_WORKFLOW_SLUG2, collectTranslatableFields, entityKey, sourceHash };
@@ -1,10 +1,11 @@
1
1
  import {
2
2
  samePlaceholders,
3
3
  traverseFields,
4
+ TRANSLATE_WORKFLOW_SLUG2,
4
5
  collectTranslatableFields,
5
6
  entityKey,
6
7
  sourceHash
7
- } from "./chunk-9d433kmv.js";
8
+ } from "./chunk-d0n3f8ad.js";
8
9
  import {
9
10
  TRANSLATION_STATUS_SLUG2
10
11
  } from "./chunk-31qgv1zk.js";
@@ -127,6 +128,7 @@ var translateOperation2 = async (args) => {
127
128
  traverseFields({
128
129
  dataFrom,
129
130
  emptyOnly: args.emptyOnly,
131
+ retranslateIdentical: args.retranslateIdentical,
130
132
  fields: config.fields,
131
133
  translatedData,
132
134
  valuesToTranslate,
@@ -178,7 +180,7 @@ ${valuesToTranslate.map((v) => ` ${v.path ?? "(unknown)"}: ${preview(v.value)}`
178
180
  ${summary.join(`
179
181
  `)}` : "")
180
182
  });
181
- if (args.update) {
183
+ if (args.update && valuesToTranslate.length > 0) {
182
184
  const { _locale, _parent_id, createdAt, updatedAt, ...data } = translatedData;
183
185
  await updateEntity({
184
186
  collectionSlug,
@@ -194,16 +196,13 @@ ${summary.join(`
194
196
  result = {
195
197
  success: true,
196
198
  translatedData,
199
+ translatedCount: valuesToTranslate.length,
197
200
  dataFrom
198
201
  };
199
202
  }
200
203
  return result;
201
204
  };
202
205
 
203
- // src/jobs/constants.ts
204
- var TRANSLATE_TASK_SLUG2 = "translateEntityToLocale";
205
- var TRANSLATE_WORKFLOW_SLUG2 = "translateEntityToLocales";
206
-
207
206
  // src/jobs/createAutoTranslateCollectionHook.ts
208
207
  function createAutoTranslateCollectionHook2(options) {
209
208
  const {
@@ -432,6 +431,13 @@ var recordTranslationStatus = async ({ req, collectionSlug, globalSlug, id, loca
432
431
  await upsertTranslationStatus(req, entityKey({ collectionSlug, globalSlug, id }), locale, reviewedBy ? { reviewedHash: hash, reviewedAt: now, reviewedBy } : { sourceHash: hash, translatedAt: now });
433
432
  };
434
433
 
434
+ // src/utils/translateMode.ts
435
+ var translateModeArgs = (mode) => ({
436
+ emptyOnly: mode !== "all",
437
+ retranslateIdentical: mode === "untranslated"
438
+ });
439
+ var isTranslateMode = (value) => value === "all" || value === "missing" || value === "untranslated";
440
+
435
441
  // src/jobs/createTranslateTask.ts
436
442
  function createTranslateTask2(options = {}) {
437
443
  const { slug = "translateEntityToLocale" } = options;
@@ -451,7 +457,7 @@ function createTranslateTask2(options = {}) {
451
457
  handler: async (args) => {
452
458
  const { input, job, req } = args;
453
459
  const { id, collection, global, fromLocale, toLocale, resolver: inputResolver } = input;
454
- const mode = input.mode ?? readAutoTranslateMode(req);
460
+ const mode = isTranslateMode(input.mode) ? input.mode : readAutoTranslateMode(req);
455
461
  if (!collection && !global) {
456
462
  throw new Error("translateTask: either `collection` or `global` must be provided");
457
463
  }
@@ -470,7 +476,7 @@ function createTranslateTask2(options = {}) {
470
476
  req,
471
477
  collectionSlug: collection,
472
478
  globalSlug: global,
473
- emptyOnly: mode !== "all",
479
+ ...translateModeArgs(mode),
474
480
  id,
475
481
  locale: toLocale,
476
482
  localeFrom: fromLocale,
@@ -507,6 +513,10 @@ function createTranslateTask2(options = {}) {
507
513
  });
508
514
  return { output: { success: true } };
509
515
  }
516
+ if (result.translatedCount === 0) {
517
+ req.payload.logger.info({ jobId: job.id, msg: `[translate] ${entityLabel}#${id ?? global} → ${toLocale}: nothing to translate` });
518
+ return { output: { success: true } };
519
+ }
510
520
  const translated = result.translatedData ?? {};
511
521
  const { _locale: _dropLocale, _parent_id: _dropParent, updatedAt: _dropUpdatedAt, createdAt: _dropCreatedAt, ...data } = translated;
512
522
  req.payload.logger.info({
@@ -558,7 +568,8 @@ function readFirstResolverKey3(req) {
558
568
  return typeof first?.key === "string" ? first.key : "";
559
569
  }
560
570
  function readAutoTranslateMode(req) {
561
- return req.payload.config.custom?.translator?.autoTranslateMode === "all" ? "all" : "missing";
571
+ const mode = req.payload.config.custom?.translator?.autoTranslateMode;
572
+ return isTranslateMode(mode) ? mode : "missing";
562
573
  }
563
574
 
564
575
  // src/jobs/createTranslateWorkflow.ts
@@ -574,11 +585,12 @@ function createTranslateWorkflow2(options = {}) {
574
585
  { name: "fromLocale", type: "text", required: true },
575
586
  { name: "toLocales", type: "json", required: false },
576
587
  { name: "toLocale", type: "text", required: false },
577
- { name: "resolver", type: "text", required: false }
588
+ { name: "resolver", type: "text", required: false },
589
+ { name: "mode", type: "text", required: false }
578
590
  ],
579
591
  handler: async (args) => {
580
592
  const { job, req, tasks } = args;
581
- const { id, collection, global, fromLocale, toLocales, toLocale, updatedAt, resolver } = job.input;
593
+ const { id, collection, global, fromLocale, toLocales, toLocale, updatedAt, resolver, mode } = job.input;
582
594
  if (!collection && !global) {
583
595
  throw new Error("translateWorkflow: either `collection` or `global` must be provided");
584
596
  }
@@ -612,7 +624,8 @@ function createTranslateWorkflow2(options = {}) {
612
624
  global,
613
625
  fromLocale,
614
626
  toLocale: target,
615
- resolver
627
+ resolver,
628
+ mode
616
629
  }
617
630
  });
618
631
  }
@@ -620,4 +633,4 @@ function createTranslateWorkflow2(options = {}) {
620
633
  };
621
634
  }
622
635
 
623
- export { findEntityWithConfig, translateOperation2, TRANSLATE_TASK_SLUG2, TRANSLATE_WORKFLOW_SLUG2, createAutoTranslateCollectionHook2, createAutoTranslateGlobalHook2, recordTranslationStatus, createTranslateTask2, createTranslateWorkflow2 };
636
+ 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,7 +32,7 @@ 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;
33
38
  _options?: {
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.0",
4
+ "version": "3.2.0",
5
5
  "type": "module",
6
6
  "private": false,
7
7
  "files": [