@purposeinplay/payload-ai-translate 0.1.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.
Files changed (301) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +714 -0
  3. package/dist/alerts-collection.d.ts +21 -0
  4. package/dist/alerts-collection.js +159 -0
  5. package/dist/api.d.ts +4 -0
  6. package/dist/api.js +918 -0
  7. package/dist/bulk-translate-batches-collection.d.ts +29 -0
  8. package/dist/bulk-translate-batches-collection.js +404 -0
  9. package/dist/bulk-translate-units-collection.d.ts +35 -0
  10. package/dist/bulk-translate-units-collection.js +310 -0
  11. package/dist/client/estimated-cost-cell.d.ts +6 -0
  12. package/dist/client/estimated-cost-cell.js +12 -0
  13. package/dist/client/excluded-fields-field.d.ts +45 -0
  14. package/dist/client/excluded-fields-field.js +553 -0
  15. package/dist/client/field-translate-button.d.ts +6 -0
  16. package/dist/client/field-translate-button.js +199 -0
  17. package/dist/client/index.d.ts +6 -0
  18. package/dist/client/index.js +6 -0
  19. package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
  20. package/dist/client/lib/use-global-kill-switches.js +58 -0
  21. package/dist/client/translate-button.d.ts +2 -0
  22. package/dist/client/translate-button.js +228 -0
  23. package/dist/client/translate-modal.d.ts +16 -0
  24. package/dist/client/translate-modal.js +549 -0
  25. package/dist/client/translation-progress.d.ts +10 -0
  26. package/dist/client/translation-progress.js +297 -0
  27. package/dist/components/TranslationNavGroup.d.ts +45 -0
  28. package/dist/components/TranslationNavGroup.js +104 -0
  29. package/dist/defaults.d.ts +11 -0
  30. package/dist/defaults.js +16 -0
  31. package/dist/endpoints/client-config.d.ts +44 -0
  32. package/dist/endpoints/client-config.js +145 -0
  33. package/dist/endpoints/estimate.d.ts +5 -0
  34. package/dist/endpoints/estimate.js +237 -0
  35. package/dist/endpoints/progress.d.ts +2 -0
  36. package/dist/endpoints/progress.js +314 -0
  37. package/dist/endpoints/translate.d.ts +11 -0
  38. package/dist/endpoints/translate.js +376 -0
  39. package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
  40. package/dist/endpoints/translation-hub/_helpers.js +297 -0
  41. package/dist/endpoints/translation-hub/active.d.ts +21 -0
  42. package/dist/endpoints/translation-hub/active.js +220 -0
  43. package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
  44. package/dist/endpoints/translation-hub/cancel.js +233 -0
  45. package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
  46. package/dist/endpoints/translation-hub/enqueue.js +529 -0
  47. package/dist/endpoints/translation-hub/failures.d.ts +12 -0
  48. package/dist/endpoints/translation-hub/failures.js +67 -0
  49. package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
  50. package/dist/endpoints/translation-hub/force-reset.js +144 -0
  51. package/dist/endpoints/translation-hub/index.d.ts +21 -0
  52. package/dist/endpoints/translation-hub/index.js +20 -0
  53. package/dist/endpoints/translation-hub/list.d.ts +40 -0
  54. package/dist/endpoints/translation-hub/list.js +182 -0
  55. package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
  56. package/dist/endpoints/translation-hub/preflight.js +141 -0
  57. package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
  58. package/dist/endpoints/translation-hub/retry-failed.js +235 -0
  59. package/dist/endpoints/translation-hub/revert.d.ts +88 -0
  60. package/dist/endpoints/translation-hub/revert.js +405 -0
  61. package/dist/endpoints/translation-hub/status.d.ts +45 -0
  62. package/dist/endpoints/translation-hub/status.js +391 -0
  63. package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
  64. package/dist/endpoints/translation-hub/usage-summary.js +481 -0
  65. package/dist/exports/client.d.ts +6 -0
  66. package/dist/exports/client.js +6 -0
  67. package/dist/exports/components.d.ts +6 -0
  68. package/dist/exports/components.js +5 -0
  69. package/dist/exports/index.d.ts +8 -0
  70. package/dist/exports/index.js +7 -0
  71. package/dist/exports/providers.d.ts +9 -0
  72. package/dist/exports/providers.js +5 -0
  73. package/dist/exports/views-client.d.ts +23 -0
  74. package/dist/exports/views-client.js +22 -0
  75. package/dist/exports/views.d.ts +30 -0
  76. package/dist/exports/views.js +29 -0
  77. package/dist/hooks/after-change-global.d.ts +4 -0
  78. package/dist/hooks/after-change-global.js +109 -0
  79. package/dist/hooks/after-change.d.ts +16 -0
  80. package/dist/hooks/after-change.js +205 -0
  81. package/dist/hooks/after-delete.d.ts +30 -0
  82. package/dist/hooks/after-delete.js +95 -0
  83. package/dist/index.d.ts +5 -0
  84. package/dist/index.js +5 -0
  85. package/dist/jobs-collection.d.ts +17 -0
  86. package/dist/jobs-collection.js +139 -0
  87. package/dist/lexical/classifier.d.ts +3 -0
  88. package/dist/lexical/classifier.js +108 -0
  89. package/dist/lexical/deserializer.d.ts +4 -0
  90. package/dist/lexical/deserializer.js +263 -0
  91. package/dist/lexical/placeholder-integrity.d.ts +6 -0
  92. package/dist/lexical/placeholder-integrity.js +21 -0
  93. package/dist/lexical/placeholders.d.ts +21 -0
  94. package/dist/lexical/placeholders.js +117 -0
  95. package/dist/lexical/serializer.d.ts +21 -0
  96. package/dist/lexical/serializer.js +233 -0
  97. package/dist/lexical/types.d.ts +32 -0
  98. package/dist/lexical/types.js +1 -0
  99. package/dist/lib/auth-diagnostics.d.ts +14 -0
  100. package/dist/lib/auth-diagnostics.js +19 -0
  101. package/dist/lib/batch-counts.d.ts +58 -0
  102. package/dist/lib/batch-counts.js +105 -0
  103. package/dist/lib/bulk-translate-migrations.d.ts +92 -0
  104. package/dist/lib/bulk-translate-migrations.js +153 -0
  105. package/dist/lib/coalescing-queue.d.ts +38 -0
  106. package/dist/lib/coalescing-queue.js +69 -0
  107. package/dist/lib/content-extractor.d.ts +16 -0
  108. package/dist/lib/content-extractor.js +410 -0
  109. package/dist/lib/content-hash.d.ts +1 -0
  110. package/dist/lib/content-hash.js +19 -0
  111. package/dist/lib/content-patcher.d.ts +15 -0
  112. package/dist/lib/content-patcher.js +293 -0
  113. package/dist/lib/cost-guards.d.ts +2 -0
  114. package/dist/lib/cost-guards.js +18 -0
  115. package/dist/lib/daily-spend-cap.d.ts +58 -0
  116. package/dist/lib/daily-spend-cap.js +233 -0
  117. package/dist/lib/effective-locales.d.ts +181 -0
  118. package/dist/lib/effective-locales.js +302 -0
  119. package/dist/lib/error-messages.d.ts +245 -0
  120. package/dist/lib/error-messages.js +626 -0
  121. package/dist/lib/events.d.ts +39 -0
  122. package/dist/lib/events.js +146 -0
  123. package/dist/lib/exclude-fields.d.ts +3 -0
  124. package/dist/lib/exclude-fields.js +64 -0
  125. package/dist/lib/field-breadcrumb.d.ts +31 -0
  126. package/dist/lib/field-breadcrumb.js +227 -0
  127. package/dist/lib/field-diff.d.ts +1 -0
  128. package/dist/lib/field-diff.js +25 -0
  129. package/dist/lib/field-empty.d.ts +2 -0
  130. package/dist/lib/field-empty.js +68 -0
  131. package/dist/lib/field-resolver.d.ts +3 -0
  132. package/dist/lib/field-resolver.js +164 -0
  133. package/dist/lib/group-soft-skips.d.ts +39 -0
  134. package/dist/lib/group-soft-skips.js +45 -0
  135. package/dist/lib/locale-merge.d.ts +44 -0
  136. package/dist/lib/locale-merge.js +357 -0
  137. package/dist/lib/locale-row-check.d.ts +30 -0
  138. package/dist/lib/locale-row-check.js +64 -0
  139. package/dist/lib/logger.d.ts +74 -0
  140. package/dist/lib/logger.js +97 -0
  141. package/dist/lib/manual-edit-guard.d.ts +128 -0
  142. package/dist/lib/manual-edit-guard.js +393 -0
  143. package/dist/lib/output-validation.d.ts +48 -0
  144. package/dist/lib/output-validation.js +148 -0
  145. package/dist/lib/payload-read.d.ts +16 -0
  146. package/dist/lib/payload-read.js +51 -0
  147. package/dist/lib/per-doc-claim.d.ts +90 -0
  148. package/dist/lib/per-doc-claim.js +140 -0
  149. package/dist/lib/per-doc-lock.d.ts +94 -0
  150. package/dist/lib/per-doc-lock.js +119 -0
  151. package/dist/lib/persist-usage.d.ts +91 -0
  152. package/dist/lib/persist-usage.js +116 -0
  153. package/dist/lib/progress-store.d.ts +103 -0
  154. package/dist/lib/progress-store.js +314 -0
  155. package/dist/lib/rate-limiter.d.ts +3 -0
  156. package/dist/lib/rate-limiter.js +53 -0
  157. package/dist/lib/snapshot-select.d.ts +43 -0
  158. package/dist/lib/snapshot-select.js +108 -0
  159. package/dist/lib/translate-prompt.d.ts +31 -0
  160. package/dist/lib/translate-prompt.js +66 -0
  161. package/dist/lib/translation-token-bucket.d.ts +57 -0
  162. package/dist/lib/translation-token-bucket.js +365 -0
  163. package/dist/lib/truncate-source-value.d.ts +1 -0
  164. package/dist/lib/truncate-source-value.js +27 -0
  165. package/dist/manual-edit-collection.d.ts +22 -0
  166. package/dist/manual-edit-collection.js +124 -0
  167. package/dist/plugin.d.ts +3 -0
  168. package/dist/plugin.js +934 -0
  169. package/dist/providers/ai-sdk-adapter.d.ts +35 -0
  170. package/dist/providers/ai-sdk-adapter.js +100 -0
  171. package/dist/providers/anthropic.d.ts +31 -0
  172. package/dist/providers/anthropic.js +66 -0
  173. package/dist/providers/custom.d.ts +36 -0
  174. package/dist/providers/custom.js +24 -0
  175. package/dist/providers/gemini.d.ts +20 -0
  176. package/dist/providers/gemini.js +48 -0
  177. package/dist/providers/mock.d.ts +2 -0
  178. package/dist/providers/mock.js +29 -0
  179. package/dist/providers/openai.d.ts +28 -0
  180. package/dist/providers/openai.js +69 -0
  181. package/dist/settings-global.d.ts +74 -0
  182. package/dist/settings-global.js +216 -0
  183. package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
  184. package/dist/tasks/bulk-translate-coordinator.js +708 -0
  185. package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
  186. package/dist/tasks/bulk-translate-doc-task.js +1000 -0
  187. package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
  188. package/dist/tasks/bulk-translate-janitor.js +311 -0
  189. package/dist/tasks/translate-job-task.d.ts +51 -0
  190. package/dist/tasks/translate-job-task.js +154 -0
  191. package/dist/translate.d.ts +113 -0
  192. package/dist/translate.js +911 -0
  193. package/dist/translation-daily-spend-collection.d.ts +24 -0
  194. package/dist/translation-daily-spend-collection.js +133 -0
  195. package/dist/translation-rate-limits-collection.d.ts +30 -0
  196. package/dist/translation-rate-limits-collection.js +144 -0
  197. package/dist/types.d.ts +672 -0
  198. package/dist/types.js +1 -0
  199. package/dist/usage-collection.d.ts +14 -0
  200. package/dist/usage-collection.js +377 -0
  201. package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
  202. package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
  203. package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
  204. package/dist/views/BulkRunsHub/BucketRow.js +982 -0
  205. package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
  206. package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
  207. package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
  208. package/dist/views/BulkRunsHub/EmptyState.js +64 -0
  209. package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
  210. package/dist/views/BulkRunsHub/FilterBar.js +284 -0
  211. package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
  212. package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
  213. package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
  214. package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
  215. package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
  216. package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
  217. package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
  218. package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
  219. package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
  220. package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
  221. package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
  222. package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
  223. package/dist/views/BulkRunsHub/index.d.ts +17 -0
  224. package/dist/views/BulkRunsHub/index.js +80 -0
  225. package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
  226. package/dist/views/BulkRunsHub/urlFilters.js +50 -0
  227. package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
  228. package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
  229. package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
  230. package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
  231. package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
  232. package/dist/views/TranslationHub/ActiveJobs.js +320 -0
  233. package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
  234. package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
  235. package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
  236. package/dist/views/TranslationHub/AlertBanner.js +568 -0
  237. package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
  238. package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
  239. package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
  240. package/dist/views/TranslationHub/AuditPanel.js +1367 -0
  241. package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
  242. package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
  243. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
  244. package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
  245. package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
  246. package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
  247. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
  248. package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
  249. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
  250. package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
  251. package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
  252. package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
  253. package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
  254. package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
  255. package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
  256. package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
  257. package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
  258. package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
  259. package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
  260. package/dist/views/TranslationHub/Hub.client.js +357 -0
  261. package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
  262. package/dist/views/TranslationHub/ModelCombobox.js +415 -0
  263. package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
  264. package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
  265. package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
  266. package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
  267. package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
  268. package/dist/views/TranslationHub/SettingsRail.js +382 -0
  269. package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
  270. package/dist/views/TranslationHub/StatusStrip.js +451 -0
  271. package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
  272. package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
  273. package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
  274. package/dist/views/TranslationHub/UsageTable.js +1240 -0
  275. package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
  276. package/dist/views/TranslationHub/alertGrouping.js +99 -0
  277. package/dist/views/TranslationHub/index.d.ts +20 -0
  278. package/dist/views/TranslationHub/index.js +109 -0
  279. package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
  280. package/dist/views/TranslationHub/tabNavigation.js +74 -0
  281. package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
  282. package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
  283. package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
  284. package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
  285. package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
  286. package/dist/views/TranslationHub/useFocusTrap.js +81 -0
  287. package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
  288. package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
  289. package/dist/views/shared/EditorError.d.ts +97 -0
  290. package/dist/views/shared/EditorError.js +205 -0
  291. package/dist/views/shared/ModelCell.d.ts +18 -0
  292. package/dist/views/shared/ModelCell.js +31 -0
  293. package/dist/views/shared/docHref.d.ts +16 -0
  294. package/dist/views/shared/docHref.js +26 -0
  295. package/dist/views/shared/fetch-error-body.d.ts +25 -0
  296. package/dist/views/shared/fetch-error-body.js +42 -0
  297. package/dist/views/shared/filterPillStyle.d.ts +35 -0
  298. package/dist/views/shared/filterPillStyle.js +40 -0
  299. package/dist/views/shared/format.d.ts +75 -0
  300. package/dist/views/shared/format.js +131 -0
  301. package/package.json +141 -0
package/dist/api.js ADDED
@@ -0,0 +1,918 @@
1
+ import { DEFAULT_CONCURRENCY, DEFAULT_RETRY, DEFAULT_TARGET_POLICY, REENTRY_FLAG } from './defaults.js';
2
+ import { collectBlocksConfig, extractTranslationUnits } from './lib/content-extractor.js';
3
+ import { checkPerDocLimit } from './lib/cost-guards.js';
4
+ import { getEffectiveExcludePatternsForSurface, getExcludedFieldPaths, isPathExcluded } from './lib/effective-locales.js';
5
+ import { emitAlert, emitEvent } from './lib/events.js';
6
+ import { diffFields } from './lib/field-diff.js';
7
+ import { isFieldEmpty } from './lib/field-empty.js';
8
+ import { resolveTranslatableFields } from './lib/field-resolver.js';
9
+ import { mergeTranslationsIntoTargetDoc } from './lib/locale-merge.js';
10
+ import { applyHashSkip, recordHashesAfterWrite } from './lib/manual-edit-guard.js';
11
+ import { findByIdNoFallback, findGlobalNoFallback } from './lib/payload-read.js';
12
+ import { bucketLocaleOutcomes, persistTranslationUsage } from './lib/persist-usage.js';
13
+ import { completeJobNow, createJob, updateJob } from './lib/progress-store.js';
14
+ import { truncateSourceValue } from './lib/truncate-source-value.js';
15
+ import { DEFAULT_MANUAL_EDIT_COLLECTION_SLUG } from './manual-edit-collection.js';
16
+ import { DEFAULT_SETTINGS_GLOBAL_SLUG } from './settings-global.js';
17
+ import { batchUnits, translateBatch, translateForLocale } from './translate.js';
18
+ /**
19
+ * Pick the provider this translation run should use.
20
+ *
21
+ * Resolution order:
22
+ * 1. If `config.providers` is configured AND the `translation-settings`
23
+ * global has `activeProvider` set to one of those keys → that provider.
24
+ * 2. Otherwise → `config.provider` (the static fallback).
25
+ *
26
+ * The settings read is wrapped in try/catch because the global may not
27
+ * exist yet (first request after install before migrations land) or the
28
+ * read may fail for unrelated reasons. In those cases we fall back to
29
+ * the static provider rather than failing the translation.
30
+ */ async function resolveProvider(payload, config) {
31
+ if (!config.providers || Object.keys(config.providers).length === 0) {
32
+ return config.provider;
33
+ }
34
+ const slug = config.settings?.globalSlug ?? DEFAULT_SETTINGS_GLOBAL_SLUG;
35
+ try {
36
+ const settings = await payload.findGlobal({
37
+ slug: slug,
38
+ depth: 0,
39
+ overrideAccess: true
40
+ });
41
+ const activeName = settings?.activeProvider;
42
+ if (activeName && config.providers[activeName]) {
43
+ return config.providers[activeName];
44
+ }
45
+ } catch {
46
+ // Global not yet created or read failed — fall back to default.
47
+ }
48
+ return config.provider;
49
+ }
50
+ // ---------------------------------------------------------------------------
51
+ // Public API
52
+ // ---------------------------------------------------------------------------
53
+ export async function translateDocument(payload, options) {
54
+ const baseConfig = payload.config?.custom?.aiTranslate;
55
+ if (!baseConfig) {
56
+ throw new Error('[ai-translate] Plugin not configured. Add aiTranslatePlugin() to your Payload config.');
57
+ }
58
+ // Resolve the runtime provider ONCE per document. All locales of this
59
+ // run use the same provider — a switch shouldn't happen mid-job. The
60
+ // resolved provider is spliced into a shallow config copy that flows
61
+ // through the rest of the pipeline; no other call site needs to
62
+ // change.
63
+ const runtimeProvider = await resolveProvider(payload, baseConfig);
64
+ const config = runtimeProvider === baseConfig.provider ? baseConfig : {
65
+ ...baseConfig,
66
+ provider: runtimeProvider
67
+ };
68
+ const { collection, id, signal } = options;
69
+ const targetLocales = options.targetLocales ?? config.targetLocales;
70
+ const targetPolicy = options.targetPolicy ?? config.automation?.targetPolicy ?? DEFAULT_TARGET_POLICY;
71
+ const startedAt = Date.now();
72
+ // Read source document. Default to reading the draft so admin-UI flows
73
+ // ("draft → translate → review → publish") work on versioned collections.
74
+ // Hooks that fire on the published transition can pass `draft: false`
75
+ // explicitly if they need the published shape.
76
+ const sourceDoc = await findByIdNoFallback(payload, collection, id, config.sourceLocale, {
77
+ draft: options.draft ?? true
78
+ });
79
+ if (!sourceDoc) {
80
+ throw new Error(`[ai-translate] Document not found: ${collection}/${String(id)}`);
81
+ }
82
+ // Resolve collection field config
83
+ const collectionFields = getCollectionFields(payload, collection);
84
+ if (!collectionFields) {
85
+ throw new Error(`[ai-translate] Collection "${collection}" not found in Payload config.`);
86
+ }
87
+ // Find translatable fields
88
+ let translatableFields = resolveTranslatableFields(collectionFields);
89
+ // Surface-aware patterns: respects D7 `translateSlug` opt-in on the
90
+ // matching `perCollection` row, so slugs only flow through when
91
+ // explicitly enabled per surface.
92
+ const excludePatterns = await getEffectiveExcludePatternsForSurface(payload, config, collection);
93
+ // Block schemas for the schema-aware extractor path. Walks every
94
+ // `type: 'blocks'` field in the collection config and gathers its
95
+ // registered `Block` configs so the extractor can gate which keys
96
+ // inside a block are translatable based on the schema (fixes BUG-04
97
+ // family — non-text strings being treated as translatable). When the
98
+ // map has no entry for a given blockType, the extractor falls back to
99
+ // the previous heuristic walk.
100
+ const blocksConfig = collectBlocksConfig(collectionFields);
101
+ // Capture the full set of localized top-level fields BEFORE narrowing to
102
+ // `options.fields`. The locale-write merge needs this even when a per-
103
+ // field translate restricts work to a single field — Payload validates
104
+ // required-localized siblings on partial writes, so we must include them
105
+ // in writeData with their existing-target or source values.
106
+ const allLocalizedTopFields = new Set(translatableFields.filter((f)=>f.localized).map((f)=>f.path.split('.')[0]));
107
+ // Admin-configured per-surface exclusions (D6 feature). Applies to
108
+ // BOTH manual and automation paths — distinct from `excludePatterns`
109
+ // (static plugin config) and from the locale gates in
110
+ // `effective-locales` (automation-only). Applied BEFORE the
111
+ // `options.fields` narrowing below so that a manual per-field
112
+ // request for an excluded path returns an empty job rather than
113
+ // silently translating a field the admin opted out.
114
+ const adminExcludedPaths = await getExcludedFieldPaths(payload, config, collection);
115
+ if (adminExcludedPaths.size > 0) {
116
+ translatableFields = translatableFields.filter((f)=>!isPathExcluded(f.path, adminExcludedPaths));
117
+ }
118
+ // If caller specified specific fields, filter down
119
+ if (options.fields?.length) {
120
+ const requested = new Set(options.fields);
121
+ translatableFields = translatableFields.filter((f)=>requested.has(f.path));
122
+ }
123
+ if (translatableFields.length === 0) {
124
+ // Caller may have seeded a progress-store job (e.g. on-change hook) and
125
+ // threaded its id through `options.jobId`. Without this nudge the job
126
+ // stays `running` until the 2-minute TTL — the SSE stream keeps emitting
127
+ // it and the editor sees a phantom "Translating: 0/N" widget.
128
+ if (options.jobId) completeJobNow(options.jobId);
129
+ return {
130
+ documentId: id,
131
+ collection,
132
+ sourceLocale: config.sourceLocale,
133
+ succeeded: [],
134
+ preserved: [],
135
+ failed: [],
136
+ usage: {
137
+ inputTokens: 0,
138
+ outputTokens: 0,
139
+ estimatedCostUsd: 0
140
+ }
141
+ };
142
+ }
143
+ // Extract translation units
144
+ let units = extractTranslationUnits(sourceDoc, translatableFields, excludePatterns, blocksConfig);
145
+ // If previousDoc is available, filter to changed fields only.
146
+ // A unit whose fieldPath either (a) equals a changed field path or (b) is
147
+ // nested under one (e.g. unit "general.hello" under changed field "general")
148
+ // is kept. Plain string equality breaks for JSON / nested group units.
149
+ if (options.previousDoc) {
150
+ const fieldPaths = translatableFields.map((f)=>f.path);
151
+ const changedPaths = diffFields(options.previousDoc, sourceDoc, fieldPaths);
152
+ units = units.filter((u)=>changedPaths.some((cp)=>u.fieldPath === cp || u.fieldPath.startsWith(`${cp}.`)));
153
+ }
154
+ if (units.length === 0) {
155
+ if (options.jobId) completeJobNow(options.jobId);
156
+ return {
157
+ documentId: id,
158
+ collection,
159
+ sourceLocale: config.sourceLocale,
160
+ succeeded: [],
161
+ preserved: [],
162
+ failed: [],
163
+ usage: {
164
+ inputTokens: 0,
165
+ outputTokens: 0,
166
+ estimatedCostUsd: 0
167
+ }
168
+ };
169
+ }
170
+ // Check per-document character ceiling
171
+ const totalChars = units.reduce((sum, u)=>sum + u.text.length, 0);
172
+ try {
173
+ checkPerDocLimit(totalChars, config.costLimits.perDocCharCeiling);
174
+ } catch (error) {
175
+ const costError = error;
176
+ emitAlert(config, {
177
+ type: 'translation.cost-guard-abort',
178
+ code: costError.code === 'PER_CALL_LIMIT' ? 'cost-guard.per-call' : 'cost-guard.per-doc',
179
+ context: {
180
+ chars: costError.characterCount,
181
+ limit: costError.limit,
182
+ collection,
183
+ documentId: id
184
+ },
185
+ message: costError.message,
186
+ documentId: id,
187
+ collection,
188
+ timestamp: new Date(),
189
+ metadata: {
190
+ characterCount: costError.characterCount,
191
+ limit: costError.limit
192
+ }
193
+ });
194
+ throw error;
195
+ }
196
+ // Create progress tracking job — or reuse one passed in by the caller
197
+ // (the after-change hook seeds a job for instant UI feedback and threads
198
+ // its id through here so we don't end up with a phantom + real job pair)
199
+ const jobId = options.jobId ?? createJob(collection, id, targetLocales);
200
+ // Emit started event
201
+ emitEvent(config, {
202
+ type: 'translation.started',
203
+ documentId: id,
204
+ collection,
205
+ sourceLocale: config.sourceLocale,
206
+ targetLocales,
207
+ timestamp: new Date()
208
+ });
209
+ // Translate for each target locale with concurrency control
210
+ const concurrency = config.concurrency?.perDocument ?? DEFAULT_CONCURRENCY.perDocument;
211
+ const allSucceeded = [];
212
+ const allPreserved = [];
213
+ const allFailed = [];
214
+ const totalUsage = {
215
+ inputTokens: 0,
216
+ outputTokens: 0,
217
+ estimatedCostUsd: 0
218
+ };
219
+ let providerLatencyMs = 0;
220
+ const localeQueue = [
221
+ ...targetLocales
222
+ ];
223
+ const running = new Set();
224
+ while(localeQueue.length > 0 || running.size > 0){
225
+ // Fill up to concurrency limit
226
+ while(localeQueue.length > 0 && running.size < concurrency){
227
+ if (signal?.aborted) break;
228
+ const locale = localeQueue.shift();
229
+ const promise = (async ()=>{
230
+ try {
231
+ const result = await translateForLocale({
232
+ payload,
233
+ config,
234
+ sourceDoc,
235
+ documentId: id,
236
+ collection,
237
+ targetLocale: locale,
238
+ units,
239
+ fields: translatableFields,
240
+ allLocalizedTopFields,
241
+ writeMode: options.writeMode,
242
+ targetPolicy,
243
+ signal,
244
+ req: options.req,
245
+ force: options.force
246
+ });
247
+ allSucceeded.push(...result.succeeded);
248
+ allPreserved.push(...result.preserved ?? []);
249
+ allFailed.push(...result.failed);
250
+ totalUsage.inputTokens += result.usage.inputTokens;
251
+ totalUsage.outputTokens += result.usage.outputTokens;
252
+ totalUsage.estimatedCostUsd = (totalUsage.estimatedCostUsd ?? 0) + (result.usage.estimatedCostUsd ?? 0);
253
+ totalUsage.model = totalUsage.model ?? result.usage.model;
254
+ providerLatencyMs += result.providerLatencyMs;
255
+ // Per-locale tick: only real failures gate the locale's bar from
256
+ // green. Skipped fields (verbatim echoes, etc.) are info-level
257
+ // and shouldn't false-alarm the editor.
258
+ const realFailedLocale = result.failed.filter((f)=>f.status === 'failed');
259
+ updateJob(jobId, locale, realFailedLocale.length === 0, realFailedLocale.length > 0 ? `${realFailedLocale.length} field(s) failed` : undefined);
260
+ } catch (error) {
261
+ const errMsg = error instanceof Error ? error.message : 'Unknown locale error';
262
+ allFailed.push({
263
+ fieldPath: '*',
264
+ locale,
265
+ status: 'failed',
266
+ error: errMsg
267
+ });
268
+ updateJob(jobId, locale, false, errMsg);
269
+ payload.logger.error(`[ai-translate] Locale "${locale}" failed for ${collection}/${String(id)}: ${errMsg}`);
270
+ }
271
+ })();
272
+ const tracked = promise.then(()=>{
273
+ running.delete(tracked);
274
+ }, ()=>{
275
+ running.delete(tracked);
276
+ });
277
+ running.add(tracked);
278
+ }
279
+ if (running.size > 0) {
280
+ await Promise.race(running);
281
+ }
282
+ }
283
+ // Fallback: when every locale fails before its provider call returns
284
+ // (e.g. 429 quota, network error), `totalUsage.model` is never stamped
285
+ // from a response. Surface the configured model so usage rows show what
286
+ // *would have* run instead of `<No Model>`.
287
+ if (!totalUsage.model && config.provider.model) {
288
+ totalUsage.model = config.provider.model;
289
+ }
290
+ // Real failures only — `skipped` results (verbatim echo, etc.) carry
291
+ // `status: 'skipped'` and shouldn't drive doc-level status to "failed".
292
+ const realFailed = allFailed.filter((f)=>f.status === 'failed');
293
+ // Soft skips (verbatim echo, etc.) — surface separately so the event
294
+ // consumer can flag "doc succeeded but N fields stayed in source".
295
+ const skippedReports = allFailed.filter((f)=>f.status === 'skipped').map((f)=>({
296
+ fieldPath: f.fieldPath,
297
+ locale: f.locale,
298
+ reason: f.error,
299
+ reasonCode: f.errorCode,
300
+ sourceValue: f.sourceValue
301
+ }));
302
+ // Emit final event
303
+ const eventType = realFailed.length === 0 ? 'translation.succeeded' : 'translation.failed';
304
+ emitEvent(config, {
305
+ type: eventType,
306
+ documentId: id,
307
+ collection,
308
+ sourceLocale: config.sourceLocale,
309
+ targetLocales,
310
+ timestamp: new Date(),
311
+ fields: [
312
+ ...allSucceeded,
313
+ ...allFailed
314
+ ],
315
+ usage: totalUsage,
316
+ error: realFailed.length > 0 ? `${realFailed.length} field(s) failed` : undefined,
317
+ ...skippedReports.length > 0 ? {
318
+ skippedFields: skippedReports
319
+ } : {}
320
+ });
321
+ // Persist a usage row when the consumer opted in. The helper short-circuits
322
+ // when usageTracking is disabled and swallows its own errors — translation
323
+ // results are returned to the caller regardless of whether the row landed.
324
+ await persistTranslationUsage({
325
+ payload,
326
+ config,
327
+ kind: 'collection',
328
+ slug: collection,
329
+ documentId: id,
330
+ jobId,
331
+ status: realFailed.length === 0 ? 'succeeded' : 'failed',
332
+ sourceLocale: config.sourceLocale,
333
+ localeOutcomes: bucketLocaleOutcomes(targetLocales, allSucceeded, realFailed),
334
+ succeededCount: allSucceeded.length,
335
+ failedCount: realFailed.length,
336
+ succeeded: allSucceeded,
337
+ preserved: allPreserved,
338
+ softSkippedFields: skippedReports,
339
+ usage: totalUsage,
340
+ durationMs: Date.now() - startedAt,
341
+ error: realFailed.length > 0 ? `${realFailed.length} field(s) failed` : undefined
342
+ });
343
+ return {
344
+ jobId,
345
+ documentId: id,
346
+ collection,
347
+ sourceLocale: config.sourceLocale,
348
+ succeeded: allSucceeded,
349
+ preserved: allPreserved,
350
+ failed: allFailed,
351
+ usage: totalUsage,
352
+ providerLatencyMs
353
+ };
354
+ }
355
+ // ---------------------------------------------------------------------------
356
+ // Globals API
357
+ // ---------------------------------------------------------------------------
358
+ const NO_FALLBACK = null;
359
+ export async function translateGlobal(payload, options) {
360
+ const baseConfig = payload.config?.custom?.aiTranslate;
361
+ if (!baseConfig) {
362
+ throw new Error('[ai-translate] Plugin not configured. Add aiTranslatePlugin() to your Payload config.');
363
+ }
364
+ // Same runtime-provider resolution as translateDocument — see comment there.
365
+ const runtimeProvider = await resolveProvider(payload, baseConfig);
366
+ const config = runtimeProvider === baseConfig.provider ? baseConfig : {
367
+ ...baseConfig,
368
+ provider: runtimeProvider
369
+ };
370
+ const { signal } = options;
371
+ const targetLocales = options.targetLocales ?? config.targetLocales;
372
+ const startedAt = Date.now();
373
+ const targetPolicy = options.targetPolicy ?? config.automation?.targetPolicy ?? DEFAULT_TARGET_POLICY;
374
+ // Read source global
375
+ const sourceDoc = await payload.findGlobal({
376
+ slug: options.global,
377
+ locale: config.sourceLocale,
378
+ fallbackLocale: NO_FALLBACK
379
+ });
380
+ if (!sourceDoc) {
381
+ throw new Error(`[ai-translate] Global not found: ${options.global}`);
382
+ }
383
+ // Resolve global field config
384
+ const globalFields = getGlobalFields(payload, options.global);
385
+ if (!globalFields) {
386
+ throw new Error(`[ai-translate] Global "${options.global}" not found in Payload config.`);
387
+ }
388
+ // Find translatable fields
389
+ let translatableFields = resolveTranslatableFields(globalFields);
390
+ const excludePatterns = await getEffectiveExcludePatternsForSurface(payload, config, options.global);
391
+ // See comment in `translateDocument` — globals can also embed
392
+ // `type: 'blocks'` fields (header zones, layout blocks).
393
+ const blocksConfig = collectBlocksConfig(globalFields);
394
+ // Capture the full localized-top-field set BEFORE narrowing — see comment
395
+ // on the matching block in `translateDocument`.
396
+ const allLocalizedTopFields = new Set(translatableFields.filter((f)=>f.localized).map((f)=>f.path.split('.')[0]));
397
+ // Admin-configured exclusions — see comment in `translateDocument`.
398
+ // Applies to manual + automation. For globals, `surfaceSlug` is the
399
+ // global slug itself (globals only have one row in `perCollection`).
400
+ const adminExcludedPaths = await getExcludedFieldPaths(payload, config, options.global);
401
+ if (adminExcludedPaths.size > 0) {
402
+ translatableFields = translatableFields.filter((f)=>!isPathExcluded(f.path, adminExcludedPaths));
403
+ }
404
+ // If caller specified specific fields, filter down
405
+ if (options.fields?.length) {
406
+ const requested = new Set(options.fields);
407
+ translatableFields = translatableFields.filter((f)=>requested.has(f.path));
408
+ }
409
+ if (translatableFields.length === 0) {
410
+ // See comment in `translateDocument` for the same early-return —
411
+ // without this nudge the seeded job stays `running` until TTL.
412
+ if (options.jobId) completeJobNow(options.jobId);
413
+ return {
414
+ documentId: options.global,
415
+ collection: options.global,
416
+ sourceLocale: config.sourceLocale,
417
+ succeeded: [],
418
+ preserved: [],
419
+ failed: [],
420
+ usage: {
421
+ inputTokens: 0,
422
+ outputTokens: 0,
423
+ estimatedCostUsd: 0
424
+ }
425
+ };
426
+ }
427
+ // Extract translation units
428
+ let units = extractTranslationUnits(sourceDoc, translatableFields, excludePatterns, blocksConfig);
429
+ // diffOnly: if previousDoc is provided, narrow units to only changed fields.
430
+ // Match by exact path or nested-under prefix (e.g. unit "general.title"
431
+ // under changed field "general") so JSON / nested group units are kept.
432
+ if (options.previousDoc) {
433
+ const fieldPaths = translatableFields.map((f)=>f.path);
434
+ const changedPaths = diffFields(options.previousDoc, sourceDoc, fieldPaths);
435
+ units = units.filter((u)=>changedPaths.some((cp)=>u.fieldPath === cp || u.fieldPath.startsWith(`${cp}.`)));
436
+ }
437
+ if (units.length === 0) {
438
+ if (options.jobId) completeJobNow(options.jobId);
439
+ return {
440
+ documentId: options.global,
441
+ collection: options.global,
442
+ sourceLocale: config.sourceLocale,
443
+ succeeded: [],
444
+ preserved: [],
445
+ failed: [],
446
+ usage: {
447
+ inputTokens: 0,
448
+ outputTokens: 0,
449
+ estimatedCostUsd: 0
450
+ }
451
+ };
452
+ }
453
+ // Check per-document character ceiling
454
+ const totalChars = units.reduce((sum, u)=>sum + u.text.length, 0);
455
+ try {
456
+ checkPerDocLimit(totalChars, config.costLimits.perDocCharCeiling);
457
+ } catch (error) {
458
+ const costError = error;
459
+ emitAlert(config, {
460
+ type: 'translation.cost-guard-abort',
461
+ code: costError.code === 'PER_CALL_LIMIT' ? 'cost-guard.per-call' : 'cost-guard.per-doc',
462
+ context: {
463
+ chars: costError.characterCount,
464
+ limit: costError.limit,
465
+ globalSlug: options.global
466
+ },
467
+ message: costError.message,
468
+ documentId: options.global,
469
+ collection: options.global,
470
+ timestamp: new Date(),
471
+ metadata: {
472
+ characterCount: costError.characterCount,
473
+ limit: costError.limit
474
+ }
475
+ });
476
+ throw error;
477
+ }
478
+ // Progress tracking job — reuse caller's jobId when provided (e.g. the
479
+ // on-change hook seeds one for instant UI feedback).
480
+ const jobId = options.jobId ?? createJob(options.global, options.global, targetLocales);
481
+ // Emit started event
482
+ emitEvent(config, {
483
+ type: 'translation.started',
484
+ documentId: options.global,
485
+ collection: options.global,
486
+ sourceLocale: config.sourceLocale,
487
+ targetLocales,
488
+ timestamp: new Date()
489
+ });
490
+ // Translate for each target locale with concurrency control
491
+ const concurrency = config.concurrency?.perDocument ?? DEFAULT_CONCURRENCY.perDocument;
492
+ const allSucceeded = [];
493
+ const allPreserved = [];
494
+ const allFailed = [];
495
+ const totalUsage = {
496
+ inputTokens: 0,
497
+ outputTokens: 0,
498
+ estimatedCostUsd: 0
499
+ };
500
+ let providerLatencyMs = 0;
501
+ const localeQueue = [
502
+ ...targetLocales
503
+ ];
504
+ const running = new Set();
505
+ while(localeQueue.length > 0 || running.size > 0){
506
+ while(localeQueue.length > 0 && running.size < concurrency){
507
+ if (signal?.aborted) break;
508
+ const locale = localeQueue.shift();
509
+ const promise = (async ()=>{
510
+ try {
511
+ const result = await translateGlobalForLocale({
512
+ payload,
513
+ config,
514
+ sourceDoc,
515
+ globalSlug: options.global,
516
+ targetLocale: locale,
517
+ units,
518
+ fields: translatableFields,
519
+ allLocalizedTopFields,
520
+ writeMode: options.writeMode,
521
+ targetPolicy,
522
+ signal,
523
+ req: options.req,
524
+ force: options.force
525
+ });
526
+ allSucceeded.push(...result.succeeded);
527
+ allPreserved.push(...result.preserved ?? []);
528
+ allFailed.push(...result.failed);
529
+ totalUsage.inputTokens += result.usage.inputTokens;
530
+ totalUsage.outputTokens += result.usage.outputTokens;
531
+ totalUsage.estimatedCostUsd = (totalUsage.estimatedCostUsd ?? 0) + (result.usage.estimatedCostUsd ?? 0);
532
+ totalUsage.model = totalUsage.model ?? result.usage.model;
533
+ providerLatencyMs += result.providerLatencyMs;
534
+ // See collection branch above — skipped fields are info-level,
535
+ // not real failures, and must not flip the locale's bar to red.
536
+ const realFailedLocale = result.failed.filter((f)=>f.status === 'failed');
537
+ updateJob(jobId, locale, realFailedLocale.length === 0, realFailedLocale.length > 0 ? `${realFailedLocale.length} field(s) failed` : undefined);
538
+ } catch (error) {
539
+ const errMsg = error instanceof Error ? error.message : 'Unknown locale error';
540
+ allFailed.push({
541
+ fieldPath: '*',
542
+ locale,
543
+ status: 'failed',
544
+ error: errMsg
545
+ });
546
+ updateJob(jobId, locale, false, errMsg);
547
+ payload.logger.error(`[ai-translate] Locale "${locale}" failed for global "${options.global}": ${errMsg}`);
548
+ }
549
+ })();
550
+ const tracked = promise.then(()=>{
551
+ running.delete(tracked);
552
+ }, ()=>{
553
+ running.delete(tracked);
554
+ });
555
+ running.add(tracked);
556
+ }
557
+ if (running.size > 0) {
558
+ await Promise.race(running);
559
+ }
560
+ }
561
+ // Same model fallback as the collection path — see comment above.
562
+ if (!totalUsage.model && config.provider.model) {
563
+ totalUsage.model = config.provider.model;
564
+ }
565
+ // Real failures only — see comment in collection branch above.
566
+ const realFailed = allFailed.filter((f)=>f.status === 'failed');
567
+ const skippedReports = allFailed.filter((f)=>f.status === 'skipped').map((f)=>({
568
+ fieldPath: f.fieldPath,
569
+ locale: f.locale,
570
+ reason: f.error,
571
+ reasonCode: f.errorCode,
572
+ sourceValue: f.sourceValue
573
+ }));
574
+ // Emit final event
575
+ const eventType = realFailed.length === 0 ? 'translation.succeeded' : 'translation.failed';
576
+ emitEvent(config, {
577
+ type: eventType,
578
+ documentId: options.global,
579
+ collection: options.global,
580
+ sourceLocale: config.sourceLocale,
581
+ targetLocales,
582
+ timestamp: new Date(),
583
+ fields: [
584
+ ...allSucceeded,
585
+ ...allFailed
586
+ ],
587
+ usage: totalUsage,
588
+ error: realFailed.length > 0 ? `${realFailed.length} field(s) failed` : undefined,
589
+ ...skippedReports.length > 0 ? {
590
+ skippedFields: skippedReports
591
+ } : {}
592
+ });
593
+ // Persist usage row when feature is enabled. Same contract as the
594
+ // collection branch — helper short-circuits + swallows errors.
595
+ await persistTranslationUsage({
596
+ payload,
597
+ config,
598
+ kind: 'global',
599
+ slug: options.global,
600
+ documentId: options.global,
601
+ jobId,
602
+ status: realFailed.length === 0 ? 'succeeded' : 'failed',
603
+ sourceLocale: config.sourceLocale,
604
+ localeOutcomes: bucketLocaleOutcomes(targetLocales, allSucceeded, realFailed),
605
+ succeededCount: allSucceeded.length,
606
+ failedCount: realFailed.length,
607
+ succeeded: allSucceeded,
608
+ preserved: allPreserved,
609
+ softSkippedFields: skippedReports,
610
+ usage: totalUsage,
611
+ durationMs: Date.now() - startedAt,
612
+ error: realFailed.length > 0 ? `${realFailed.length} field(s) failed` : undefined
613
+ });
614
+ return {
615
+ jobId,
616
+ documentId: options.global,
617
+ collection: options.global,
618
+ sourceLocale: config.sourceLocale,
619
+ succeeded: allSucceeded,
620
+ preserved: allPreserved,
621
+ failed: allFailed,
622
+ usage: totalUsage,
623
+ providerLatencyMs
624
+ };
625
+ }
626
+ async function translateGlobalForLocale(params) {
627
+ const { payload, config, sourceDoc, globalSlug, targetLocale, fields, allLocalizedTopFields, writeMode, targetPolicy, signal, req, force } = params;
628
+ let { units } = params;
629
+ const succeeded = [];
630
+ const preserved = [];
631
+ const failed = [];
632
+ const totalUsage = {
633
+ inputTokens: 0,
634
+ outputTokens: 0,
635
+ estimatedCostUsd: 0
636
+ };
637
+ let providerLatencyMs = 0;
638
+ if (targetLocale === config.sourceLocale) {
639
+ return {
640
+ succeeded,
641
+ preserved,
642
+ failed,
643
+ usage: totalUsage,
644
+ providerLatencyMs
645
+ };
646
+ }
647
+ // Preserve policy: skip fields that already have content in the target locale
648
+ if (targetPolicy === 'preserve') {
649
+ units = await filterPreservedGlobalFields(payload, globalSlug, targetLocale, units, fields);
650
+ }
651
+ if (units.length === 0) {
652
+ return {
653
+ succeeded,
654
+ preserved,
655
+ failed,
656
+ usage: totalUsage,
657
+ providerLatencyMs
658
+ };
659
+ }
660
+ // BUG-21 hash-skip preflight for globals (1.2.8). Previously globals
661
+ // bypassed this path entirely — every re-translate re-hit the LLM for
662
+ // every field even when the source content was unchanged. Now mirrors
663
+ // the collection path in `translate.ts`: load stored source+target
664
+ // hashes, drop units whose top-level field hashes match on both sides,
665
+ // surface those as hash-skipped successes (characterCount = 0,
666
+ // durationMs = 0). `force: true` callers bypass — see
667
+ // `TranslateGlobalOptions.force`.
668
+ if (config.preserveManualEdits && !force) {
669
+ const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
670
+ const skip = await applyHashSkip({
671
+ payload,
672
+ metaCollection: metaSlug,
673
+ surfaceSlug: globalSlug,
674
+ documentId: globalSlug,
675
+ targetLocale,
676
+ units,
677
+ sourceDoc,
678
+ loadTargetDoc: ()=>findGlobalNoFallback(payload, globalSlug, targetLocale)
679
+ });
680
+ units = skip.remainingUnits;
681
+ succeeded.push(...skip.hashSkipped);
682
+ if (units.length === 0) {
683
+ return {
684
+ succeeded,
685
+ preserved,
686
+ failed,
687
+ usage: totalUsage,
688
+ providerLatencyMs
689
+ };
690
+ }
691
+ }
692
+ const batches = batchUnits(units, config.costLimits.perCallCharLimit, config.costLimits.perCallItemLimit);
693
+ const retryConfig = {
694
+ ...DEFAULT_RETRY,
695
+ ...config.retry ?? {}
696
+ };
697
+ const translatedMap = new Map();
698
+ for (const batch of batches){
699
+ if (signal?.aborted) {
700
+ for (const unit of batch){
701
+ failed.push({
702
+ fieldPath: unit.fieldPath,
703
+ locale: targetLocale,
704
+ status: 'failed',
705
+ error: 'Aborted'
706
+ });
707
+ }
708
+ continue;
709
+ }
710
+ const batchResult = await translateBatch({
711
+ batch,
712
+ config,
713
+ collection: globalSlug,
714
+ targetLocale,
715
+ signal,
716
+ retryConfig
717
+ });
718
+ totalUsage.inputTokens += batchResult.usage.inputTokens;
719
+ totalUsage.outputTokens += batchResult.usage.outputTokens;
720
+ totalUsage.estimatedCostUsd = (totalUsage.estimatedCostUsd ?? 0) + (batchResult.usage.estimatedCostUsd ?? 0);
721
+ totalUsage.model = totalUsage.model ?? batchResult.usage.model;
722
+ providerLatencyMs += batchResult.latencyMs;
723
+ for (const unit of batch){
724
+ const translated = batchResult.translations.get(unit.id);
725
+ if (translated !== undefined) {
726
+ translatedMap.set(unit.id, translated);
727
+ succeeded.push({
728
+ fieldPath: unit.fieldPath,
729
+ locale: targetLocale,
730
+ status: 'success',
731
+ characterCount: translated.length,
732
+ durationMs: batchResult.latencyMs,
733
+ ...unit.kind === 'plain' ? {
734
+ translatedText: translated
735
+ } : {}
736
+ });
737
+ } else if (batchResult.skipped.has(unit.id)) {
738
+ failed.push({
739
+ fieldPath: unit.fieldPath,
740
+ locale: targetLocale,
741
+ status: 'skipped',
742
+ error: batchResult.skipped.get(unit.id),
743
+ sourceValue: truncateSourceValue(unit.text)
744
+ });
745
+ } else {
746
+ const err = batchResult.errors.get(unit.id);
747
+ failed.push({
748
+ fieldPath: unit.fieldPath,
749
+ locale: targetLocale,
750
+ status: 'failed',
751
+ error: err ?? 'No translation returned'
752
+ });
753
+ }
754
+ }
755
+ }
756
+ // Read the existing target-locale doc and merge translations onto it,
757
+ // then write only the translated top-level fields. Reading from the
758
+ // target locale (rather than cloning the source) is what keeps Payload
759
+ // internal columns from leaking into the update body and lets
760
+ // required-localized siblings stay populated when we only change a
761
+ // subset of fields.
762
+ if (translatedMap.size > 0) {
763
+ const translatedTopFields = new Set();
764
+ for (const unit of units){
765
+ if (translatedMap.has(unit.id)) {
766
+ translatedTopFields.add(unit.fieldPath.split('.')[0]);
767
+ }
768
+ }
769
+ let targetDoc = null;
770
+ try {
771
+ targetDoc = await findGlobalNoFallback(payload, globalSlug, targetLocale);
772
+ } catch {
773
+ targetDoc = null;
774
+ }
775
+ const writeData = mergeTranslationsIntoTargetDoc({
776
+ sourceDoc,
777
+ targetDoc,
778
+ translatedMap,
779
+ fields,
780
+ translatedTopFields,
781
+ allLocalizedTopFields,
782
+ writeMode
783
+ });
784
+ // BUG-25 fix: skip the DB write when writeData is empty (everything
785
+ // got preserved by manual-edit guard or merge produced no diff).
786
+ // Mirrors the collection-level fix in translate.ts.
787
+ if (Object.keys(writeData).length === 0) {
788
+ payload.logger?.info?.(`[ai-translate] No fields to write for global "${globalSlug}" locale=${targetLocale} — skipping payload.updateGlobal.`);
789
+ } else {
790
+ try {
791
+ // See comment in translate.ts — fresh transactionID per locale
792
+ // write so concurrent updates don't collide on shared junction
793
+ // tables. `req.user` and friends are preserved for audit-log.
794
+ const writeReq = req ? {
795
+ ...req,
796
+ transactionID: undefined
797
+ } : undefined;
798
+ // Same allowlist pattern as translateForLocale (translate.ts:478).
799
+ // Only `disableRevalidate` flows through — naive `...writeReq.context`
800
+ // would propagate audit-log's AUDIT_CONTEXT_KEY into the global
801
+ // write's afterChange and mis-attribute the audit row.
802
+ const forwardedContext = {};
803
+ if (writeReq?.context && 'disableRevalidate' in writeReq.context) {
804
+ forwardedContext.disableRevalidate = writeReq.context.disableRevalidate;
805
+ }
806
+ await payload.updateGlobal({
807
+ slug: globalSlug,
808
+ locale: targetLocale,
809
+ data: writeData,
810
+ context: {
811
+ ...forwardedContext,
812
+ [REENTRY_FLAG]: true
813
+ },
814
+ ...writeReq ? {
815
+ req: writeReq
816
+ } : {}
817
+ });
818
+ // Persist source+target hashes for next-translation hash-skip
819
+ // (1.2.8). Mirrors `translate.ts` for collections. Best-effort —
820
+ // failure here doesn't roll back the successful translation write
821
+ // above.
822
+ if (config.preserveManualEdits) {
823
+ const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
824
+ await recordHashesAfterWrite({
825
+ payload,
826
+ metaCollection: metaSlug,
827
+ surfaceSlug: globalSlug,
828
+ documentId: globalSlug,
829
+ targetLocale,
830
+ writeData,
831
+ sourceDoc
832
+ });
833
+ }
834
+ } catch (error) {
835
+ const errMsg = error instanceof Error ? error.message : 'Unknown write error';
836
+ payload.logger.error(`[ai-translate] Failed to write translations for global "${globalSlug}" locale=${targetLocale}: ${errMsg}`);
837
+ for (const result of succeeded){
838
+ failed.push({
839
+ ...result,
840
+ status: 'failed',
841
+ error: `Write failed: ${errMsg}`
842
+ });
843
+ }
844
+ succeeded.length = 0;
845
+ }
846
+ } // end else (non-empty)
847
+ }
848
+ // Emit per-field events
849
+ const isCanary = targetLocale === config.quality?.canaryLocale;
850
+ for (const result of [
851
+ ...succeeded,
852
+ ...failed
853
+ ]){
854
+ emitEvent(config, {
855
+ type: 'translation.field',
856
+ documentId: globalSlug,
857
+ collection: globalSlug,
858
+ sourceLocale: config.sourceLocale,
859
+ targetLocales: [
860
+ targetLocale
861
+ ],
862
+ timestamp: new Date(),
863
+ fields: [
864
+ result
865
+ ],
866
+ canary: isCanary || undefined
867
+ });
868
+ }
869
+ return {
870
+ succeeded,
871
+ preserved,
872
+ failed,
873
+ usage: totalUsage,
874
+ providerLatencyMs
875
+ };
876
+ }
877
+ // ---------------------------------------------------------------------------
878
+ // Global preserve-policy filter
879
+ // ---------------------------------------------------------------------------
880
+ async function filterPreservedGlobalFields(payload, globalSlug, targetLocale, units, fields) {
881
+ try {
882
+ const targetDoc = await payload.findGlobal({
883
+ slug: globalSlug,
884
+ locale: targetLocale,
885
+ fallbackLocale: NO_FALLBACK
886
+ });
887
+ return units.filter((unit)=>{
888
+ const field = fields.find((f)=>unit.fieldPath === f.path || unit.fieldPath.startsWith(`${f.path}.`));
889
+ if (!field) return true;
890
+ const targetValue = resolvePathValue(targetDoc, field.path);
891
+ return isFieldEmpty(targetValue, field.type);
892
+ });
893
+ } catch {
894
+ return units;
895
+ }
896
+ }
897
+ function resolvePathValue(obj, path) {
898
+ const segments = path.split('.');
899
+ let current = obj;
900
+ for (const segment of segments){
901
+ if (current == null || typeof current !== 'object') return undefined;
902
+ current = current[segment];
903
+ }
904
+ return current;
905
+ }
906
+ // ---------------------------------------------------------------------------
907
+ // Helpers
908
+ // ---------------------------------------------------------------------------
909
+ function getCollectionFields(payload, collectionSlug) {
910
+ const collections = payload.config?.collections ?? [];
911
+ const collection = collections.find((c)=>c.slug === collectionSlug);
912
+ return collection?.fields;
913
+ }
914
+ function getGlobalFields(payload, globalSlug) {
915
+ const globals = payload.config?.globals ?? [];
916
+ const global = globals.find((g)=>g.slug === globalSlug);
917
+ return global?.fields;
918
+ }