@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,626 @@
1
+ /**
2
+ * Editor-facing error catalogue.
3
+ *
4
+ * Single source of truth for every error / soft-skip / alert / endpoint
5
+ * failure that surfaces to a content editor in the admin UI. Plugin
6
+ * internals throw / log machine codes; this file maps each code to
7
+ * plain-English copy keyed by `{ title, body, action?, explanation? }`.
8
+ *
9
+ * Why this exists: editors hit the Translation Hub, AlertBanner, doc-view
10
+ * Translate modal, etc. and used to see raw `err.message` text like
11
+ * "NoObjectGeneratedError: Response was not valid JSON" or internal codes
12
+ * like "Code: permanent.config". Engineers can debug those; editors can't.
13
+ * Every render site now routes through `editorMessageFor(code, ctx)` and
14
+ * shows the raw text only behind a "Show technical details" disclosure.
15
+ *
16
+ * Adding a new code: add the literal to `EditorErrorCode`, register a
17
+ * formatter in `MESSAGES`, set `isPermanentFailure` if applicable. Tests
18
+ * in `error-messages.test.ts` enforce that every code has a formatter.
19
+ */ // ---------------------------------------------------------------------------
20
+ // Code taxonomy
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Every code an editor can encounter in the UI. Grouped by source:
24
+ *
25
+ * - `bulk.*` — `FailureCode` from `tasks/bulk-translate-doc-task.ts`
26
+ * classifyError(). Persisted on `bulk_translate_units.failureCode`.
27
+ * - `soft-skip.*` — Validation outcome from `lib/output-validation.ts`
28
+ * when an LLM response is rejected without erroring. Persisted on
29
+ * `translation-usage.targetLocales[].softSkippedFields[].reasonCode`.
30
+ * - `cost-guard.*` — Throws from `lib/cost-guards.ts`. Persisted on
31
+ * `translation-alerts.metadata.code` when alert is `cost-guard-abort`.
32
+ * - `alert.*` — `translation-alerts.alertType`. The collection itself
33
+ * already enums these.
34
+ * - `endpoint.*` — Response.error.code from `endpoints/translation-hub/*`.
35
+ * - `marker.*` — Special-case `failureMessage` values written by admin
36
+ * actions (cancel / force-reset) that aren't real failures.
37
+ */ // ---------------------------------------------------------------------------
38
+ // Formatting helpers
39
+ // ---------------------------------------------------------------------------
40
+ const USD_FORMATTER = new Intl.NumberFormat('en-US', {
41
+ style: 'currency',
42
+ currency: 'USD',
43
+ minimumFractionDigits: 2,
44
+ maximumFractionDigits: 2
45
+ });
46
+ const NUMBER_FORMATTER = new Intl.NumberFormat('en-US');
47
+ function formatUsd(value) {
48
+ if (value == null || !Number.isFinite(value)) return '$0.00';
49
+ return USD_FORMATTER.format(value);
50
+ }
51
+ function formatNumber(value) {
52
+ if (value == null || !Number.isFinite(value)) return '0';
53
+ return NUMBER_FORMATTER.format(value);
54
+ }
55
+ /**
56
+ * Best-effort mapping from locale code to a human label. The plugin
57
+ * doesn't ship an exhaustive locale name table — for unknown codes
58
+ * we render the code wrapped (e.g. "the 'es-419' locale") so the
59
+ * meaning is still inferable.
60
+ */ function localeLabel(locale) {
61
+ if (!locale) return 'a target language';
62
+ const lower = locale.toLowerCase();
63
+ const known = {
64
+ en: 'English',
65
+ es: 'Spanish',
66
+ fr: 'French',
67
+ de: 'German',
68
+ it: 'Italian',
69
+ pt: 'Portuguese',
70
+ ru: 'Russian',
71
+ ja: 'Japanese',
72
+ zh: 'Chinese',
73
+ ko: 'Korean',
74
+ ar: 'Arabic',
75
+ nl: 'Dutch',
76
+ pl: 'Polish',
77
+ sv: 'Swedish',
78
+ tr: 'Turkish',
79
+ uk: 'Ukrainian',
80
+ cs: 'Czech',
81
+ ro: 'Romanian',
82
+ hu: 'Hungarian',
83
+ da: 'Danish',
84
+ fi: 'Finnish',
85
+ no: 'Norwegian'
86
+ };
87
+ return known[lower] ?? known[lower.split(/[-_]/)[0] ?? ''] ?? `the '${locale}' locale`;
88
+ }
89
+ const MESSAGES = {
90
+ // -------------------------------------------------------------------------
91
+ // Bulk worker failures — surface on bulk_translate_units rows
92
+ // -------------------------------------------------------------------------
93
+ 'bulk.transient.rate_limited': ()=>({
94
+ title: 'Translation service is busy',
95
+ body: "The translation service is handling too many requests right now. We're retrying automatically.",
96
+ action: 'Wait a few minutes, or contact engineering if it keeps failing.'
97
+ }),
98
+ 'bulk.transient.provider': ()=>({
99
+ title: 'Translation service temporarily down',
100
+ body: 'The translation service returned an error. This is usually temporary.',
101
+ action: "We're retrying automatically. If it keeps failing, contact engineering."
102
+ }),
103
+ 'bulk.transient.crashed': ()=>({
104
+ title: 'Translation stopped mid-run',
105
+ body: 'The translation worker stopped before finishing this document — usually because of a server restart.',
106
+ action: 'Click Retry to translate it again.'
107
+ }),
108
+ 'bulk.permanent.schema': ()=>({
109
+ // 2026-06-05: copy revised. Old copy ("AI response was unreadable /
110
+ // Contact engineering — this needs a configuration fix") pointed
111
+ // editors at the wrong remedy. This bucket actually catches two
112
+ // distinct LLM-output-quality outcomes: (1) the response wasn't
113
+ // valid structured output (parser couldn't read it), or (2) the
114
+ // validator rejected the translation per-field (e.g. ratio too
115
+ // short for dense target locales like zh/ja/ko on long English
116
+ // sources). Neither is a "configuration fix" — both benefit from
117
+ // a retry on a more capable model, OR are legitimate edge cases
118
+ // the editor needs to translate manually.
119
+ title: 'Some fields couldn’t be auto-translated',
120
+ body: 'The AI’s response for these fields didn’t meet our quality checks — usually the output was cut off, much shorter than the source, or returned in an unexpected format. The fields are listed below.',
121
+ action: 'Open the document on this locale to translate the listed fields manually, or click Retry to try again (often works on the second pass, especially with a different model).',
122
+ explanation: 'This usually happens when the AI hits its output limit or returns content too short to match the source. Common for very dense target languages (Chinese, Japanese, Korean) on long English passages. Retrying often works; if it persistently fails on the same fields, edit them manually.'
123
+ }),
124
+ 'bulk.permanent.too_large': ()=>({
125
+ title: 'Document exceeds size limit',
126
+ body: 'This document is too large to translate in one pass.',
127
+ action: 'Split it into shorter sections, or ask engineering to raise the limit.',
128
+ explanation: "Translation requests are capped at a per-document character limit to keep costs predictable. Documents above the cap won't fit in a single AI call. Splitting into smaller sections is the quickest fix; engineering can also raise the cap if needed."
129
+ }),
130
+ 'bulk.permanent.config': ()=>({
131
+ title: 'Translation system is not set up',
132
+ body: 'The translation system rejected this document due to a configuration issue — missing authentication, invalid AI model, or unsupported content type.',
133
+ action: 'Contact engineering to fix the settings.',
134
+ explanation: "Configuration problems won't resolve on retry. Engineering needs to check the authentication keys, allowed content types, and AI model settings."
135
+ }),
136
+ 'bulk.permanent.deleted': ()=>({
137
+ title: 'Document was deleted',
138
+ body: 'This document no longer exists — it was deleted after the run started.',
139
+ action: 'The run continues with remaining documents.',
140
+ explanation: "The document was removed between the time we started the run and when we tried to translate it. There's nothing left to translate."
141
+ }),
142
+ 'bulk.unknown': ()=>({
143
+ title: 'Translation failed unexpectedly',
144
+ body: "Something went wrong, but we couldn't identify what.",
145
+ action: 'Try again. If it persists, contact engineering with the document ID.'
146
+ }),
147
+ // -------------------------------------------------------------------------
148
+ // Soft-skips — surfaced in the UsageTable expanded row
149
+ // -------------------------------------------------------------------------
150
+ 'soft-skip.echoed': (ctx)=>({
151
+ title: "AI didn't translate this field",
152
+ body: `The AI returned the source text unchanged for ${localeLabel(ctx.locale)} — it may be a brand name, URL, or term that shouldn't be translated.`,
153
+ action: 'Open the document and edit the field manually if needed.'
154
+ }),
155
+ 'soft-skip.too-short': (ctx)=>({
156
+ title: 'Translation was too short',
157
+ body: 'The translation is much shorter than the source — usually because the AI cut off its output. We kept the source text instead of saving a partial translation.',
158
+ action: 'Open the document and translate the field manually, or try again.'
159
+ }),
160
+ 'soft-skip.too-long': ()=>({
161
+ title: 'Translation was too long',
162
+ body: 'The translation is much longer than the source — usually because the AI repeated itself or added extra content. We kept the source text instead.',
163
+ action: 'Open the document and translate the field manually, or try again.'
164
+ }),
165
+ 'soft-skip.placeholders': (ctx)=>{
166
+ const missing = ctx.missingPlaceholders?.length ?? 0;
167
+ const extra = ctx.extraPlaceholders?.length ?? 0;
168
+ const issues = [];
169
+ if (missing) issues.push(`lost ${missing} formatting tag${missing === 1 ? '' : 's'}`);
170
+ if (extra) issues.push(`added ${extra} extra tag${extra === 1 ? '' : 's'}`);
171
+ const detail = issues.join(' and ');
172
+ return {
173
+ title: 'Translation broke formatting',
174
+ body: `The translation ${detail || 'changed formatting tags'} (bold, links, italics, etc.). We kept the source text to protect the formatting.`,
175
+ action: 'Open the document and translate the field manually, or try again.'
176
+ };
177
+ },
178
+ 'soft-skip.refusal': ()=>({
179
+ title: 'AI refused to translate',
180
+ body: 'The AI returned a refusal instead of a translation. We kept the source text.',
181
+ action: 'Try a different model, or translate the field manually.'
182
+ }),
183
+ 'soft-skip.injection': ()=>({
184
+ title: 'Translation contained suspicious code',
185
+ body: "The AI's response included tags that looked like code or system instructions. We kept the source text as a safety precaution.",
186
+ action: 'Try again, or contact engineering if it persists.'
187
+ }),
188
+ 'soft-skip.control-chars': ()=>({
189
+ title: 'Translation had invalid characters',
190
+ body: "The AI's response included characters we can't store safely. We kept the source text.",
191
+ action: 'Try again.'
192
+ }),
193
+ // -------------------------------------------------------------------------
194
+ // Cost guards — surface in AlertBanner + Preflight modal
195
+ // -------------------------------------------------------------------------
196
+ 'cost-guard.per-call': (ctx)=>({
197
+ title: 'Section is too large to translate',
198
+ body: `This section is ${formatNumber(ctx.chars)} characters, above the per-translation limit of ${formatNumber(ctx.limit)}.`,
199
+ action: 'Split it into smaller sections, or ask engineering to raise the limit.',
200
+ explanation: "Translation requests are limited to keep AI costs predictable. Sections above the limit can't be sent in one piece. Splitting the content is the quickest fix; engineering can also raise the limit if needed."
201
+ }),
202
+ 'cost-guard.per-doc': (ctx)=>({
203
+ title: 'Document exceeds the size limit',
204
+ body: `This document is ${formatNumber(ctx.chars)} characters, above the limit of ${formatNumber(ctx.limit)}.`,
205
+ action: 'Split it into shorter sections, or ask engineering to raise the limit.',
206
+ explanation: "Documents are capped at a maximum size to keep translation costs predictable. Documents above the cap can't be translated as a whole. Engineering can raise the cap if needed."
207
+ }),
208
+ 'cost-guard.daily-cap': (ctx)=>({
209
+ title: 'Daily budget reached',
210
+ body: `You've spent ${formatUsd(ctx.spent)} of the ${formatUsd(ctx.cap)} daily limit.`,
211
+ action: 'The budget resets at midnight UTC. Ask engineering to raise the limit if you need to translate sooner.',
212
+ explanation: 'Translation is capped at a daily budget to prevent runaway costs. New translations are blocked once the cap is reached until the next UTC day. Engineering can increase the cap if needed.'
213
+ }),
214
+ // -------------------------------------------------------------------------
215
+ // Alert types — surface in AlertBanner
216
+ // -------------------------------------------------------------------------
217
+ 'alert.persistent-failure': (ctx)=>{
218
+ // HUB-2 / NEW-13 (v1.2.6): standardised on "attempts" — matches the
219
+ // recorded `attempts` column on `bulk_translate_units` and the
220
+ // BucketRow "Attempts" label. `ctx.maxAttempts` is sourced from
221
+ // `retryConfig.attempts` (DEFAULT_RETRY.attempts in defaults.ts), so
222
+ // the number reflects the real configured retry budget instead of a
223
+ // hard-coded "3" fallback that drifted from the default of 2.
224
+ const attempts = ctx.maxAttempts ?? 2;
225
+ return {
226
+ title: 'Translation failed repeatedly',
227
+ body: `${formatNumber(ctx.count)} field${ctx.count === 1 ? '' : 's'} couldn't be translated to ${localeLabel(ctx.locale)} after ${formatNumber(attempts)} attempt${attempts === 1 ? '' : 's'}.`,
228
+ action: 'Open the document and review the failed fields. Contact engineering if the problem persists.'
229
+ };
230
+ },
231
+ 'alert.cost-guard-abort': ()=>({
232
+ title: 'Translation stopped — document too large',
233
+ body: 'We stopped the translation because the document exceeded the size limit. No partial translation was saved.',
234
+ action: 'See the alert details for the specific document. Split it into shorter pieces or ask engineering to raise the limit.'
235
+ }),
236
+ 'alert.provider-outage': ()=>({
237
+ title: 'Translation service is down',
238
+ body: 'The translation service has been experiencing errors. New translations may fail until it recovers.',
239
+ action: 'Try again later. Engineering is aware.'
240
+ }),
241
+ // -------------------------------------------------------------------------
242
+ // Hub endpoint error codes
243
+ // -------------------------------------------------------------------------
244
+ 'endpoint.invalid_state': (ctx)=>{
245
+ const status = ctx.status ?? 'unknown';
246
+ if (status === 'running' || status === 'pending') {
247
+ return {
248
+ title: 'Run is still in progress',
249
+ body: "You can't do this while the translation run is in progress.",
250
+ action: 'Wait for it to finish, or cancel it first.'
251
+ };
252
+ }
253
+ if (status === 'cancelling') {
254
+ return {
255
+ title: 'Run is already being cancelled',
256
+ body: 'A cancellation is in progress — no need to cancel again.',
257
+ action: "Refresh the page if the status doesn't update in a few seconds."
258
+ };
259
+ }
260
+ return {
261
+ title: 'Run has already ended',
262
+ body: 'This run is finished.',
263
+ action: 'Refresh the page to see the latest status.'
264
+ };
265
+ },
266
+ 'endpoint.not_found': ()=>({
267
+ title: "Run doesn't exist",
268
+ body: "This translation run can't be found.",
269
+ action: 'Refresh the page.'
270
+ }),
271
+ 'endpoint.totp_invalid': ()=>({
272
+ title: 'Authentication code was incorrect',
273
+ body: "The 6-digit code from your authenticator app didn't match.",
274
+ action: 'Check your authenticator app and try again.'
275
+ }),
276
+ 'endpoint.totp_missing': ()=>({
277
+ title: 'Authentication code required',
278
+ body: 'This action needs a 6-digit code from your authenticator app.',
279
+ action: 'Enter the code shown in your authenticator app.'
280
+ }),
281
+ 'endpoint.totp_plugin_unavailable': ()=>({
282
+ title: 'Two-factor authentication not set up',
283
+ body: "This action requires two-factor authentication, but it hasn't been enabled on your account.",
284
+ action: 'Go to Account → Security and enrol your authenticator app first.'
285
+ }),
286
+ 'endpoint.daily_cap_exceeded': (ctx)=>({
287
+ title: 'Daily budget exceeded',
288
+ body: `You've spent ${formatUsd(ctx.spent)} of the ${formatUsd(ctx.cap)} daily limit${ctx.delta != null ? `. This would add about ${formatUsd(ctx.delta)}` : ''}.`,
289
+ action: 'The budget resets at midnight UTC. Ask engineering to raise the limit if you need to translate sooner.'
290
+ }),
291
+ 'endpoint.concurrent_batch': ()=>({
292
+ title: 'A translation run is already in progress',
293
+ body: 'Only one translation run can be active at a time.',
294
+ action: 'Cancel the current run before starting a new one.'
295
+ }),
296
+ 'endpoint.not_configured': ()=>({
297
+ title: 'Translation is not set up',
298
+ body: "Translation hasn't been configured for this site.",
299
+ action: 'Contact engineering to enable it.'
300
+ }),
301
+ 'endpoint.window_expired': (ctx)=>{
302
+ const elapsed = ctx.hoursElapsed != null ? `${formatNumber(ctx.hoursElapsed)}h` : 'too long';
303
+ const window = ctx.hoursWindow != null ? `${formatNumber(ctx.hoursWindow)}h` : 'the undo window';
304
+ return {
305
+ title: 'Undo window expired',
306
+ body: `The ${window} undo window closed (this run was ${elapsed} ago).`,
307
+ action: 'Contact engineering if you need to restore the previous version.'
308
+ };
309
+ },
310
+ 'endpoint.cost_unavailable': ()=>({
311
+ title: 'Cost estimate unavailable',
312
+ body: "We couldn't estimate the cost for this translation. For safety, the run is blocked.",
313
+ action: "Contact engineering — the AI model's pricing may need to be configured."
314
+ }),
315
+ 'endpoint.invalid_batch_id': ()=>({
316
+ title: 'Run ID is invalid',
317
+ body: 'The run ID in the link is malformed.',
318
+ action: 'Refresh the page or go back to the Translation Hub.'
319
+ }),
320
+ 'endpoint.invalid_json': ()=>({
321
+ title: 'Request failed',
322
+ body: 'The data for this action was malformed.',
323
+ action: 'Refresh the page and try again. Contact engineering if it persists.'
324
+ }),
325
+ 'endpoint.forbidden': ()=>({
326
+ title: "You don't have permission",
327
+ body: "Your account can't perform this action.",
328
+ action: 'Contact an admin if this is incorrect.'
329
+ }),
330
+ 'endpoint.unauthorized': ()=>({
331
+ title: 'Please sign in again',
332
+ body: 'Your session expired.',
333
+ action: 'Sign in and try again.'
334
+ }),
335
+ // -------------------------------------------------------------------------
336
+ // Admin action markers — written to failureMessage when an admin
337
+ // cancelled or force-reset a unit. Not real failures.
338
+ // -------------------------------------------------------------------------
339
+ 'marker.cancelled-by-admin': ()=>({
340
+ title: 'Cancelled by an admin',
341
+ body: 'This translation was stopped before it could run.'
342
+ }),
343
+ 'marker.force-reset-by-admin': ()=>({
344
+ title: 'Reset by admin',
345
+ body: 'This translation was manually reset after it stalled.'
346
+ }),
347
+ 'marker.cancelled-mid-flight': ()=>({
348
+ title: 'Stopped mid-run',
349
+ body: "This document was being translated when the run was cancelled — it didn't finish in time.",
350
+ action: 'Start a new run to translate the remaining documents.'
351
+ })
352
+ };
353
+ // ---------------------------------------------------------------------------
354
+ // Public API
355
+ // ---------------------------------------------------------------------------
356
+ /**
357
+ * Returns the editor-facing message for a given code. When the code is
358
+ * unknown (e.g. legacy data with a code we no longer ship), returns a
359
+ * safe fallback rather than rendering `undefined` in the UI.
360
+ */ export function editorMessageFor(code, ctx = {}) {
361
+ if (code && code in MESSAGES) {
362
+ return MESSAGES[code](ctx);
363
+ }
364
+ return {
365
+ title: 'Something went wrong',
366
+ body: "The system reported an error we don't have a friendly description for.",
367
+ action: 'Try again. If it keeps happening, contact engineering with the run details.'
368
+ };
369
+ }
370
+ /**
371
+ * `true` when the code names a failure that won't fix itself on retry
372
+ * — used to disable the Retry button in the UI and show the inline
373
+ * "Why won't this work?" explanation. Conservative by design: when
374
+ * we're not sure, allow retry.
375
+ */ export function isPermanentFailure(code) {
376
+ if (!code) return false;
377
+ return code === 'bulk.permanent.schema' || code === 'bulk.permanent.too_large' || code === 'bulk.permanent.config' || code === 'bulk.permanent.deleted' || code === 'cost-guard.per-call' || code === 'cost-guard.per-doc';
378
+ }
379
+ /**
380
+ * Adapter from the bulk worker's `FailureCode` (machine enum on the
381
+ * `bulk_translate_units.failureCode` column) to an `EditorErrorCode`.
382
+ * Keeps the persistence enum unchanged while letting the UI key off
383
+ * the catalogue.
384
+ */ export function editorCodeFromFailureCode(failureCode) {
385
+ switch(failureCode){
386
+ case 'transient.rate_limited':
387
+ return 'bulk.transient.rate_limited';
388
+ case 'transient.provider':
389
+ return 'bulk.transient.provider';
390
+ case 'transient.crashed':
391
+ return 'bulk.transient.crashed';
392
+ case 'permanent.schema':
393
+ return 'bulk.permanent.schema';
394
+ case 'permanent.too_large':
395
+ return 'bulk.permanent.too_large';
396
+ case 'permanent.config':
397
+ return 'bulk.permanent.config';
398
+ case 'permanent.deleted':
399
+ return 'bulk.permanent.deleted';
400
+ default:
401
+ return 'bulk.unknown';
402
+ }
403
+ }
404
+ /**
405
+ * Adapter from the alerts collection's `alertType` enum to an
406
+ * `EditorErrorCode`. Used by AlertBanner to render the friendly
407
+ * message via the catalogue.
408
+ */ export function editorCodeFromAlertType(alertType) {
409
+ switch(alertType){
410
+ case 'persistent-failure':
411
+ return 'alert.persistent-failure';
412
+ case 'cost-guard-abort':
413
+ return 'alert.cost-guard-abort';
414
+ case 'provider-outage':
415
+ return 'alert.provider-outage';
416
+ default:
417
+ return 'alert.persistent-failure';
418
+ }
419
+ }
420
+ /**
421
+ * Adapter from the cost-guard throw's `code` property to an
422
+ * `EditorErrorCode`. Used in API catch blocks that emitAlert.
423
+ */ export function editorCodeFromCostGuardCode(costGuardCode) {
424
+ switch(costGuardCode){
425
+ case 'PER_CALL_LIMIT':
426
+ return 'cost-guard.per-call';
427
+ case 'PER_DOC_CEILING':
428
+ return 'cost-guard.per-doc';
429
+ default:
430
+ return 'cost-guard.per-doc';
431
+ }
432
+ }
433
+ /**
434
+ * Adapter from a `failureMessage` enum marker (admin action) to an
435
+ * `EditorErrorCode`. Returns `null` when the message is a real error
436
+ * (not an admin marker) so callers can fall back to `failureCode`.
437
+ */ export function editorCodeFromFailureMessageMarker(failureMessage) {
438
+ if (!failureMessage) return null;
439
+ if (failureMessage === 'cancelled_by_admin') return 'marker.cancelled-by-admin';
440
+ if (failureMessage === 'force-reset by admin') return 'marker.force-reset-by-admin';
441
+ return null;
442
+ }
443
+ const CONFIG_ERROR_FAILURE_CODES = new Set([
444
+ 'permanent.config'
445
+ ]);
446
+ const TRANSIENT_PROVIDER_FAILURE_CODES = new Set([
447
+ 'transient.provider',
448
+ 'transient.rate_limited',
449
+ 'transient.crashed'
450
+ ]);
451
+ const OUTPUT_VALIDATION_FAILURE_CODES = new Set([
452
+ 'permanent.schema'
453
+ ]);
454
+ const CATEGORY_LABEL = {
455
+ 'config-error': 'Configuration error',
456
+ 'transient-provider-error': 'Provider hiccup',
457
+ 'cancelled-mid-flight': 'Stopped mid-run',
458
+ 'output-validation-error': 'Invalid AI output',
459
+ 'unknown-error': 'Unknown error'
460
+ };
461
+ const CATEGORY_EDITOR_CODE = {
462
+ 'config-error': 'bulk.permanent.config',
463
+ 'transient-provider-error': 'bulk.transient.provider',
464
+ 'cancelled-mid-flight': 'marker.cancelled-mid-flight',
465
+ 'output-validation-error': 'bulk.permanent.schema',
466
+ 'unknown-error': 'bulk.unknown'
467
+ };
468
+ // Hints inside the raw error message that override what `failureCode`
469
+ // claims. The persisted `failureCode` is best-effort from
470
+ // `classifyError`; when the underlying message clearly says something
471
+ // else, trust the message.
472
+ const MESSAGE_HINTS = [
473
+ // Output validation — Vercel AI SDK throws this when structured output
474
+ // doesn't match the schema.
475
+ {
476
+ category: 'output-validation-error',
477
+ pattern: /noobjectgeneratederror|invalid json|schema rejected/i
478
+ },
479
+ // Auth / config — 401 / 403, missing key, missing model
480
+ {
481
+ category: 'config-error',
482
+ pattern: /\b401\b|\b403\b|unauthorized|forbidden|api[_-]?key|no model (configured|registered)|no provider/i
483
+ },
484
+ // Transient provider — 5xx, timeout, network, rate limit, undefined cost
485
+ {
486
+ category: 'transient-provider-error',
487
+ pattern: /\b5\d\d\b|\b429\b|timeout|timed out|fetch failed|econnreset|enotfound|network|rate[_-]?limit|provider returned undefined|cost instrumentation broken/i
488
+ }
489
+ ];
490
+ function rawErrorMessage(input) {
491
+ if (input.failureMessage) return input.failureMessage;
492
+ if (input.err instanceof Error) return input.err.message;
493
+ if (typeof input.err === 'string') return input.err;
494
+ return '';
495
+ }
496
+ /**
497
+ * Coarse-grained failure categorizer used by `bulk_translate_units` UI
498
+ * surfaces (BatchRow expanded row, BucketRow locale detail, Failure
499
+ * drawer chip). Pure function over the persisted row + parent batch
500
+ * status. Server-side callers (worker) can also pass the raw thrown
501
+ * error before persistence.
502
+ *
503
+ * Decision order:
504
+ * 1. Admin markers (failureMessage = 'cancelled_by_admin' /
505
+ * 'force-reset by admin') short-circuit to the existing marker
506
+ * codes — those aren't real failures.
507
+ * 2. If the parent batch is `cancelled` or `cancelling` AND the unit
508
+ * has a real failure (failureCode present, not just the admin
509
+ * marker), classify as `cancelled-mid-flight`. The user's mental
510
+ * model is "I cancelled, so this stopped" — not "my config is
511
+ * broken."
512
+ * 3. Message-hint patterns override the persisted `failureCode` when
513
+ * they clearly point at a different bucket (defends against
514
+ * `classifyError` drift in legacy data — e.g. pre-v1.2.6 the
515
+ * COST_UNDEFINED case persisted as `permanent.config`).
516
+ * 4. Map `failureCode` to a category via the static tables.
517
+ * 5. Fallback: `unknown-error`.
518
+ */ export function categorizeFailure(input) {
519
+ const failureMessage = input.failureMessage ?? null;
520
+ const failureCode = input.failureCode ?? null;
521
+ const batchStatus = input.batchStatus ?? null;
522
+ const message = rawErrorMessage(input);
523
+ // 1. Admin markers — the unit is not a real failure.
524
+ if (failureMessage === 'cancelled_by_admin') {
525
+ return {
526
+ category: 'cancelled-mid-flight',
527
+ label: 'Cancelled',
528
+ editorCode: 'marker.cancelled-by-admin',
529
+ userMessage: editorMessageFor('marker.cancelled-by-admin').title
530
+ };
531
+ }
532
+ if (failureMessage === 'force-reset by admin') {
533
+ return {
534
+ category: 'cancelled-mid-flight',
535
+ label: 'Reset',
536
+ editorCode: 'marker.force-reset-by-admin',
537
+ userMessage: editorMessageFor('marker.force-reset-by-admin').title
538
+ };
539
+ }
540
+ // 2. Cancelled-mid-flight: parent batch was cancelled/cancelling AND
541
+ // the unit has a real (non-marker) failure. The cancel context
542
+ // overrides whatever the categorizer would otherwise pick — the
543
+ // editor just clicked Cancel and shouldn't be told "config broken".
544
+ const cancelInProgress = batchStatus === 'cancelled' || batchStatus === 'cancelling';
545
+ if (cancelInProgress && (failureCode || failureMessage)) {
546
+ return {
547
+ category: 'cancelled-mid-flight',
548
+ label: CATEGORY_LABEL['cancelled-mid-flight'],
549
+ editorCode: CATEGORY_EDITOR_CODE['cancelled-mid-flight'],
550
+ userMessage: editorMessageFor(CATEGORY_EDITOR_CODE['cancelled-mid-flight']).title,
551
+ // Engineers can still see the original error in the disclosure.
552
+ details: message || undefined
553
+ };
554
+ }
555
+ // 3. Message-hint override — when the raw text clearly says one
556
+ // thing but `failureCode` says another, the message wins.
557
+ for (const hint of MESSAGE_HINTS){
558
+ if (message && hint.pattern.test(message)) {
559
+ const editorCode = CATEGORY_EDITOR_CODE[hint.category];
560
+ return {
561
+ category: hint.category,
562
+ label: CATEGORY_LABEL[hint.category],
563
+ editorCode,
564
+ userMessage: editorMessageFor(editorCode).title,
565
+ details: message
566
+ };
567
+ }
568
+ }
569
+ // 4. failureCode → category.
570
+ if (failureCode) {
571
+ if (CONFIG_ERROR_FAILURE_CODES.has(failureCode)) {
572
+ return {
573
+ category: 'config-error',
574
+ label: CATEGORY_LABEL['config-error'],
575
+ editorCode: 'bulk.permanent.config',
576
+ userMessage: editorMessageFor('bulk.permanent.config').title,
577
+ details: message || undefined
578
+ };
579
+ }
580
+ if (TRANSIENT_PROVIDER_FAILURE_CODES.has(failureCode)) {
581
+ const editorCode = editorCodeFromFailureCode(failureCode);
582
+ return {
583
+ category: 'transient-provider-error',
584
+ label: CATEGORY_LABEL['transient-provider-error'],
585
+ editorCode,
586
+ userMessage: editorMessageFor(editorCode).title,
587
+ details: message || undefined
588
+ };
589
+ }
590
+ if (OUTPUT_VALIDATION_FAILURE_CODES.has(failureCode)) {
591
+ return {
592
+ category: 'output-validation-error',
593
+ label: CATEGORY_LABEL['output-validation-error'],
594
+ editorCode: 'bulk.permanent.schema',
595
+ userMessage: editorMessageFor('bulk.permanent.schema').title,
596
+ details: message || undefined
597
+ };
598
+ }
599
+ // Recognised but uncategorised codes (e.g. `permanent.too_large`,
600
+ // `permanent.deleted`) fall through to their editor code without
601
+ // being squashed into `unknown` — the catalogue already covers
602
+ // their copy.
603
+ if (failureCode === 'permanent.too_large' || failureCode === 'permanent.deleted') {
604
+ const editorCode = editorCodeFromFailureCode(failureCode);
605
+ return {
606
+ // These two are config-adjacent permanent failures; mapping
607
+ // them to `config-error` keeps the UI tone consistent (red,
608
+ // explanation block, disabled retry) without inventing two
609
+ // more category buckets for what's still "won't retry past".
610
+ category: 'config-error',
611
+ label: editorMessageFor(editorCode).title,
612
+ editorCode,
613
+ userMessage: editorMessageFor(editorCode).title,
614
+ details: message || undefined
615
+ };
616
+ }
617
+ }
618
+ // 5. Fallback.
619
+ return {
620
+ category: 'unknown-error',
621
+ label: CATEGORY_LABEL['unknown-error'],
622
+ editorCode: 'bulk.unknown',
623
+ userMessage: editorMessageFor('bulk.unknown').title,
624
+ details: message || undefined
625
+ };
626
+ }
@@ -0,0 +1,39 @@
1
+ import type { AITranslatePluginConfig, TranslationAlert, TranslationEvent } from '../types.js';
2
+ /**
3
+ * Module-scoped payload reference for alert persistence. Set via
4
+ * `setAlertsContext` from the plugin's `onInit` (post-config-build,
5
+ * when `payload` is live). When null, `emitAlert` skips DB persistence
6
+ * but still fires the consumer's `onAlert` callback.
7
+ *
8
+ * `payload` carries an optional `logger` (pino) so we can emit
9
+ * structured "alerts about alerts failing" — without it, persistence
10
+ * failures swallow silently (Grafana blindness).
11
+ */
12
+ declare let alertsContext: {
13
+ payload: {
14
+ create: (args: unknown) => Promise<unknown>;
15
+ logger?: {
16
+ debug?: (...args: unknown[]) => void;
17
+ info?: (...args: unknown[]) => void;
18
+ warn?: (...args: unknown[]) => void;
19
+ error?: (...args: unknown[]) => void;
20
+ };
21
+ };
22
+ collectionSlug: string;
23
+ } | null;
24
+ export declare function setAlertsContext(ctx: typeof alertsContext): void;
25
+ export declare function emitEvent(config: AITranslatePluginConfig, event: TranslationEvent): void;
26
+ /**
27
+ * Best-effort alert persistence + consumer callback. Two outputs:
28
+ *
29
+ * 1. Persists a row to `translation-alerts` collection when the
30
+ * plugin's alertsContext is set (auto-registered when
31
+ * `usageTracking.enabled`). Lets the Hub surface alerts in-app.
32
+ * 2. Calls the consumer's `onAlert` callback if set (back-compat —
33
+ * pre-1.1.15 consumers wired this to Slack/email).
34
+ *
35
+ * Neither path can break the main translation flow — both are wrapped
36
+ * in best-effort try/catch with no propagation.
37
+ */
38
+ export declare function emitAlert(config: AITranslatePluginConfig, alert: TranslationAlert): void;
39
+ export {};