@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/plugin.js ADDED
@@ -0,0 +1,934 @@
1
+ import { createAlertsCollection } from './alerts-collection.js';
2
+ import { translateDocument, translateGlobal } from './api.js';
3
+ // Bulk-translate engine (v1.2.0+). All gated on `options.bulk?.enabled`.
4
+ import { createBulkTranslateBatchesCollection, DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG } from './bulk-translate-batches-collection.js';
5
+ import { createBulkTranslateUnitsCollection, DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG } from './bulk-translate-units-collection.js';
6
+ import { DEFAULT_ALERTS_COLLECTION_SLUG, DEFAULT_COALESCING_WINDOW_MS, DEFAULT_CONCURRENCY, DEFAULT_USAGE_COLLECTION_SLUG } from './defaults.js';
7
+ import { getClientConfigHandler } from './endpoints/client-config.js';
8
+ import { getEstimateHandler } from './endpoints/estimate.js';
9
+ import { getProgressHandler } from './endpoints/progress.js';
10
+ import { getTranslateHandler } from './endpoints/translate.js';
11
+ import { getBulkTranslateActiveHandler } from './endpoints/translation-hub/active.js';
12
+ import { getBulkTranslateCancelHandler } from './endpoints/translation-hub/cancel.js';
13
+ import { getBulkTranslateEnqueueHandler } from './endpoints/translation-hub/enqueue.js';
14
+ import { getBulkTranslateFailuresHandler } from './endpoints/translation-hub/failures.js';
15
+ import { getBulkTranslateForceResetHandler } from './endpoints/translation-hub/force-reset.js';
16
+ import { getBulkTranslateListHandler } from './endpoints/translation-hub/list.js';
17
+ import { getBulkTranslatePreflightHandler } from './endpoints/translation-hub/preflight.js';
18
+ import { getBulkTranslateRetryFailedHandler } from './endpoints/translation-hub/retry-failed.js';
19
+ import { getBulkTranslateRevertHandler } from './endpoints/translation-hub/revert.js';
20
+ import { getBulkTranslateStatusHandler } from './endpoints/translation-hub/status.js';
21
+ import { getTranslationHubUsageSummaryHandler } from './endpoints/translation-hub/usage-summary.js';
22
+ import { createTranslateAfterChangeHook } from './hooks/after-change.js';
23
+ import { createTranslateGlobalAfterChangeHook } from './hooks/after-change-global.js';
24
+ import { createAiTranslateAfterDeleteHook } from './hooks/after-delete.js';
25
+ import { createJobsCollection, DEFAULT_JOBS_COLLECTION_SLUG } from './jobs-collection.js';
26
+ import { ensureBulkTranslateSchema } from './lib/bulk-translate-migrations.js';
27
+ import { createCoalescingQueue } from './lib/coalescing-queue.js';
28
+ import { setAlertsContext } from './lib/events.js';
29
+ import { getEffectiveExcludePatterns, isExcluded } from './lib/exclude-fields.js';
30
+ import { setPersistenceContext } from './lib/progress-store.js';
31
+ import { createRateLimiter } from './lib/rate-limiter.js';
32
+ import { createManualEditMetaCollection, DEFAULT_MANUAL_EDIT_COLLECTION_SLUG } from './manual-edit-collection.js';
33
+ import { createSettingsGlobal } from './settings-global.js';
34
+ import { BULK_TRANSLATE_COORDINATOR_SLUG, BULK_TRANSLATE_DOC_TASK_SLUG, buildBulkTranslateCoordinator } from './tasks/bulk-translate-coordinator.js';
35
+ import { buildBulkTranslateDocTask } from './tasks/bulk-translate-doc-task.js';
36
+ import { BULK_TRANSLATE_JANITOR_SLUG, buildBulkTranslateJanitor, runJanitorSweep } from './tasks/bulk-translate-janitor.js';
37
+ import { buildTranslateDocumentTask, buildTranslateGlobalTask, TRANSLATE_DOCUMENT_TASK_SLUG, TRANSLATE_GLOBAL_TASK_SLUG } from './tasks/translate-job-task.js';
38
+ import { createTranslationDailySpendCollection } from './translation-daily-spend-collection.js';
39
+ import { createTranslationRateLimitsCollection } from './translation-rate-limits-collection.js';
40
+ import { createUsageCollection } from './usage-collection.js';
41
+ export function aiTranslatePlugin(options) {
42
+ return (incomingConfig)=>{
43
+ const config = {
44
+ ...incomingConfig
45
+ };
46
+ config.custom = {
47
+ ...config.custom ?? {},
48
+ aiTranslate: options
49
+ };
50
+ // Expose a serializable subset of plugin config to the admin client
51
+ // bundle. The full `options` object contains provider function
52
+ // references (`translate`, `estimate`) that Payload's admin builder
53
+ // strips during client serialization — the client-side modal can
54
+ // only read JSON-safe fields. Writing this subset to a stable path
55
+ // (`config.custom.aiTranslateClient`) lets the Translate drawer
56
+ // restrict its locale picker to `targetLocales` without an extra
57
+ // round-trip.
58
+ config.custom.aiTranslateClient = {
59
+ sourceLocale: options.sourceLocale,
60
+ targetLocales: [
61
+ ...options.targetLocales
62
+ ],
63
+ perFieldButton: !!options.perFieldButton
64
+ };
65
+ if (options.enabled === false) {
66
+ return config;
67
+ }
68
+ // Validate required config
69
+ if (!options.sourceLocale) {
70
+ throw new Error('[ai-translate] sourceLocale is required');
71
+ }
72
+ if (!options.targetLocales?.length) {
73
+ throw new Error('[ai-translate] targetLocales must contain at least one locale');
74
+ }
75
+ if (!options.provider) {
76
+ throw new Error('[ai-translate] provider is required');
77
+ }
78
+ if (!options.costLimits) {
79
+ throw new Error('[ai-translate] costLimits is required');
80
+ }
81
+ if (!options.costLimits.perCallCharLimit) {
82
+ throw new Error('[ai-translate] costLimits.perCallCharLimit is required');
83
+ }
84
+ if (!options.costLimits.perDocCharCeiling) {
85
+ throw new Error('[ai-translate] costLimits.perDocCharCeiling is required');
86
+ }
87
+ if (!options.costLimits.bulkConfirmUsdThreshold) {
88
+ throw new Error('[ai-translate] costLimits.bulkConfirmUsdThreshold is required');
89
+ }
90
+ // Validate locale overlap
91
+ if (options.targetLocales.includes(options.sourceLocale)) {
92
+ console.warn(`[ai-translate] targetLocales includes sourceLocale "${options.sourceLocale}". It will be skipped during translation.`);
93
+ }
94
+ // Create rate limiter for provider calls
95
+ const rpm = options.concurrency?.perProvider ?? DEFAULT_CONCURRENCY.perProvider;
96
+ options._rateLimiter = createRateLimiter(rpm);
97
+ // Top-level client-config endpoint. Payload's admin bundle strips
98
+ // function refs from `config.custom`, so the Translate drawer can't
99
+ // read `targetLocales` directly via `useConfig()`. This endpoint
100
+ // exposes the JSON-safe subset (`sourceLocale`, `targetLocales`,
101
+ // `perFieldButton`) over `/api/ai-translate/client-config`, which
102
+ // the drawer fetches on open. Without it, the drawer falls back to
103
+ // listing every non-source locale Payload has registered — fine,
104
+ // just shows more choices than the plugin can translate to.
105
+ config.endpoints = [
106
+ ...Array.isArray(config.endpoints) ? config.endpoints : [],
107
+ {
108
+ handler: getClientConfigHandler(),
109
+ method: 'get',
110
+ path: '/ai-translate/client-config'
111
+ }
112
+ ];
113
+ // Auto-register the usage-tracking collection. Mirrors auditLogPlugin's
114
+ // collection-registration call site. No-op when feature is disabled or
115
+ // not configured, preserving today's behavior for projects that opt out.
116
+ if (options.usageTracking?.enabled) {
117
+ const usageSlug = options.usageTracking.collectionSlug ?? DEFAULT_USAGE_COLLECTION_SLUG;
118
+ const usageCollection = createUsageCollection(options.usageTracking.access?.read, usageSlug);
119
+ config.collections = [
120
+ ...config.collections ?? [],
121
+ usageCollection
122
+ ];
123
+ }
124
+ // Auto-register the alerts collection. Gated on usageTracking — if a
125
+ // consumer hasn't opted into usage tracking they likely don't want
126
+ // alerts persistence either (both feed the Hub). The collection is
127
+ // additive (creates a new table), so opt-in is the safe default.
128
+ // Alert persistence is best-effort in `emitAlert`; the consumer's
129
+ // `onAlert` callback still fires regardless for back-compat.
130
+ if (options.usageTracking?.enabled) {
131
+ const alertsSlug = options.alertsCollectionSlug ?? DEFAULT_ALERTS_COLLECTION_SLUG;
132
+ const alertsCollection = createAlertsCollection(options.usageTracking.access?.read, alertsSlug);
133
+ config.collections = [
134
+ ...config.collections ?? [],
135
+ alertsCollection
136
+ ];
137
+ }
138
+ // Auto-register the manual-edit-meta sidecar collection. Holds per-
139
+ // (collection, doc, locale, field) hashes of the last value the
140
+ // plugin wrote, so the writer can detect manual edits and skip
141
+ // overwriting them. See `preserveManualEdits` in types.ts.
142
+ if (options.preserveManualEdits) {
143
+ const metaSlug = options.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
144
+ const metaCollection = createManualEditMetaCollection(undefined, metaSlug);
145
+ config.collections = [
146
+ ...config.collections ?? [],
147
+ metaCollection
148
+ ];
149
+ }
150
+ // Auto-register the jobs sidecar collection. Mirrors in-memory
151
+ // job state so a server restart doesn't lose admin visibility
152
+ // into in-flight translations. See `persistJobs` in types.ts.
153
+ if (options.persistJobs) {
154
+ const jobsSlug = options.jobsCollectionSlug ?? DEFAULT_JOBS_COLLECTION_SLUG;
155
+ const jobsCollection = createJobsCollection(undefined, jobsSlug);
156
+ config.collections = [
157
+ ...config.collections ?? [],
158
+ jobsCollection
159
+ ];
160
+ // Register the two Payload tasks the coalescing queue enqueues
161
+ // into. Without these, the queue worker's `payload.jobs.queue`
162
+ // call below would fail with "unknown task". Additive — never
163
+ // overwrites consumer-defined tasks.
164
+ config.jobs = config.jobs ?? {
165
+ tasks: []
166
+ };
167
+ const existingTasks = Array.isArray(config.jobs.tasks) ? config.jobs.tasks : [];
168
+ const hasDocTask = existingTasks.some((t)=>t.slug === TRANSLATE_DOCUMENT_TASK_SLUG);
169
+ const hasGlobalTask = existingTasks.some((t)=>t.slug === TRANSLATE_GLOBAL_TASK_SLUG);
170
+ config.jobs.tasks = [
171
+ ...existingTasks,
172
+ ...hasDocTask ? [] : [
173
+ buildTranslateDocumentTask(options.collections ?? [])
174
+ ],
175
+ ...hasGlobalTask ? [] : [
176
+ buildTranslateGlobalTask(options.globals ?? [])
177
+ ]
178
+ ];
179
+ }
180
+ // ---------------------------------------------------------------
181
+ // Bulk-translate engine (v1.2.0+). Auto-registers when the
182
+ // `options.bulk` block is present. Pass `enabled: false`
183
+ // explicitly to opt out — the real cost guards are
184
+ // `dailyUsdCap` + `requireTotp` + admin-role auth, not this
185
+ // flag. (Prior versions defaulted off, requiring `enabled:
186
+ // true` opt-in; the inversion landed in v1.2.3.)
187
+ // ---------------------------------------------------------------
188
+ if (options.bulk && options.bulk.enabled !== false) {
189
+ const bulkConfig = options.bulk;
190
+ const allowedCollectionsForBulk = (options.collections ?? []).filter((slug)=>!(bulkConfig.excludeCollections ?? []).includes(slug));
191
+ const allowedGlobalsForBulk = options.globals ?? [];
192
+ // (1) Collections — batches, units, daily-spend, rate-limits.
193
+ const bulkCollections = [
194
+ createBulkTranslateBatchesCollection(),
195
+ createBulkTranslateUnitsCollection(),
196
+ createTranslationDailySpendCollection(),
197
+ createTranslationRateLimitsCollection()
198
+ ];
199
+ config.collections = [
200
+ ...config.collections ?? [],
201
+ ...bulkCollections
202
+ ];
203
+ // (2) Tasks — coordinator, worker, janitor.
204
+ config.jobs = config.jobs ?? {
205
+ tasks: []
206
+ };
207
+ const tasks = Array.isArray(config.jobs.tasks) ? config.jobs.tasks : [];
208
+ const hasCoordinator = tasks.some((t)=>t.slug === BULK_TRANSLATE_COORDINATOR_SLUG);
209
+ const hasBulkWorker = tasks.some((t)=>t.slug === BULK_TRANSLATE_DOC_TASK_SLUG);
210
+ const hasJanitor = tasks.some((t)=>t.slug === BULK_TRANSLATE_JANITOR_SLUG);
211
+ config.jobs.tasks = [
212
+ ...tasks,
213
+ ...hasCoordinator ? [] : [
214
+ buildBulkTranslateCoordinator()
215
+ ],
216
+ ...hasBulkWorker ? [] : [
217
+ buildBulkTranslateDocTask({
218
+ allowedCollections: allowedCollectionsForBulk,
219
+ allowedGlobals: allowedGlobalsForBulk,
220
+ callbacks: {
221
+ onBatchComplete: bulkConfig.onBatchComplete,
222
+ onBatchFailed: bulkConfig.onBatchFailed
223
+ }
224
+ })
225
+ ],
226
+ ...hasJanitor ? [] : [
227
+ buildBulkTranslateJanitor({
228
+ callbacks: {
229
+ onBatchComplete: bulkConfig.onBatchComplete,
230
+ onBatchFailed: bulkConfig.onBatchFailed
231
+ }
232
+ })
233
+ ]
234
+ ];
235
+ // (3) Endpoints — POST/GET handlers under /api/translation-hub.
236
+ // Handlers resolve admin roles, providers, locales, etc. at
237
+ // request time from `req.payload.config.custom.aiTranslate`
238
+ // (set at line 50). Plugin config flows through that channel.
239
+ // Factory options here are only slug overrides.
240
+ const bulkEndpointBase = '/translation-hub/bulk-translate';
241
+ const retryFailedHandler = getBulkTranslateRetryFailedHandler();
242
+ config.endpoints = [
243
+ ...Array.isArray(config.endpoints) ? config.endpoints : [],
244
+ {
245
+ handler: getBulkTranslateEnqueueHandler(),
246
+ method: 'post',
247
+ path: bulkEndpointBase
248
+ },
249
+ {
250
+ handler: getBulkTranslateListHandler(),
251
+ method: 'get',
252
+ path: bulkEndpointBase
253
+ },
254
+ {
255
+ handler: getBulkTranslateActiveHandler(),
256
+ method: 'get',
257
+ path: `${bulkEndpointBase}/active`
258
+ },
259
+ {
260
+ handler: getBulkTranslatePreflightHandler(),
261
+ method: 'get',
262
+ path: `${bulkEndpointBase}/preflight`
263
+ },
264
+ {
265
+ handler: getBulkTranslateStatusHandler(),
266
+ method: 'get',
267
+ path: `${bulkEndpointBase}/:id/status`
268
+ },
269
+ {
270
+ handler: getBulkTranslateFailuresHandler(),
271
+ method: 'get',
272
+ path: `${bulkEndpointBase}/:id/failures`
273
+ },
274
+ {
275
+ handler: retryFailedHandler,
276
+ method: 'post',
277
+ path: `${bulkEndpointBase}/:id/retry-failed`
278
+ },
279
+ // UI hits `/retry` (shorter); register the same handler under
280
+ // both paths so consumers that call either keep working.
281
+ {
282
+ handler: retryFailedHandler,
283
+ method: 'post',
284
+ path: `${bulkEndpointBase}/:id/retry`
285
+ },
286
+ {
287
+ handler: getBulkTranslateCancelHandler(),
288
+ method: 'post',
289
+ path: `${bulkEndpointBase}/:id/cancel`
290
+ },
291
+ {
292
+ handler: getBulkTranslateRevertHandler(),
293
+ method: 'post',
294
+ path: `${bulkEndpointBase}/:id/revert`
295
+ },
296
+ {
297
+ handler: getBulkTranslateForceResetHandler(),
298
+ method: 'post',
299
+ path: `${bulkEndpointBase}/:id/force-reset`
300
+ },
301
+ // Hub-level (not bulk-translate-specific) — server-side
302
+ // aggregation for Overview + Audit KPIs. NEW-15 (v1.2.6):
303
+ // replaces the prior `/translation-usage?limit=1000` pattern
304
+ // that silently truncated past 1000 rows.
305
+ {
306
+ handler: getTranslationHubUsageSummaryHandler(),
307
+ method: 'get',
308
+ path: '/translation-hub/usage-summary'
309
+ }
310
+ ];
311
+ // (4) Schema migration: idempotent CREATE UNIQUE INDEX IF NOT
312
+ // EXISTS for the partial unique index that backs F-DA-TOCTOU.
313
+ // Wraps the consumer's existing onInit so we don't clobber it.
314
+ const consumerOnInit = config.onInit;
315
+ config.onInit = async (payload)=>{
316
+ if (consumerOnInit) {
317
+ await consumerOnInit(payload);
318
+ }
319
+ try {
320
+ await ensureBulkTranslateSchema(payload);
321
+ } catch (err) {
322
+ payload.logger?.warn?.(`[ai-translate] bulk-translate schema bootstrap failed: ${err instanceof Error ? err.message : String(err)}`);
323
+ }
324
+ };
325
+ }
326
+ // Track which collections / globals are configured for translation
327
+ const trackedCollections = options.collections ?? [];
328
+ const trackedGlobals = options.globals ?? [];
329
+ // Auto-register the translation-settings global. The global carries
330
+ // three sets of fields: an `activeProvider` selector (only meaningful
331
+ // when `providers` map has multiple entries), an `enabledTargetLocales`
332
+ // toggle (always meaningful as long as `targetLocales` are configured),
333
+ // and a `perCollection` array letting admins override the site-wide
334
+ // settings per surface.
335
+ const providerKeys = Object.keys(options.providers ?? {});
336
+ const settingsEnabled = options.targetLocales.length > 0 && options.settings?.enabled !== false;
337
+ if (settingsEnabled) {
338
+ const settingsGlobal = createSettingsGlobal(providerKeys, options.targetLocales, options.settings, [
339
+ ...trackedCollections,
340
+ ...trackedGlobals
341
+ ]);
342
+ config.globals = [
343
+ ...config.globals ?? [],
344
+ settingsGlobal
345
+ ];
346
+ }
347
+ // Pre-resolve the admin-role gate so both injection sites share the
348
+ // same check function. Defaults to ['admin'] for back-compat. Pass
349
+ // `adminRoles: ['super-admin']` etc. to support different role naming.
350
+ const adminRoles = Array.isArray(options.adminRoles) ? options.adminRoles : [
351
+ 'admin'
352
+ ];
353
+ // Loose `args: any` because Payload's `FieldAccess` typing has
354
+ // `req.user: UntypedUser | null` (no roles property until consumers
355
+ // generate types). At runtime the user shape always has `roles`
356
+ // when the consumer uses Payload's auth — we read defensively.
357
+ const isAdminFromReq = (args)=>{
358
+ if (adminRoles.length === 0) return true; // explicit "everyone" opt-in
359
+ const roles = args?.req?.user?.roles;
360
+ if (!Array.isArray(roles)) return false;
361
+ return roles.some((r)=>typeof r === 'string' && adminRoles.includes(r));
362
+ };
363
+ // Automation: create coalescing queue if async mode. The queue is shared
364
+ // by both collection and global hooks — they dispatch to the right
365
+ // translate function via `entry.kind`.
366
+ let queue = null;
367
+ if (options.automation && options.automation.mode !== 'inline') {
368
+ const windowMs = options.automation.coalescingWindowMs ?? DEFAULT_COALESCING_WINDOW_MS;
369
+ queue = createCoalescingQueue(windowMs, async (entry)=>{
370
+ // When `persistJobs: true`, hand the work off to Payload's
371
+ // native jobs queue. The translation closure (`req`, doc id,
372
+ // `previousDoc`, target locales) is serialized into the
373
+ // job's input — Payload's cron worker re-runs the task if
374
+ // the process restarts before it completes. Without this
375
+ // flag we fall back to the original in-memory fire-and-
376
+ // forget path, which is faster but loses pending work on
377
+ // restart.
378
+ if (options.persistJobs) {
379
+ try {
380
+ if (entry.kind === 'global') {
381
+ await entry.payload.jobs.queue({
382
+ task: TRANSLATE_GLOBAL_TASK_SLUG,
383
+ input: {
384
+ global: entry.collection,
385
+ jobId: entry.jobId,
386
+ targetLocales: entry.targetLocales ?? null,
387
+ previousDoc: entry.previousDoc ?? null
388
+ }
389
+ });
390
+ } else {
391
+ await entry.payload.jobs.queue({
392
+ task: TRANSLATE_DOCUMENT_TASK_SLUG,
393
+ input: {
394
+ collection: entry.collection,
395
+ documentId: String(entry.id),
396
+ jobId: entry.jobId,
397
+ targetLocales: entry.targetLocales ?? null,
398
+ previousDoc: entry.previousDoc ?? null,
399
+ draft: true
400
+ }
401
+ });
402
+ }
403
+ // Best-effort immediate run so the user-visible latency
404
+ // matches the previous in-memory behavior. If `runByID`
405
+ // isn't available on this Payload version (older
406
+ // versions only exposed `run`), the cron will still pick
407
+ // it up.
408
+ void entry.payload.jobs.run?.({
409
+ queue: 'default',
410
+ limit: 1
411
+ }).catch(()=>{});
412
+ return;
413
+ } catch (err) {
414
+ entry.payload.logger?.error?.(`[ai-translate] Failed to enqueue persisted job for ${entry.collection}/${entry.kind === 'global' ? '<global>' : String(entry.id)}: ${err instanceof Error ? err.message : 'Unknown error'}. Falling back to in-process translation.`);
415
+ // Fall through to the original in-process path so a
416
+ // jobs-queue outage doesn't drop the work entirely.
417
+ }
418
+ }
419
+ if (entry.kind === 'global') {
420
+ await translateGlobal(entry.payload, {
421
+ global: entry.collection,
422
+ previousDoc: entry.previousDoc,
423
+ jobId: entry.jobId,
424
+ req: entry.req,
425
+ targetLocales: entry.targetLocales
426
+ });
427
+ return;
428
+ }
429
+ await translateDocument(entry.payload, {
430
+ collection: entry.collection,
431
+ id: entry.id,
432
+ previousDoc: entry.previousDoc,
433
+ // Reuse the job seeded by the after-change hook so the polling UI
434
+ // doesn't see two jobs for the same doc (one phantom, one real).
435
+ jobId: entry.jobId,
436
+ req: entry.req,
437
+ targetLocales: entry.targetLocales
438
+ });
439
+ });
440
+ }
441
+ // Inject endpoints, hooks, and UI field on configured collections
442
+ // IMPORTANT: mutate and return the ORIGINAL collection object.
443
+ // Payload captures object references during buildConfig — shallow copies
444
+ // via spread create new references whose hooks Payload never sees.
445
+ config.collections = (config.collections ?? []).map((collection)=>{
446
+ if (!trackedCollections.includes(collection.slug)) {
447
+ return collection;
448
+ }
449
+ // Use the original — do NOT spread into a copy
450
+ const matchedCollection = collection;
451
+ // --- Endpoints ---
452
+ const existingEndpoints = matchedCollection.endpoints ?? [];
453
+ matchedCollection.endpoints = [
454
+ ...Array.isArray(existingEndpoints) ? existingEndpoints : [],
455
+ {
456
+ handler: getTranslateHandler({
457
+ kind: 'collection',
458
+ slug: collection.slug
459
+ }),
460
+ method: 'post',
461
+ path: '/ai-translate'
462
+ },
463
+ {
464
+ handler: getEstimateHandler({
465
+ kind: 'collection',
466
+ slug: collection.slug
467
+ }),
468
+ method: 'post',
469
+ path: '/ai-translate/estimate'
470
+ },
471
+ {
472
+ handler: getProgressHandler(collection.slug),
473
+ method: 'get',
474
+ path: '/ai-translate/progress'
475
+ }
476
+ ];
477
+ // --- Per-doc auto-translate widgets ---
478
+ //
479
+ // Two sibling fields control per-doc auto-translate behavior:
480
+ //
481
+ // 1. `_aiTranslateAutoLocales` (select hasMany) — NARROW which
482
+ // locales fan out on this doc. Empty / unset = inherit
483
+ // collection-level decision (no narrowing). Non-empty =
484
+ // restrict to these locales.
485
+ //
486
+ // 2. `_aiTranslateOptOut` (checkbox) — SKIP auto-translate
487
+ // entirely for this doc. Unchecked = participate normally;
488
+ // checked = bail before any locale work.
489
+ //
490
+ // Why two fields instead of overloading the array's empty state:
491
+ // Payload's hasMany select can't distinguish "admin cleared
492
+ // every checkbox" from "admin never touched the widget" — both
493
+ // serialize to 0 rows. Pre-fix versions treated empty as
494
+ // opt-out, which made auto-translate dead-by-default for every
495
+ // new doc (the inherit state was unreachable). Splitting the
496
+ // intents into distinct fields fixes that: empty array ALWAYS
497
+ // means inherit; opt-out is a separate, explicit boolean.
498
+ //
499
+ // `localized: false` on both: a per-locale value would let one
500
+ // locale clobber another. `access.update` is admin-only because
501
+ // they gate billable LLM spend; editors see the fields but they
502
+ // render read-only.
503
+ matchedCollection.fields = [
504
+ ...matchedCollection.fields,
505
+ {
506
+ name: '_aiTranslateOptOut',
507
+ label: 'Skip auto-translate',
508
+ type: 'checkbox',
509
+ defaultValue: false,
510
+ required: false,
511
+ localized: false,
512
+ access: {
513
+ update: isAdminFromReq
514
+ },
515
+ admin: {
516
+ position: 'sidebar',
517
+ description: 'Admin only. Check to skip auto-translate entirely for this document on publish. Manual Translate from the dialog still works. Leave unchecked to participate in normal auto-translate.'
518
+ }
519
+ },
520
+ {
521
+ name: '_aiTranslateAutoLocales',
522
+ label: 'Auto-translate locales',
523
+ type: 'select',
524
+ hasMany: true,
525
+ required: false,
526
+ localized: false,
527
+ options: options.targetLocales.map((code)=>({
528
+ label: code,
529
+ value: code
530
+ })),
531
+ // Reject locale codes not in the plugin's configured
532
+ // `targetLocales`. The select's option list constrains the
533
+ // admin form, but the REST/GraphQL API path accepts arbitrary
534
+ // values — without this guard, an API write with
535
+ // `_aiTranslateAutoLocales: ['xx', 'yy']` would persist and
536
+ // silently narrow the intersection to empty downstream.
537
+ validate: (value)=>{
538
+ if (value === null || value === undefined) return true;
539
+ if (!Array.isArray(value)) {
540
+ return 'Must be an array of locale codes.';
541
+ }
542
+ const universe = new Set(options.targetLocales);
543
+ const bad = value.filter((v)=>typeof v !== 'string' || !universe.has(v));
544
+ if (bad.length > 0) {
545
+ return `Unknown locale(s): ${bad.join(', ')}. Must be one of: ${options.targetLocales.join(', ')}.`;
546
+ }
547
+ return true;
548
+ },
549
+ access: {
550
+ update: isAdminFromReq
551
+ },
552
+ admin: {
553
+ position: 'sidebar',
554
+ description: 'Admin only. Narrow which target locales auto-translate on publish for this document. Leave empty to inherit the collection default (translate to every enabled locale). Pick specific locales to translate to ONLY those. To skip auto-translate entirely for this doc, use the "Skip auto-translate" checkbox above — clearing this list does NOT opt out.'
555
+ }
556
+ },
557
+ {
558
+ name: '_aiTranslate',
559
+ type: 'ui',
560
+ admin: {
561
+ position: 'sidebar',
562
+ condition: (data)=>Boolean(data?.id),
563
+ components: {
564
+ Field: '@purposeinplay/payload-ai-translate/client#TranslateButton'
565
+ }
566
+ }
567
+ }
568
+ ];
569
+ // --- Per-field translate button (opt-in) ---
570
+ if (options.perFieldButton) {
571
+ matchedCollection.fields = injectFieldButtons(matchedCollection.fields, getEffectiveExcludePatterns(options));
572
+ }
573
+ // --- Automation hooks (only if automation config present) ---
574
+ if (options.automation) {
575
+ const automation = options.automation;
576
+ // Drafts detection: warn if on-change trigger + drafts enabled
577
+ const hasDrafts = !!(collection.versions && typeof collection.versions === 'object' && collection.versions.drafts);
578
+ if (hasDrafts && automation.trigger !== 'on-publish') {
579
+ console.warn(`[ai-translate] Collection "${collection.slug}" has drafts enabled but automation trigger is "${automation.trigger ?? 'on-change'}". ` + `This will translate on every autosave. Consider using trigger: 'on-publish'.`);
580
+ }
581
+ const hook = createTranslateAfterChangeHook({
582
+ collectionSlug: collection.slug,
583
+ pluginOptions: options,
584
+ queue,
585
+ hasDrafts
586
+ });
587
+ // Mutate the ORIGINAL collection's hooks array in-place.
588
+ // Payload captures hook references during buildConfig before plugins
589
+ // return. Replacing the hooks object via spread creates a new reference
590
+ // that Payload never sees. We must push onto the original array.
591
+ if (!collection.hooks) {
592
+ collection.hooks = {};
593
+ }
594
+ if (!collection.hooks.afterChange) {
595
+ collection.hooks.afterChange = [];
596
+ }
597
+ collection.hooks.afterChange.unshift(hook);
598
+ }
599
+ // BUG-20 + BUG-26 — after-delete cleanup: cancel in-memory jobs +
600
+ // purge `ai_translate_jobs` and `ai_translate_meta` rows for the
601
+ // deleted doc. Registered unconditionally (no `automation` gate)
602
+ // because the storage tables can hold rows from earlier translate
603
+ // calls regardless of whether automation is on now.
604
+ const deleteHook = createAiTranslateAfterDeleteHook(collection.slug, options);
605
+ if (!collection.hooks.afterDelete) {
606
+ collection.hooks.afterDelete = [];
607
+ }
608
+ collection.hooks.afterDelete.unshift(deleteHook);
609
+ return matchedCollection;
610
+ });
611
+ // Globals: register endpoints + UI + after-change hook on configured globals.
612
+ // Mirrors the collection branch so manual translate, per-field buttons,
613
+ // estimate/progress endpoints, and auto-translate all work for globals too.
614
+ if (trackedGlobals.length > 0 && config.globals) {
615
+ config.globals = config.globals.map((global)=>{
616
+ if (!global.slug || !trackedGlobals.includes(global.slug)) {
617
+ return global;
618
+ }
619
+ const matchedGlobal = global;
620
+ // --- Endpoints ---
621
+ const existingEndpoints = matchedGlobal.endpoints ?? [];
622
+ matchedGlobal.endpoints = [
623
+ ...Array.isArray(existingEndpoints) ? existingEndpoints : [],
624
+ {
625
+ handler: getTranslateHandler({
626
+ kind: 'global',
627
+ slug: global.slug
628
+ }),
629
+ method: 'post',
630
+ path: '/ai-translate'
631
+ },
632
+ {
633
+ handler: getEstimateHandler({
634
+ kind: 'global',
635
+ slug: global.slug
636
+ }),
637
+ method: 'post',
638
+ path: '/ai-translate/estimate'
639
+ },
640
+ {
641
+ handler: getProgressHandler(global.slug),
642
+ method: 'get',
643
+ path: '/ai-translate/progress'
644
+ }
645
+ ];
646
+ // --- Per-doc auto-translate widgets ---
647
+ // See collection branch above for the full rationale on why
648
+ // these are two separate fields instead of a single 3-state array.
649
+ //
650
+ // The descriptions say "on save" rather than "on publish":
651
+ // globals in Payload don't have a publish gate (no draft
652
+ // state by default), so every save fires after-change. Editors
653
+ // should know that auto-translate flows differently here.
654
+ //
655
+ // Globals don't have a numeric `id`, but they always exist once
656
+ // saved. The sidebar Translate button renders unconditionally —
657
+ // the client component reads the global slug from
658
+ // useDocumentInfo to build URLs.
659
+ matchedGlobal.fields = [
660
+ ...matchedGlobal.fields,
661
+ {
662
+ name: '_aiTranslateOptOut',
663
+ label: 'Skip auto-translate',
664
+ type: 'checkbox',
665
+ defaultValue: false,
666
+ required: false,
667
+ localized: false,
668
+ access: {
669
+ update: isAdminFromReq
670
+ },
671
+ admin: {
672
+ position: 'sidebar',
673
+ description: 'Admin only. Check to skip auto-translate entirely for this global on save. Manual Translate from the dialog still works. Leave unchecked to participate in normal auto-translate.'
674
+ }
675
+ },
676
+ {
677
+ name: '_aiTranslateAutoLocales',
678
+ label: 'Auto-translate locales',
679
+ type: 'select',
680
+ hasMany: true,
681
+ required: false,
682
+ localized: false,
683
+ options: options.targetLocales.map((code)=>({
684
+ label: code,
685
+ value: code
686
+ })),
687
+ validate: (value)=>{
688
+ if (value === null || value === undefined) return true;
689
+ if (!Array.isArray(value)) {
690
+ return 'Must be an array of locale codes.';
691
+ }
692
+ const universe = new Set(options.targetLocales);
693
+ const bad = value.filter((v)=>typeof v !== 'string' || !universe.has(v));
694
+ if (bad.length > 0) {
695
+ return `Unknown locale(s): ${bad.join(', ')}. Must be one of: ${options.targetLocales.join(', ')}.`;
696
+ }
697
+ return true;
698
+ },
699
+ access: {
700
+ update: isAdminFromReq
701
+ },
702
+ admin: {
703
+ position: 'sidebar',
704
+ description: 'Admin only. Narrow which target locales auto-translate on save for this global. Leave empty to inherit defaults (translate to every enabled locale). Pick specific locales to translate to ONLY those. To skip auto-translate entirely, use the "Skip auto-translate" checkbox above — clearing this list does NOT opt out.'
705
+ }
706
+ },
707
+ {
708
+ name: '_aiTranslate',
709
+ type: 'ui',
710
+ admin: {
711
+ position: 'sidebar',
712
+ components: {
713
+ Field: '@purposeinplay/payload-ai-translate/client#TranslateButton'
714
+ }
715
+ }
716
+ }
717
+ ];
718
+ // --- Per-field translate button (opt-in) ---
719
+ if (options.perFieldButton) {
720
+ matchedGlobal.fields = injectFieldButtons(matchedGlobal.fields, getEffectiveExcludePatterns(options));
721
+ }
722
+ // --- Auto-translate hook ---
723
+ if (options.automation) {
724
+ // Reuse the same coalescing queue collections use, so a burst
725
+ // of saves on a global (e.g. autosave debouncer firing rapidly)
726
+ // collapses into a single translation pass.
727
+ const hook = createTranslateGlobalAfterChangeHook(global.slug, options, queue);
728
+ if (!global.hooks) {
729
+ global.hooks = {};
730
+ }
731
+ if (!global.hooks.afterChange) {
732
+ global.hooks.afterChange = [];
733
+ }
734
+ global.hooks.afterChange.unshift(hook);
735
+ }
736
+ return matchedGlobal;
737
+ });
738
+ }
739
+ // Wire the progress-store's persistence target. Done in `onInit`
740
+ // because we need a live `payload` instance, which only exists
741
+ // post-config-build. The plugin sequence here only mutates config —
742
+ // the actual mirror writes happen later when `createJob`/
743
+ // `updateJob`/`cleanup` fire during translation.
744
+ if (options.persistJobs) {
745
+ const existingOnInit = config.onInit;
746
+ const jobsSlug = options.jobsCollectionSlug ?? DEFAULT_JOBS_COLLECTION_SLUG;
747
+ config.onInit = async (payload)=>{
748
+ if (existingOnInit) {
749
+ await existingOnInit(payload);
750
+ }
751
+ setPersistenceContext({
752
+ payload: payload,
753
+ collectionSlug: jobsSlug
754
+ });
755
+ };
756
+ }
757
+ // Wire the alerts persistence target. Gated on usageTracking — same
758
+ // gate that auto-registers the alerts collection. The module-scoped
759
+ // ref is read by `emitAlert` in lib/events.ts on every alert emission.
760
+ if (options.usageTracking?.enabled) {
761
+ const existingOnInit = config.onInit;
762
+ const alertsSlug = options.alertsCollectionSlug ?? DEFAULT_ALERTS_COLLECTION_SLUG;
763
+ config.onInit = async (payload)=>{
764
+ if (existingOnInit) {
765
+ await existingOnInit(payload);
766
+ }
767
+ setAlertsContext({
768
+ payload: payload,
769
+ collectionSlug: alertsSlug
770
+ });
771
+ };
772
+ }
773
+ // Drive the janitor sweep. The task is registered as a Payload
774
+ // job, but Payload's `autoRun` only runs *queued* jobs — it does
775
+ // not auto-enqueue tasks on a schedule. Without an explicit driver
776
+ // here, the janitor never fires and stuck `running` units stay
777
+ // stuck forever after a worker crash or container restart. We
778
+ // drive it from `onInit` with two layers:
779
+ //
780
+ // 1. Boot sweep — runs once immediately so a process that comes
781
+ // up after a crash reclaims orphaned units from the previous
782
+ // lifetime within ~10s of becoming healthy.
783
+ // 2. Periodic sweep — every `bulk.janitorIntervalMs` (default
784
+ // 5 min) for the life of the process, so a worker dying
785
+ // mid-run also gets cleaned up without waiting for a restart.
786
+ //
787
+ // In serverless / short-lived process deployments the periodic
788
+ // setInterval simply never fires (process is killed between
789
+ // invocations), but the boot sweep still runs on each cold start —
790
+ // which is exactly when stuck units would otherwise be problematic.
791
+ if (options.bulk && options.bulk.enabled !== false) {
792
+ const bulkConfigForJanitor = options.bulk;
793
+ const existingOnInit = config.onInit;
794
+ const unitsSlug = bulkConfigForJanitor.unitsCollectionSlug ?? DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG;
795
+ const usageSlug = options.usageTracking?.collectionSlug ?? DEFAULT_USAGE_COLLECTION_SLUG;
796
+ const janitorIntervalMs = bulkConfigForJanitor.janitorIntervalMs ?? 5 * 60_000;
797
+ config.onInit = async (payload)=>{
798
+ if (existingOnInit) {
799
+ await existingOnInit(payload);
800
+ }
801
+ const sweep = async (label)=>{
802
+ try {
803
+ const result = await runJanitorSweep({
804
+ payload,
805
+ unitsSlug,
806
+ batchesSlug: DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG,
807
+ usageSlug,
808
+ minThresholdMs: 10 * 60_000,
809
+ p99Multiplier: 2,
810
+ maxAttempts: 3,
811
+ p99CacheTtlMs: 5 * 60_000,
812
+ callbacks: {
813
+ onBatchComplete: bulkConfigForJanitor.onBatchComplete,
814
+ onBatchFailed: bulkConfigForJanitor.onBatchFailed
815
+ }
816
+ });
817
+ if (result.reset > 0 || result.failed > 0 || result.requeued > 0) {
818
+ payload.logger?.info?.(`[ai-translate] janitor ${label} sweep: reset=${result.reset} failed=${result.failed} requeued=${result.requeued} thresholdMs=${result.thresholdMs}`);
819
+ }
820
+ } catch (err) {
821
+ payload.logger?.warn?.(`[ai-translate] janitor ${label} sweep failed: ${err instanceof Error ? err.message : String(err)}`);
822
+ }
823
+ };
824
+ // Boot sweep — fire-and-forget so onInit doesn't block server
825
+ // start while the sweep runs.
826
+ void sweep('boot');
827
+ // Periodic sweep. `unref()` lets the process exit naturally
828
+ // without the timer keeping it alive (matters for test
829
+ // teardown and serverless-style short-lived processes).
830
+ if (janitorIntervalMs > 0) {
831
+ const timer = setInterval(()=>{
832
+ void sweep('interval');
833
+ }, janitorIntervalMs);
834
+ if (typeof timer.unref === 'function') {
835
+ timer.unref();
836
+ }
837
+ }
838
+ };
839
+ }
840
+ return config;
841
+ };
842
+ }
843
+ // ---------------------------------------------------------------------------
844
+ // Per-field button injection
845
+ // ---------------------------------------------------------------------------
846
+ const TRANSLATABLE_FIELD_TYPES = new Set([
847
+ 'text',
848
+ 'textarea',
849
+ 'richText'
850
+ ]);
851
+ function injectFieldButtons(fields, excludePatterns, pathPrefix = '') {
852
+ return fields.map((field)=>{
853
+ const f = field;
854
+ const type = f.type;
855
+ const localized = f.localized;
856
+ const name = f.name;
857
+ const fieldPath = name ? pathPrefix ? `${pathPrefix}.${name}` : name : pathPrefix;
858
+ // Inject afterInput on localized translatable fields
859
+ if (type && TRANSLATABLE_FIELD_TYPES.has(type) && localized && name) {
860
+ // Skip injection for fields that are excluded from translation. The
861
+ // server-side flow already filters these out, but rendering the button
862
+ // creates user confusion (e.g. clicking "Translate" on `slug`).
863
+ if (isExcluded(fieldPath, excludePatterns)) {
864
+ return field;
865
+ }
866
+ const admin = f.admin ?? {};
867
+ const components = admin.components ?? {};
868
+ return {
869
+ ...field,
870
+ admin: {
871
+ ...admin,
872
+ components: {
873
+ ...components,
874
+ afterInput: [
875
+ ...components.afterInput ?? [],
876
+ '@purposeinplay/payload-ai-translate/client#FieldTranslateButton'
877
+ ]
878
+ }
879
+ }
880
+ };
881
+ }
882
+ // Recurse into container fields
883
+ if (type === 'group' || type === 'array' || type === 'row' || type === 'collapsible') {
884
+ const subFields = f.fields;
885
+ if (subFields) {
886
+ // `row` and `collapsible` don't introduce a name segment in the path
887
+ const childPrefix = type === 'row' || type === 'collapsible' ? pathPrefix : fieldPath;
888
+ return {
889
+ ...field,
890
+ fields: injectFieldButtons(subFields, excludePatterns, childPrefix)
891
+ };
892
+ }
893
+ }
894
+ if (type === 'tabs') {
895
+ const tabs = f.tabs;
896
+ if (tabs) {
897
+ return {
898
+ ...field,
899
+ tabs: tabs.map((tab)=>{
900
+ const tabFields = tab.fields;
901
+ if (tabFields) {
902
+ const tabName = tab.name;
903
+ const childPrefix = tabName ? pathPrefix ? `${pathPrefix}.${tabName}` : tabName : pathPrefix;
904
+ return {
905
+ ...tab,
906
+ fields: injectFieldButtons(tabFields, excludePatterns, childPrefix)
907
+ };
908
+ }
909
+ return tab;
910
+ })
911
+ };
912
+ }
913
+ }
914
+ if (type === 'blocks') {
915
+ const blocks = f.blocks;
916
+ if (blocks) {
917
+ return {
918
+ ...field,
919
+ blocks: blocks.map((block)=>{
920
+ const blockFields = block.fields;
921
+ if (blockFields) {
922
+ return {
923
+ ...block,
924
+ fields: injectFieldButtons(blockFields, excludePatterns, fieldPath)
925
+ };
926
+ }
927
+ return block;
928
+ })
929
+ };
930
+ }
931
+ }
932
+ return field;
933
+ });
934
+ }