@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
@@ -0,0 +1,237 @@
1
+ import { describeAuthRejection } from '../lib/auth-diagnostics.js';
2
+ import { collectBlocksConfig, extractTranslationUnits } from '../lib/content-extractor.js';
3
+ import { getEffectiveExcludePatternsForSurface, getExcludedFieldPaths, isPathExcluded } from '../lib/effective-locales.js';
4
+ import { resolveTranslatableFields } from '../lib/field-resolver.js';
5
+ import { findByIdNoFallback } from '../lib/payload-read.js';
6
+ /**
7
+ * Heuristic: would the validator mark this string as a verbatim echo if the
8
+ * LLM returned it unchanged? Used to subtract "likely skipped" chars from
9
+ * the billable estimate so the editor sees an accurate cost.
10
+ *
11
+ * Conservative — false positives would just make the estimate look smaller
12
+ * than reality, but we'd rather over-estimate than under-estimate. The
13
+ * actual validator runs at translation time; this is a pre-flight guess.
14
+ */ function isLikelyVerbatim(text) {
15
+ const trimmed = text.trim();
16
+ if (trimmed.length < 3) return true; // too short to translate meaningfully
17
+ if (/^https?:\/\//i.test(trimmed)) return true; // URLs
18
+ if (/^[\d.\s,-]+$/.test(trimmed)) return true; // pure numeric / dates
19
+ if (/^[A-Z0-9_-]+$/.test(trimmed) && trimmed.length < 12) return true; // SKU / code
20
+ return false;
21
+ }
22
+ function resolveContext(arg, url) {
23
+ if (typeof arg === 'object' && arg !== null) return arg;
24
+ try {
25
+ const segments = new URL(url).pathname.split('/').filter(Boolean);
26
+ const apiIndex = segments.indexOf('api');
27
+ if (apiIndex >= 0) {
28
+ if (segments[apiIndex + 1] === 'globals' && segments[apiIndex + 2]) {
29
+ return {
30
+ kind: 'global',
31
+ slug: segments[apiIndex + 2]
32
+ };
33
+ }
34
+ if (segments[apiIndex + 1]) {
35
+ return {
36
+ kind: 'collection',
37
+ slug: arg ?? segments[apiIndex + 1]
38
+ };
39
+ }
40
+ }
41
+ } catch {
42
+ // fall through
43
+ }
44
+ return {
45
+ kind: 'collection',
46
+ slug: typeof arg === 'string' ? arg : ''
47
+ };
48
+ }
49
+ export const getEstimateHandler = (arg)=>async (req)=>{
50
+ try {
51
+ if (!req.user) {
52
+ return Response.json({
53
+ error: 'Unauthorized',
54
+ diagnostic: describeAuthRejection(req)
55
+ }, {
56
+ status: 401
57
+ });
58
+ }
59
+ const config = req.payload.config?.custom?.aiTranslate;
60
+ if (config.access?.translate) {
61
+ const allowed = await config.access.translate({
62
+ req
63
+ });
64
+ if (!allowed) {
65
+ return Response.json({
66
+ error: 'Forbidden'
67
+ }, {
68
+ status: 403
69
+ });
70
+ }
71
+ }
72
+ let body;
73
+ try {
74
+ body = await req.json();
75
+ } catch {
76
+ return Response.json({
77
+ error: 'Invalid JSON body'
78
+ }, {
79
+ status: 400
80
+ });
81
+ }
82
+ const { ids, targetLocales: bodyLocales, draft: bodyDraft } = body;
83
+ const targetLocales = bodyLocales ?? config.targetLocales;
84
+ const draft = bodyDraft ?? true;
85
+ const ctx = resolveContext(arg, req.url ?? '');
86
+ let totalCharacters = 0;
87
+ const allItems = [];
88
+ let documentCount = 0;
89
+ const excludePatterns = await getEffectiveExcludePatternsForSurface(req.payload, config, ctx.slug);
90
+ // Honour admin-configured exclusions so the estimate matches what
91
+ // the translate pipeline will actually send. Without this, the
92
+ // editor sees an inflated cost and may bail unnecessarily.
93
+ const adminExcludedPaths = await getExcludedFieldPaths(req.payload, config, ctx.slug);
94
+ if (ctx.kind === 'global') {
95
+ const globals = req.payload.config?.globals ?? [];
96
+ const globalConfig = globals.find((g)=>g.slug === ctx.slug);
97
+ if (!globalConfig) {
98
+ return Response.json({
99
+ error: `Global "${ctx.slug}" not found`
100
+ }, {
101
+ status: 404
102
+ });
103
+ }
104
+ // Globals don't have drafts in Payload's model, so `draft` is a no-op
105
+ // here. Read the source-locale doc with no fallback so we estimate
106
+ // against the actual source content.
107
+ const doc = await req.payload.findGlobal({
108
+ slug: ctx.slug,
109
+ locale: config.sourceLocale,
110
+ fallbackLocale: null
111
+ });
112
+ if (doc) {
113
+ let translatableFields = resolveTranslatableFields(globalConfig.fields);
114
+ if (adminExcludedPaths.size > 0) {
115
+ translatableFields = translatableFields.filter((f)=>!isPathExcluded(f.path, adminExcludedPaths));
116
+ }
117
+ const blocksConfig = collectBlocksConfig(globalConfig.fields);
118
+ const units = extractTranslationUnits(doc, translatableFields, excludePatterns, blocksConfig);
119
+ for (const unit of units){
120
+ totalCharacters += unit.text.length;
121
+ allItems.push({
122
+ id: unit.id,
123
+ text: unit.text,
124
+ kind: unit.kind
125
+ });
126
+ }
127
+ documentCount = 1;
128
+ }
129
+ } else {
130
+ if (!ids || !Array.isArray(ids) || ids.length === 0) {
131
+ return Response.json({
132
+ error: '"ids" must be a non-empty array'
133
+ }, {
134
+ status: 400
135
+ });
136
+ }
137
+ const collections = req.payload.config?.collections ?? [];
138
+ const collectionConfig = collections.find((c)=>c.slug === ctx.slug);
139
+ if (!collectionConfig) {
140
+ return Response.json({
141
+ error: `Collection "${ctx.slug}" not found`
142
+ }, {
143
+ status: 404
144
+ });
145
+ }
146
+ const collectionFields = collectionConfig.fields;
147
+ let translatableFields = resolveTranslatableFields(collectionFields);
148
+ if (adminExcludedPaths.size > 0) {
149
+ translatableFields = translatableFields.filter((f)=>!isPathExcluded(f.path, adminExcludedPaths));
150
+ }
151
+ const blocksConfig = collectBlocksConfig(collectionFields);
152
+ for (const docId of ids){
153
+ const doc = await findByIdNoFallback(req.payload, ctx.slug, docId, config.sourceLocale, {
154
+ draft
155
+ });
156
+ if (!doc) continue;
157
+ const units = extractTranslationUnits(doc, translatableFields, excludePatterns, blocksConfig);
158
+ for (const unit of units){
159
+ totalCharacters += unit.text.length;
160
+ allItems.push({
161
+ id: unit.id,
162
+ text: unit.text,
163
+ kind: unit.kind
164
+ });
165
+ }
166
+ }
167
+ documentCount = ids.length;
168
+ }
169
+ // Tally how many characters we expect the validator to mark as
170
+ // `skipped` (verbatim echo). The estimate stays honest by separating
171
+ // billable from skipped, so the editor isn't surprised when "152 chars"
172
+ // turns into a smaller actual LLM call.
173
+ let likelySkippedCharacters = 0;
174
+ for (const item of allItems){
175
+ if (isLikelyVerbatim(item.text)) {
176
+ likelySkippedCharacters += item.text.length;
177
+ }
178
+ }
179
+ const billableCharacters = totalCharacters - likelySkippedCharacters;
180
+ // Token estimation. The previous formula (`billable / 3.5`) under-
181
+ // counted by ~7-30× because it ignored:
182
+ // 1) System prompt + few-shot overhead — the prompt template adds
183
+ // ~SYSTEM_PROMPT_TOKENS regardless of input size.
184
+ // 2) Output tokens — most LLMs return responses of length similar to
185
+ // or slightly larger than the input (translated text is ~1.0-1.2×
186
+ // source for most language pairs).
187
+ // 3) Per-batch repetition — the prompt is sent once per locale.
188
+ //
189
+ // Result: a more realistic projection that lines up within ~2× of
190
+ // billed cost for the typical case rather than being 30× off.
191
+ const SYSTEM_PROMPT_TOKENS_PER_LOCALE_PASS = 500;
192
+ const OUTPUT_TO_INPUT_RATIO = 1.1; // tend to be slightly longer
193
+ const estimatedInputTokens = Math.ceil(billableCharacters / 3.5);
194
+ const estimatedOutputTokens = Math.ceil(estimatedInputTokens * OUTPUT_TO_INPUT_RATIO);
195
+ const overheadTokens = SYSTEM_PROMPT_TOKENS_PER_LOCALE_PASS * targetLocales.length;
196
+ const estimatedTokens = (estimatedInputTokens + estimatedOutputTokens) * targetLocales.length + overheadTokens;
197
+ let estimatedCostUsd;
198
+ if (config.provider.estimate) {
199
+ // Cost estimate is based only on billable items — skipped strings
200
+ // never reach the provider so they don't bill.
201
+ const billableItems = allItems.filter((i)=>!isLikelyVerbatim(i.text));
202
+ const mockRequest = {
203
+ items: billableItems,
204
+ sourceLocale: config.sourceLocale,
205
+ targetLocale: targetLocales[0] ?? '',
206
+ context: {
207
+ collectionSlug: ctx.slug,
208
+ fieldPath: '*'
209
+ }
210
+ };
211
+ const estimate = await config.provider.estimate(mockRequest);
212
+ estimatedCostUsd = estimate.estimatedCostUsd;
213
+ }
214
+ // Multiply by locale count (one translation pass per target locale)
215
+ if (estimatedCostUsd !== undefined) {
216
+ estimatedCostUsd = estimatedCostUsd * targetLocales.length;
217
+ }
218
+ return Response.json({
219
+ totalCharacters,
220
+ billableCharacters,
221
+ likelySkippedCharacters,
222
+ estimatedTokens,
223
+ estimatedInputTokens,
224
+ estimatedOutputTokens,
225
+ estimatedCostUsd,
226
+ documentCount,
227
+ localeCount: targetLocales.length
228
+ });
229
+ } catch (error) {
230
+ const message = error instanceof Error ? error.message : 'Internal server error';
231
+ return Response.json({
232
+ error: message
233
+ }, {
234
+ status: 500
235
+ });
236
+ }
237
+ };
@@ -0,0 +1,2 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ export declare const getProgressHandler: (collectionSlug?: string) => PayloadHandler;
@@ -0,0 +1,314 @@
1
+ import { describeAuthRejection } from '../lib/auth-diagnostics.js';
2
+ import { getActiveJobForDoc, getJob, subscribe, subscribeToDoc } from '../lib/progress-store.js';
3
+ const SSE_HEADERS = {
4
+ 'Content-Type': 'text/event-stream',
5
+ 'Cache-Control': 'no-cache',
6
+ Connection: 'keep-alive'
7
+ };
8
+ /**
9
+ * Long-lived stream lifetime for the `?docId=X` "wait for any job" mode.
10
+ * After this, the stream closes and the browser auto-reconnects (the
11
+ * `retry: 30000` directive paces idle reconnects). 5 minutes balances:
12
+ * - Don't expire mid-edit-session.
13
+ * - Don't pile up forever per editor when nothing's happening.
14
+ */ const DOC_STREAM_TTL_MS = 5 * 60_000;
15
+ /**
16
+ * Heartbeat keeps the SSE connection alive across proxies/load balancers
17
+ * that idle-time TCP. Sent as an SSE comment (`:hb`), which clients
18
+ * ignore but counts as activity.
19
+ */ const HEARTBEAT_MS = 25_000;
20
+ /**
21
+ * Hold-open lifetime for an active-job stream (`?jobId=X` or `?docId=X`
22
+ * that resolved to a job). Jobs typically finish in <30s; 2min is a
23
+ * generous ceiling for long batch translations.
24
+ */ const JOB_STREAM_TTL_MS = 120_000;
25
+ function jobToEvent(job) {
26
+ return {
27
+ jobId: job.jobId,
28
+ completed: [
29
+ ...job.completedLocales
30
+ ],
31
+ failed: [
32
+ ...job.failedLocales
33
+ ],
34
+ total: job.totalLocales,
35
+ status: job.status
36
+ };
37
+ }
38
+ export const getProgressHandler = (collectionSlug)=>(req)=>{
39
+ if (!req.user) {
40
+ return Response.json({
41
+ error: 'Unauthorized',
42
+ diagnostic: describeAuthRejection(req)
43
+ }, {
44
+ status: 401
45
+ });
46
+ }
47
+ const url = new URL(req.url);
48
+ const jobIdParam = url.searchParams.get('jobId');
49
+ const docIdParam = url.searchParams.get('docId');
50
+ // Fast path: explicit jobId — stream that job's events directly.
51
+ if (jobIdParam) {
52
+ const job = getJob(jobIdParam);
53
+ if (job) {
54
+ return new Response(buildJobStream(job.jobId, req.signal), {
55
+ headers: SSE_HEADERS
56
+ });
57
+ }
58
+ // Unknown jobId — fall through to idle close (client will reconnect
59
+ // and may resolve a different job by then).
60
+ return idleResponse();
61
+ }
62
+ // docId path: subscribe to "any active or future job for this doc".
63
+ if (docIdParam) {
64
+ const slug = collectionSlug ?? resolveSlugFromUrl(req.url ?? '');
65
+ if (!slug) return idleResponse();
66
+ const existing = getActiveJobForDoc(slug, docIdParam);
67
+ if (existing) {
68
+ // Existing in-flight job — stream it.
69
+ return new Response(buildJobStream(existing.jobId, req.signal), {
70
+ headers: SSE_HEADERS
71
+ });
72
+ }
73
+ // No active job. Hold the stream open and wait for `createJob` to
74
+ // fire for this (slug, docId). When it does, push the event and
75
+ // hand off to the per-job stream pattern (replay + subscribe to
76
+ // future updates).
77
+ return new Response(buildDocStream(slug, docIdParam, req.signal), {
78
+ headers: SSE_HEADERS
79
+ });
80
+ }
81
+ // Neither param — return idle and close. Same shape as before to
82
+ // keep the client's parser happy.
83
+ return idleResponse();
84
+ };
85
+ /**
86
+ * Idle-close response. Used when the request lacks both `jobId` and
87
+ * `docId`, or when an explicit `jobId` references an unknown job.
88
+ * `retry: 30000` paces the browser's auto-reconnect.
89
+ */ function idleResponse() {
90
+ const body = `retry: 30000\nevent: progress\ndata: ${JSON.stringify({
91
+ jobId: '',
92
+ completed: [],
93
+ failed: [],
94
+ total: 0,
95
+ status: 'idle'
96
+ })}\n\n`;
97
+ return new Response(body, {
98
+ headers: {
99
+ 'Content-Type': 'text/event-stream',
100
+ 'Cache-Control': 'no-cache'
101
+ }
102
+ });
103
+ }
104
+ /**
105
+ * Build a stream that subscribes to an existing job and pushes its
106
+ * events until completion (+ a 2s grace period so the client sees the
107
+ * final state before the connection drops).
108
+ */ function buildJobStream(jobId, reqSignal) {
109
+ let unsubscribe;
110
+ let timeout;
111
+ let heartbeat;
112
+ return new ReadableStream({
113
+ start (controller) {
114
+ const encoder = new TextEncoder();
115
+ controller.enqueue(encoder.encode('retry: 30000\n\n'));
116
+ const tryClose = ()=>{
117
+ try {
118
+ controller.close();
119
+ } catch {
120
+ // already closed
121
+ }
122
+ };
123
+ const send = (event)=>{
124
+ // Only `completed` and `failed` are terminal — `idle` means
125
+ // "waiting for a job", `running` means "in flight". Don't close
126
+ // the stream on idle or running events.
127
+ const isTerminal = event.status === 'completed' || event.status === 'failed';
128
+ const eventType = isTerminal ? 'complete' : 'progress';
129
+ try {
130
+ controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify(event)}\n\n`));
131
+ } catch {
132
+ // controller closed mid-write
133
+ return;
134
+ }
135
+ if (isTerminal) {
136
+ cleanup();
137
+ // Brief grace so the client paints the final state before close.
138
+ setTimeout(tryClose, 2000);
139
+ }
140
+ };
141
+ const cleanup = ()=>{
142
+ if (unsubscribe) {
143
+ unsubscribe();
144
+ unsubscribe = undefined;
145
+ }
146
+ if (timeout) {
147
+ clearTimeout(timeout);
148
+ timeout = undefined;
149
+ }
150
+ if (heartbeat) {
151
+ clearInterval(heartbeat);
152
+ heartbeat = undefined;
153
+ }
154
+ };
155
+ // Replay current state immediately.
156
+ const currentJob = getJob(jobId);
157
+ if (currentJob) {
158
+ send(jobToEvent(currentJob));
159
+ if (currentJob.status !== 'running') return;
160
+ }
161
+ unsubscribe = subscribe(jobId, send);
162
+ heartbeat = setInterval(()=>{
163
+ try {
164
+ controller.enqueue(encoder.encode(':hb\n\n'));
165
+ } catch {
166
+ cleanup();
167
+ }
168
+ }, HEARTBEAT_MS);
169
+ timeout = setTimeout(()=>{
170
+ cleanup();
171
+ tryClose();
172
+ }, JOB_STREAM_TTL_MS);
173
+ reqSignal?.addEventListener('abort', ()=>{
174
+ cleanup();
175
+ tryClose();
176
+ });
177
+ },
178
+ cancel () {
179
+ if (unsubscribe) unsubscribe();
180
+ if (timeout) clearTimeout(timeout);
181
+ if (heartbeat) clearInterval(heartbeat);
182
+ }
183
+ });
184
+ }
185
+ /**
186
+ * Doc-level stream. Holds open, waits for a `createJob` matching
187
+ * (slug, docId), then transitions into the regular per-job streaming
188
+ * pattern in-place. Closes after `DOC_STREAM_TTL_MS` so the connection
189
+ * doesn't pile up forever on idle editors.
190
+ *
191
+ * This is the architectural piece that drops on-publish bar-appearance
192
+ * latency from 9-11s to ~50ms. Polling can't beat this — there's no
193
+ * client-side timer fast enough to race the server.
194
+ */ function buildDocStream(slug, documentId, reqSignal) {
195
+ let unsubscribeDoc;
196
+ let unsubscribeJob;
197
+ let timeout;
198
+ let heartbeat;
199
+ return new ReadableStream({
200
+ start (controller) {
201
+ const encoder = new TextEncoder();
202
+ controller.enqueue(encoder.encode('retry: 30000\n\n'));
203
+ // Send an initial idle event so the client knows the connection is
204
+ // healthy and waiting. Without this, browsers may consider a
205
+ // never-emitting stream "stalled".
206
+ controller.enqueue(encoder.encode(`event: progress\ndata: ${JSON.stringify({
207
+ jobId: '',
208
+ completed: [],
209
+ failed: [],
210
+ total: 0,
211
+ status: 'idle'
212
+ })}\n\n`));
213
+ const tryClose = ()=>{
214
+ try {
215
+ controller.close();
216
+ } catch {
217
+ // already closed
218
+ }
219
+ };
220
+ const cleanup = ()=>{
221
+ if (unsubscribeDoc) {
222
+ unsubscribeDoc();
223
+ unsubscribeDoc = undefined;
224
+ }
225
+ if (unsubscribeJob) {
226
+ unsubscribeJob();
227
+ unsubscribeJob = undefined;
228
+ }
229
+ if (timeout) {
230
+ clearTimeout(timeout);
231
+ timeout = undefined;
232
+ }
233
+ if (heartbeat) {
234
+ clearInterval(heartbeat);
235
+ heartbeat = undefined;
236
+ }
237
+ };
238
+ const send = (event)=>{
239
+ const eventType = event.status === 'running' ? 'progress' : 'complete';
240
+ try {
241
+ controller.enqueue(encoder.encode(`event: ${eventType}\ndata: ${JSON.stringify(event)}\n\n`));
242
+ } catch {
243
+ return;
244
+ }
245
+ if (event.status !== 'running') {
246
+ cleanup();
247
+ setTimeout(tryClose, 2000);
248
+ }
249
+ };
250
+ // Subscribe FIRST, then re-check for an existing job. This order
251
+ // closes a tiny race window: between the outer handler's
252
+ // `getActiveJobForDoc` (where we decided to take the doc-stream
253
+ // branch) and this point, an after-change hook from a concurrent
254
+ // request could have fired `createJob`. Subscribing first
255
+ // guarantees we'll receive any event that fires from now on; the
256
+ // re-check handles the case where the job already exists.
257
+ unsubscribeDoc = subscribeToDoc(slug, documentId, (initial)=>{
258
+ send(initial);
259
+ if (initial.status === 'running' && !unsubscribeJob) {
260
+ unsubscribeJob = subscribe(initial.jobId, send);
261
+ }
262
+ // If status was already terminal at first event (rare), `send`
263
+ // already triggered cleanup + close above.
264
+ });
265
+ const raceCheck = getActiveJobForDoc(slug, documentId);
266
+ if (raceCheck) {
267
+ send(jobToEvent(raceCheck));
268
+ if (raceCheck.status === 'running' && !unsubscribeJob) {
269
+ unsubscribeJob = subscribe(raceCheck.jobId, send);
270
+ }
271
+ }
272
+ heartbeat = setInterval(()=>{
273
+ try {
274
+ controller.enqueue(encoder.encode(':hb\n\n'));
275
+ } catch {
276
+ cleanup();
277
+ }
278
+ }, HEARTBEAT_MS);
279
+ timeout = setTimeout(()=>{
280
+ cleanup();
281
+ tryClose();
282
+ }, DOC_STREAM_TTL_MS);
283
+ reqSignal?.addEventListener('abort', ()=>{
284
+ cleanup();
285
+ tryClose();
286
+ });
287
+ },
288
+ cancel () {
289
+ if (unsubscribeDoc) unsubscribeDoc();
290
+ if (unsubscribeJob) unsubscribeJob();
291
+ if (timeout) clearTimeout(timeout);
292
+ if (heartbeat) clearInterval(heartbeat);
293
+ }
294
+ });
295
+ }
296
+ function resolveSlugFromUrl(url) {
297
+ try {
298
+ const parsed = new URL(url);
299
+ const segments = parsed.pathname.split('/').filter(Boolean);
300
+ const apiIndex = segments.indexOf('api');
301
+ if (apiIndex < 0) return '';
302
+ // Globals pattern: /api/globals/{slug}/ai-translate/progress
303
+ if (segments[apiIndex + 1] === 'globals' && segments[apiIndex + 2]) {
304
+ return segments[apiIndex + 2];
305
+ }
306
+ // Collections pattern: /api/{slug}/ai-translate/progress
307
+ if (segments[apiIndex + 1]) {
308
+ return segments[apiIndex + 1];
309
+ }
310
+ } catch {
311
+ // fall through
312
+ }
313
+ return '';
314
+ }
@@ -0,0 +1,11 @@
1
+ import type { PayloadHandler } from 'payload';
2
+ export type HandlerContext = {
3
+ kind: 'collection';
4
+ slug: string;
5
+ } | {
6
+ kind: 'global';
7
+ slug: string;
8
+ };
9
+ type HandlerArg = HandlerContext | string | undefined;
10
+ export declare const getTranslateHandler: (arg?: HandlerArg) => PayloadHandler;
11
+ export {};