@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,293 @@
1
+ import { deserializeLexicalTree } from '../lexical/deserializer.js';
2
+ import { getValueAtPath } from './content-extractor.js';
3
+ // ---------------------------------------------------------------------------
4
+ // Public API
5
+ // ---------------------------------------------------------------------------
6
+ /**
7
+ * @param base Merge base — target-locale-first state that will be mutated
8
+ * (via structured clone) and returned with translated leaves written in.
9
+ * This was previously misleadingly called `sourceDoc`; it's the BASE that
10
+ * accumulates translated text on top of prior target-locale state.
11
+ * @param translatedUnits Map of unitId → translated text.
12
+ * @param fields Translatable field descriptors.
13
+ * @param sourceDoc The TRUE source-locale doc. Read-only. Used to recover
14
+ * structural skeletons (e.g. Lexical trees) when the base's target-locale
15
+ * row is empty or otherwise missing the source shape — without this the
16
+ * deserializer walks an empty/stale skeleton and discards source structure.
17
+ */ export function patchTranslatedContent(base, translatedUnits, fields, sourceDoc) {
18
+ const patched = structuredClone(base);
19
+ for (const field of fields){
20
+ switch(field.type){
21
+ case 'text':
22
+ case 'textarea':
23
+ {
24
+ const unitId = `${field.path}::0`;
25
+ const translated = translatedUnits.get(unitId);
26
+ if (translated !== undefined) {
27
+ setValueAtPath(patched, field.path, translated);
28
+ }
29
+ break;
30
+ }
31
+ case 'richText':
32
+ {
33
+ // Structure comes from the TRUE source, not the merge base. The base
34
+ // for a fresh target locale is often an empty-Lexical skeleton from
35
+ // findByIdNoFallback (Payload returns the default editor state), and
36
+ // walking that would discard source headings, lists, links, formatting.
37
+ const originalValue = getValueAtPath(sourceDoc, field.path);
38
+ if (!isLexicalRoot(originalValue)) break;
39
+ // Collect all block units belonging to this field
40
+ const blockMap = collectFieldUnits(field.path, translatedUnits);
41
+ if (blockMap.size === 0) break;
42
+ const rebuilt = deserializeLexicalTree(originalValue, blockMap);
43
+ setValueAtPath(patched, field.path, rebuilt);
44
+ break;
45
+ }
46
+ case 'json':
47
+ {
48
+ // Seed from the TRUE source when available — when the source's
49
+ // JSON shape changes between publishes (different top-level
50
+ // keys, new nesting), reading from `patched` walks the OLD
51
+ // target shape and translations land nowhere. Falls back to
52
+ // `patched` when sourceDoc doesn't expose the path (legacy
53
+ // callers, tests, or paths the caller composes manually) so the
54
+ // previous behavior is preserved.
55
+ const seedFromSource = getValueAtPath(sourceDoc, field.path);
56
+ const seed = seedFromSource !== undefined && seedFromSource !== null ? seedFromSource : getValueAtPath(patched, field.path);
57
+ if (seed === null || typeof seed !== 'object' || Array.isArray(seed)) break;
58
+ const jsonClone = structuredClone(seed);
59
+ patchJsonValues(jsonClone, field.path, translatedUnits);
60
+ setValueAtPath(patched, field.path, jsonClone);
61
+ break;
62
+ }
63
+ case 'group':
64
+ {
65
+ const value = getValueAtPath(patched, field.path);
66
+ if (!value || typeof value !== 'object' || Array.isArray(value)) break;
67
+ patchGroupLeaves(value, field.path, fields, translatedUnits, sourceDoc);
68
+ break;
69
+ }
70
+ case 'array':
71
+ {
72
+ const value = getValueAtPath(patched, field.path);
73
+ if (!Array.isArray(value)) break;
74
+ patchArrayItems(value, field.path, fields, translatedUnits, sourceDoc);
75
+ break;
76
+ }
77
+ case 'blocks':
78
+ {
79
+ const value = getValueAtPath(patched, field.path);
80
+ if (!Array.isArray(value)) break;
81
+ patchBlockItems(value, field.path, translatedUnits, sourceDoc);
82
+ break;
83
+ }
84
+ }
85
+ }
86
+ return patched;
87
+ }
88
+ // ---------------------------------------------------------------------------
89
+ // JSON patcher
90
+ // ---------------------------------------------------------------------------
91
+ function patchJsonValues(obj, basePath, translatedUnits) {
92
+ for (const [key, value] of Object.entries(obj)){
93
+ const fieldPath = `${basePath}.${key}`;
94
+ const unitId = `${fieldPath}::0`;
95
+ const translated = translatedUnits.get(unitId);
96
+ if (translated !== undefined) {
97
+ obj[key] = translated;
98
+ } else if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
99
+ patchJsonValues(value, fieldPath, translatedUnits);
100
+ }
101
+ }
102
+ }
103
+ // ---------------------------------------------------------------------------
104
+ // Container patchers
105
+ // ---------------------------------------------------------------------------
106
+ function patchGroupLeaves(groupValue, groupPath, allFields, translatedUnits, sourceDoc) {
107
+ const childLeaves = allFields.filter((f)=>f.path.startsWith(`${groupPath}.`) && (f.type === 'text' || f.type === 'textarea' || f.type === 'richText' || f.type === 'json'));
108
+ for (const leaf of childLeaves){
109
+ const relativePath = leaf.path.slice(groupPath.length + 1);
110
+ if (leaf.type === 'text' || leaf.type === 'textarea') {
111
+ const unitId = `${leaf.path}::0`;
112
+ const translated = translatedUnits.get(unitId);
113
+ if (translated !== undefined) {
114
+ setValueAtPath(groupValue, relativePath, translated);
115
+ }
116
+ } else if (leaf.type === 'richText') {
117
+ const originalValue = getValueAtPath(sourceDoc, leaf.path);
118
+ if (!isLexicalRoot(originalValue)) continue;
119
+ const blockMap = collectFieldUnits(leaf.path, translatedUnits);
120
+ if (blockMap.size === 0) continue;
121
+ const rebuilt = deserializeLexicalTree(originalValue, blockMap);
122
+ setValueAtPath(groupValue, relativePath, rebuilt);
123
+ } else if (leaf.type === 'json') {
124
+ // Group-nested JSON: seed from source when available, fall back to
125
+ // the current target value otherwise. Same reasoning as the
126
+ // top-level `case 'json'` — source-first propagates schema
127
+ // changes; target-fallback keeps the previous behavior for paths
128
+ // sourceDoc doesn't expose.
129
+ const seedFromSource = getValueAtPath(sourceDoc, leaf.path);
130
+ const seed = seedFromSource !== undefined && seedFromSource !== null ? seedFromSource : getValueAtPath(groupValue, relativePath);
131
+ if (seed === null || typeof seed !== 'object' || Array.isArray(seed)) continue;
132
+ const jsonClone = structuredClone(seed);
133
+ patchJsonValues(jsonClone, leaf.path, translatedUnits);
134
+ setValueAtPath(groupValue, relativePath, jsonClone);
135
+ }
136
+ }
137
+ }
138
+ function patchArrayItems(items, arrayPath, allFields, translatedUnits, sourceDoc) {
139
+ const childLeaves = allFields.filter((f)=>f.path.startsWith(`${arrayPath}.`) && (f.type === 'text' || f.type === 'textarea' || f.type === 'richText') && !pathTraversesNestedArray(f.path, arrayPath, allFields));
140
+ const nestedContainers = allFields.filter((f)=>f.path.startsWith(`${arrayPath}.`) && (f.type === 'array' || f.type === 'blocks') && !pathTraversesNestedArray(f.path, arrayPath, allFields));
141
+ for(let i = 0; i < items.length; i++){
142
+ const item = items[i];
143
+ if (!item || typeof item !== 'object') continue;
144
+ for (const leaf of childLeaves){
145
+ const relativePath = leaf.path.slice(arrayPath.length + 1);
146
+ const indexedPath = `${arrayPath}.${i}.${relativePath}`;
147
+ if (leaf.type === 'text' || leaf.type === 'textarea') {
148
+ const unitId = `${indexedPath}::0`;
149
+ const translated = translatedUnits.get(unitId);
150
+ if (translated !== undefined) {
151
+ setValueAtPath(item, relativePath, translated);
152
+ }
153
+ } else if (leaf.type === 'richText') {
154
+ const originalValue = getValueAtPath(sourceDoc, indexedPath);
155
+ if (!isLexicalRoot(originalValue)) continue;
156
+ const blockMap = collectFieldUnits(indexedPath, translatedUnits);
157
+ if (blockMap.size === 0) continue;
158
+ const rebuilt = deserializeLexicalTree(originalValue, blockMap);
159
+ setValueAtPath(item, relativePath, rebuilt);
160
+ }
161
+ }
162
+ for (const container of nestedContainers){
163
+ const relativePath = container.path.slice(arrayPath.length + 1);
164
+ const indexedContainerPath = `${arrayPath}.${i}.${relativePath}`;
165
+ const nestedValue = getValueAtPath(item, relativePath);
166
+ if (!Array.isArray(nestedValue)) continue;
167
+ const mappedFields = remapFieldsUnderPath(allFields, container.path, indexedContainerPath);
168
+ if (container.type === 'array') {
169
+ patchArrayItems(nestedValue, indexedContainerPath, mappedFields, translatedUnits, sourceDoc);
170
+ } else {
171
+ patchBlockItems(nestedValue, indexedContainerPath, translatedUnits, sourceDoc);
172
+ }
173
+ }
174
+ }
175
+ }
176
+ function pathTraversesNestedArray(path, containerPath, allFields) {
177
+ for (const f of allFields){
178
+ if (f.type !== 'array' && f.type !== 'blocks') continue;
179
+ if (f.path === containerPath) continue;
180
+ if (!f.path.startsWith(`${containerPath}.`)) continue;
181
+ if (path.startsWith(`${f.path}.`)) return true;
182
+ }
183
+ return false;
184
+ }
185
+ function remapFieldsUnderPath(allFields, fromPath, toPath) {
186
+ return allFields.filter((f)=>f.path === fromPath || f.path.startsWith(`${fromPath}.`)).map((f)=>({
187
+ ...f,
188
+ path: toPath + f.path.slice(fromPath.length)
189
+ }));
190
+ }
191
+ function patchBlockItems(blocks, blocksPath, translatedUnits, sourceDoc) {
192
+ for(let i = 0; i < blocks.length; i++){
193
+ const block = blocks[i];
194
+ if (!block || typeof block !== 'object') continue;
195
+ const prefix = `${blocksPath}.${i}`;
196
+ patchBlockFields(block, prefix, translatedUnits, sourceDoc);
197
+ }
198
+ }
199
+ function patchBlockFields(obj, pathPrefix, translatedUnits, sourceDoc) {
200
+ for (const [key, value] of Object.entries(obj)){
201
+ if (key === 'id' || key === 'blockType' || key === 'blockName') continue;
202
+ const fieldPath = `${pathPrefix}.${key}`;
203
+ if (typeof value === 'string') {
204
+ const unitId = `${fieldPath}::0`;
205
+ const translated = translatedUnits.get(unitId);
206
+ if (translated !== undefined) {
207
+ obj[key] = translated;
208
+ }
209
+ } else if (isLexicalRoot(value)) {
210
+ const originalValue = getValueAtPath(sourceDoc, fieldPath);
211
+ if (!isLexicalRoot(originalValue)) continue;
212
+ const blockMap = collectFieldUnits(fieldPath, translatedUnits);
213
+ if (blockMap.size === 0) continue;
214
+ obj[key] = deserializeLexicalTree(originalValue, blockMap);
215
+ } else if (Array.isArray(value)) {
216
+ for(let i = 0; i < value.length; i++){
217
+ const item = value[i];
218
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
219
+ patchBlockFields(item, `${fieldPath}.${i}`, translatedUnits, sourceDoc);
220
+ } else if (typeof item === 'string' && item.trim().length > 0) {
221
+ // Mirror of extractor: strings inside arrays (e.g. JSON `tags`)
222
+ // are translated at indexed paths.
223
+ const translated = translatedUnits.get(`${fieldPath}.${i}::0`);
224
+ if (translated !== undefined) {
225
+ value[i] = translated;
226
+ }
227
+ }
228
+ }
229
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
230
+ patchBlockFields(value, fieldPath, translatedUnits, sourceDoc);
231
+ }
232
+ }
233
+ }
234
+ // ---------------------------------------------------------------------------
235
+ // Path utilities
236
+ // ---------------------------------------------------------------------------
237
+ export function setValueAtPath(obj, path, value) {
238
+ const segments = path.split('.');
239
+ let current = obj;
240
+ for(let i = 0; i < segments.length - 1; i++){
241
+ const segment = segments[i];
242
+ const nextSegment = segments[i + 1];
243
+ const isNextIndex = /^\d+$/.test(nextSegment);
244
+ if (current[segment] === undefined || current[segment] === null) {
245
+ current[segment] = isNextIndex ? [] : {};
246
+ }
247
+ const next = current[segment];
248
+ if (Array.isArray(next)) {
249
+ const index = Number(nextSegment);
250
+ if (Number.isNaN(index)) return;
251
+ // Ensure the array is large enough
252
+ while(next.length <= index){
253
+ next.push(undefined);
254
+ }
255
+ if (i + 1 === segments.length - 2) {
256
+ // Next iteration will be the final segment set
257
+ }
258
+ if (next[index] === undefined || next[index] === null) {
259
+ const afterNext = segments[i + 2];
260
+ next[index] = afterNext && /^\d+$/.test(afterNext) ? [] : {};
261
+ }
262
+ current = next[index];
263
+ i++; // skip the index segment
264
+ } else if (typeof next === 'object') {
265
+ current = next;
266
+ } else {
267
+ // Can't traverse further
268
+ return;
269
+ }
270
+ }
271
+ const lastSegment = segments[segments.length - 1];
272
+ current[lastSegment] = value;
273
+ }
274
+ // ---------------------------------------------------------------------------
275
+ // Helpers
276
+ // ---------------------------------------------------------------------------
277
+ function collectFieldUnits(fieldPath, translatedUnits) {
278
+ const prefix = `${fieldPath}::`;
279
+ const blockMap = new Map();
280
+ for (const [id, text] of translatedUnits){
281
+ if (id.startsWith(prefix)) {
282
+ // Extract the blockId part after the field path prefix
283
+ const blockId = id.slice(prefix.length);
284
+ blockMap.set(blockId, text);
285
+ }
286
+ }
287
+ return blockMap;
288
+ }
289
+ function isLexicalRoot(value) {
290
+ if (value === null || typeof value !== 'object') return false;
291
+ const obj = value;
292
+ return 'root' in obj && obj.root !== null && typeof obj.root === 'object';
293
+ }
@@ -0,0 +1,2 @@
1
+ export declare function checkPerCallLimit(chars: number, limit: number): void;
2
+ export declare function checkPerDocLimit(totalChars: number, ceiling: number): void;
@@ -0,0 +1,18 @@
1
+ export function checkPerCallLimit(chars, limit) {
2
+ if (chars > limit) {
3
+ const err = new Error(`Per-call character limit exceeded: ${chars} > ${limit}`);
4
+ err.code = 'PER_CALL_LIMIT';
5
+ err.characterCount = chars;
6
+ err.limit = limit;
7
+ throw err;
8
+ }
9
+ }
10
+ export function checkPerDocLimit(totalChars, ceiling) {
11
+ if (totalChars > ceiling) {
12
+ const err = new Error(`Per-document character ceiling exceeded: ${totalChars} > ${ceiling}`);
13
+ err.code = 'PER_DOC_CEILING';
14
+ err.characterCount = totalChars;
15
+ err.limit = ceiling;
16
+ throw err;
17
+ }
18
+ }
@@ -0,0 +1,58 @@
1
+ import type { Payload } from 'payload';
2
+ export type DailySpendCapResult = {
3
+ allowed: true;
4
+ todaySpentUsd: number;
5
+ remainingUsd: number;
6
+ capUsd: number;
7
+ } | {
8
+ allowed: false;
9
+ reason: 'cap_exceeded' | 'invalid_estimate';
10
+ todaySpentUsd: number;
11
+ remainingUsd: number;
12
+ capUsd: number;
13
+ message: string;
14
+ };
15
+ export interface DailySpendCapOptions {
16
+ /**
17
+ * Override the env-var cap. Used by the bulk plugin config so
18
+ * consumers can pin a different cap without touching env. If both
19
+ * env and option are set, the option wins.
20
+ */
21
+ capUsd?: number;
22
+ /**
23
+ * Distinguishes spend rows when multiple plugin consumers share a
24
+ * single database (rare). Defaults to `'default'`.
25
+ */
26
+ consumerKey?: string;
27
+ /**
28
+ * Override slug if the consumer renamed the collection.
29
+ */
30
+ collectionSlug?: string;
31
+ /**
32
+ * Override the date (UTC ISO YYYY-MM-DD) for testing. Production
33
+ * code should leave this undefined to use today's date.
34
+ */
35
+ dateOverride?: string;
36
+ }
37
+ /**
38
+ * Check whether `estimatedCostUsd` would push today's spend past the
39
+ * configured cap. On `allowed`, atomically increments the day's
40
+ * running total. On `!allowed`, leaves state unchanged.
41
+ *
42
+ * Idempotency note: this is NOT idempotent. Callers must call exactly
43
+ * once per request that will actually fire a translation. The bulk
44
+ * endpoint calls it at enqueue time before queueing tasks; the per-
45
+ * doc retry endpoint calls it at HTTP entry. The plugin coalesce
46
+ * path calls it at hook entry before dispatching to the LLM.
47
+ */
48
+ export declare function checkAndIncrementDailySpend(payload: Payload, estimatedCostUsd: number | undefined, opt?: DailySpendCapOptions): Promise<DailySpendCapResult>;
49
+ /**
50
+ * Read-only inspector — exposed for the bulk pre-flight modal which
51
+ * needs to display remaining budget without incrementing. Does not
52
+ * touch storage.
53
+ */
54
+ export declare function getDailySpendStatus(payload: Payload, opt?: DailySpendCapOptions): Promise<{
55
+ todaySpentUsd: number;
56
+ remainingUsd: number;
57
+ capUsd: number;
58
+ }>;
@@ -0,0 +1,233 @@
1
+ import { DEFAULT_TRANSLATION_DAILY_SPEND_COLLECTION_SLUG } from '../translation-daily-spend-collection.js';
2
+ /**
3
+ * Daily USD cap utility for translation cost gating.
4
+ *
5
+ * Used by all three translate entry points (bulk-translate endpoint,
6
+ * per-doc retry endpoint, plugin coalesce path) per Decision #13 v2 +
7
+ * F-SEC-TOTP-BYPASS — the TOTP gate on bulk alone is cosmetic if the
8
+ * per-doc retry endpoint has no equivalent enforcement. The daily cap
9
+ * is the real boundary; TOTP becomes friction-not-boundary.
10
+ *
11
+ * Reject semantics:
12
+ * - `estimatedCostUsd` MUST be a finite number. `undefined` /
13
+ * non-finite values reject hard (Decision #29). This forces
14
+ * callers to fix the upstream estimate gap rather than silently
15
+ * bypassing the cap.
16
+ * - Today's spend (`spendUsd`) is summed across all entry points.
17
+ * The request's `estimatedCostUsd` is added to the running total;
18
+ * if `running + estimate > cap`, the request is rejected and the
19
+ * spend row is NOT incremented (the request never happens).
20
+ * - Successful checks increment `spendUsd += estimatedCostUsd` and
21
+ * `requestCount += 1` atomically via raw SQL upsert.
22
+ *
23
+ * The cap value comes from the `BULK_TRANSLATE_DAILY_USD_CAP` env var
24
+ * (consumer override). Snapshotted into the row at first write of the
25
+ * day so mid-day env changes don't retroactively shift the limit.
26
+ */ const DEFAULT_CAP_USD = 50;
27
+ function resolveCap(opt) {
28
+ if (typeof opt.capUsd === 'number' && Number.isFinite(opt.capUsd)) {
29
+ return opt.capUsd;
30
+ }
31
+ const envRaw = process.env.BULK_TRANSLATE_DAILY_USD_CAP;
32
+ if (envRaw) {
33
+ const parsed = Number.parseFloat(envRaw);
34
+ if (Number.isFinite(parsed) && parsed >= 0) {
35
+ return parsed;
36
+ }
37
+ }
38
+ return DEFAULT_CAP_USD;
39
+ }
40
+ function todayUtcIso() {
41
+ // YYYY-MM-DD in UTC. Avoids time-zone drift across Vercel functions
42
+ // that may run in different regions.
43
+ return new Date().toISOString().slice(0, 10);
44
+ }
45
+ /**
46
+ * Check whether `estimatedCostUsd` would push today's spend past the
47
+ * configured cap. On `allowed`, atomically increments the day's
48
+ * running total. On `!allowed`, leaves state unchanged.
49
+ *
50
+ * Idempotency note: this is NOT idempotent. Callers must call exactly
51
+ * once per request that will actually fire a translation. The bulk
52
+ * endpoint calls it at enqueue time before queueing tasks; the per-
53
+ * doc retry endpoint calls it at HTTP entry. The plugin coalesce
54
+ * path calls it at hook entry before dispatching to the LLM.
55
+ */ export async function checkAndIncrementDailySpend(payload, estimatedCostUsd, opt = {}) {
56
+ const capUsd = resolveCap(opt);
57
+ const consumerKey = opt.consumerKey ?? 'default';
58
+ const date = opt.dateOverride ?? todayUtcIso();
59
+ const slug = opt.collectionSlug ?? DEFAULT_TRANSLATION_DAILY_SPEND_COLLECTION_SLUG;
60
+ if (typeof estimatedCostUsd !== 'number' || !Number.isFinite(estimatedCostUsd) || estimatedCostUsd < 0) {
61
+ return {
62
+ allowed: false,
63
+ reason: 'invalid_estimate',
64
+ todaySpentUsd: 0,
65
+ remainingUsd: capUsd,
66
+ capUsd,
67
+ message: 'Translation cost estimate is missing or non-numeric. Refusing to proceed without a valid estimate (Decision #29).'
68
+ };
69
+ }
70
+ // Read today's row. Best-effort — failure here doesn't block the
71
+ // translation. If the read fails we still try the upsert path below
72
+ // and let the DB error surface if storage is broken.
73
+ let todaySpent = 0;
74
+ try {
75
+ const result = await payload.find({
76
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
77
+ collection: slug,
78
+ where: {
79
+ and: [
80
+ {
81
+ date: {
82
+ equals: date
83
+ }
84
+ },
85
+ {
86
+ consumerKey: {
87
+ equals: consumerKey
88
+ }
89
+ }
90
+ ]
91
+ },
92
+ limit: 1,
93
+ overrideAccess: true
94
+ });
95
+ const existing = result.docs[0];
96
+ if (existing && typeof existing.spendUsd === 'number') {
97
+ todaySpent = existing.spendUsd;
98
+ }
99
+ } catch {
100
+ // Treat as cold day. The upsert below will create the row.
101
+ }
102
+ const projected = todaySpent + estimatedCostUsd;
103
+ if (projected > capUsd) {
104
+ return {
105
+ allowed: false,
106
+ reason: 'cap_exceeded',
107
+ todaySpentUsd: todaySpent,
108
+ remainingUsd: Math.max(0, capUsd - todaySpent),
109
+ capUsd,
110
+ message: `Daily translation cost cap exceeded: spent $${todaySpent.toFixed(4)}, request adds $${estimatedCostUsd.toFixed(4)}, cap $${capUsd.toFixed(2)}.`
111
+ };
112
+ }
113
+ // Atomic upsert. Uses Payload's `find` + `create` / `update`
114
+ // pattern as the portable fallback; consumers who want strict
115
+ // multi-process atomicity can replace this with a raw SQL
116
+ // `INSERT ... ON CONFLICT DO UPDATE` against the unique
117
+ // (date, consumer_key) constraint via `payload.db.drizzle`.
118
+ //
119
+ // The race window between read-then-write is bounded: the
120
+ // unique index on (date, consumer_key) ensures only one row
121
+ // per day, and concurrent increments race-update the same
122
+ // row's spendUsd — the second increment may briefly over-shoot
123
+ // the cap, but the cap re-checks on every call so the next
124
+ // request after the over-shoot will reject. Acceptable for a
125
+ // cost-cap gate; not acceptable as exact financial tracking.
126
+ try {
127
+ const result = await payload.find({
128
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
129
+ collection: slug,
130
+ where: {
131
+ and: [
132
+ {
133
+ date: {
134
+ equals: date
135
+ }
136
+ },
137
+ {
138
+ consumerKey: {
139
+ equals: consumerKey
140
+ }
141
+ }
142
+ ]
143
+ },
144
+ limit: 1,
145
+ overrideAccess: true
146
+ });
147
+ const existing = result.docs[0];
148
+ if (existing) {
149
+ await payload.update({
150
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
151
+ collection: slug,
152
+ id: existing.id,
153
+ data: {
154
+ spendUsd: (existing.spendUsd ?? 0) + estimatedCostUsd,
155
+ requestCount: (existing.requestCount ?? 0) + 1,
156
+ lastRequestAt: new Date().toISOString()
157
+ },
158
+ overrideAccess: true
159
+ });
160
+ } else {
161
+ await payload.create({
162
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
163
+ collection: slug,
164
+ data: {
165
+ date,
166
+ consumerKey,
167
+ spendUsd: estimatedCostUsd,
168
+ capUsd,
169
+ requestCount: 1,
170
+ lastRequestAt: new Date().toISOString()
171
+ },
172
+ overrideAccess: true
173
+ });
174
+ }
175
+ } catch (err) {
176
+ payload.logger?.error?.(`[ai-translate] daily-spend-cap upsert failed: ${err instanceof Error ? err.message : String(err)}`);
177
+ // Don't reject the request just because tracking failed —
178
+ // the gate already approved based on the read. Tracking is
179
+ // best-effort. The next request will re-read and may
180
+ // approve over-cap if this write was lost. Log loudly.
181
+ }
182
+ return {
183
+ allowed: true,
184
+ todaySpentUsd: projected,
185
+ remainingUsd: Math.max(0, capUsd - projected),
186
+ capUsd
187
+ };
188
+ }
189
+ /**
190
+ * Read-only inspector — exposed for the bulk pre-flight modal which
191
+ * needs to display remaining budget without incrementing. Does not
192
+ * touch storage.
193
+ */ export async function getDailySpendStatus(payload, opt = {}) {
194
+ const capUsd = resolveCap(opt);
195
+ const consumerKey = opt.consumerKey ?? 'default';
196
+ const date = opt.dateOverride ?? todayUtcIso();
197
+ const slug = opt.collectionSlug ?? DEFAULT_TRANSLATION_DAILY_SPEND_COLLECTION_SLUG;
198
+ try {
199
+ const result = await payload.find({
200
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic collection slug
201
+ collection: slug,
202
+ where: {
203
+ and: [
204
+ {
205
+ date: {
206
+ equals: date
207
+ }
208
+ },
209
+ {
210
+ consumerKey: {
211
+ equals: consumerKey
212
+ }
213
+ }
214
+ ]
215
+ },
216
+ limit: 1,
217
+ overrideAccess: true
218
+ });
219
+ const existing = result.docs[0];
220
+ const todaySpent = existing && typeof existing.spendUsd === 'number' ? existing.spendUsd : 0;
221
+ return {
222
+ todaySpentUsd: todaySpent,
223
+ remainingUsd: Math.max(0, capUsd - todaySpent),
224
+ capUsd
225
+ };
226
+ } catch {
227
+ return {
228
+ todaySpentUsd: 0,
229
+ remainingUsd: capUsd,
230
+ capUsd
231
+ };
232
+ }
233
+ }