@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.
- package/LICENSE +21 -0
- package/README.md +714 -0
- package/dist/alerts-collection.d.ts +21 -0
- package/dist/alerts-collection.js +159 -0
- package/dist/api.d.ts +4 -0
- package/dist/api.js +918 -0
- package/dist/bulk-translate-batches-collection.d.ts +29 -0
- package/dist/bulk-translate-batches-collection.js +404 -0
- package/dist/bulk-translate-units-collection.d.ts +35 -0
- package/dist/bulk-translate-units-collection.js +310 -0
- package/dist/client/estimated-cost-cell.d.ts +6 -0
- package/dist/client/estimated-cost-cell.js +12 -0
- package/dist/client/excluded-fields-field.d.ts +45 -0
- package/dist/client/excluded-fields-field.js +553 -0
- package/dist/client/field-translate-button.d.ts +6 -0
- package/dist/client/field-translate-button.js +199 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +6 -0
- package/dist/client/lib/use-global-kill-switches.d.ts +20 -0
- package/dist/client/lib/use-global-kill-switches.js +58 -0
- package/dist/client/translate-button.d.ts +2 -0
- package/dist/client/translate-button.js +228 -0
- package/dist/client/translate-modal.d.ts +16 -0
- package/dist/client/translate-modal.js +549 -0
- package/dist/client/translation-progress.d.ts +10 -0
- package/dist/client/translation-progress.js +297 -0
- package/dist/components/TranslationNavGroup.d.ts +45 -0
- package/dist/components/TranslationNavGroup.js +104 -0
- package/dist/defaults.d.ts +11 -0
- package/dist/defaults.js +16 -0
- package/dist/endpoints/client-config.d.ts +44 -0
- package/dist/endpoints/client-config.js +145 -0
- package/dist/endpoints/estimate.d.ts +5 -0
- package/dist/endpoints/estimate.js +237 -0
- package/dist/endpoints/progress.d.ts +2 -0
- package/dist/endpoints/progress.js +314 -0
- package/dist/endpoints/translate.d.ts +11 -0
- package/dist/endpoints/translate.js +376 -0
- package/dist/endpoints/translation-hub/_helpers.d.ts +140 -0
- package/dist/endpoints/translation-hub/_helpers.js +297 -0
- package/dist/endpoints/translation-hub/active.d.ts +21 -0
- package/dist/endpoints/translation-hub/active.js +220 -0
- package/dist/endpoints/translation-hub/cancel.d.ts +22 -0
- package/dist/endpoints/translation-hub/cancel.js +233 -0
- package/dist/endpoints/translation-hub/enqueue.d.ts +70 -0
- package/dist/endpoints/translation-hub/enqueue.js +529 -0
- package/dist/endpoints/translation-hub/failures.d.ts +12 -0
- package/dist/endpoints/translation-hub/failures.js +67 -0
- package/dist/endpoints/translation-hub/force-reset.d.ts +20 -0
- package/dist/endpoints/translation-hub/force-reset.js +144 -0
- package/dist/endpoints/translation-hub/index.d.ts +21 -0
- package/dist/endpoints/translation-hub/index.js +20 -0
- package/dist/endpoints/translation-hub/list.d.ts +40 -0
- package/dist/endpoints/translation-hub/list.js +182 -0
- package/dist/endpoints/translation-hub/preflight.d.ts +19 -0
- package/dist/endpoints/translation-hub/preflight.js +141 -0
- package/dist/endpoints/translation-hub/retry-failed.d.ts +38 -0
- package/dist/endpoints/translation-hub/retry-failed.js +235 -0
- package/dist/endpoints/translation-hub/revert.d.ts +88 -0
- package/dist/endpoints/translation-hub/revert.js +405 -0
- package/dist/endpoints/translation-hub/status.d.ts +45 -0
- package/dist/endpoints/translation-hub/status.js +391 -0
- package/dist/endpoints/translation-hub/usage-summary.d.ts +114 -0
- package/dist/endpoints/translation-hub/usage-summary.js +481 -0
- package/dist/exports/client.d.ts +6 -0
- package/dist/exports/client.js +6 -0
- package/dist/exports/components.d.ts +6 -0
- package/dist/exports/components.js +5 -0
- package/dist/exports/index.d.ts +8 -0
- package/dist/exports/index.js +7 -0
- package/dist/exports/providers.d.ts +9 -0
- package/dist/exports/providers.js +5 -0
- package/dist/exports/views-client.d.ts +23 -0
- package/dist/exports/views-client.js +22 -0
- package/dist/exports/views.d.ts +30 -0
- package/dist/exports/views.js +29 -0
- package/dist/hooks/after-change-global.d.ts +4 -0
- package/dist/hooks/after-change-global.js +109 -0
- package/dist/hooks/after-change.d.ts +16 -0
- package/dist/hooks/after-change.js +205 -0
- package/dist/hooks/after-delete.d.ts +30 -0
- package/dist/hooks/after-delete.js +95 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/jobs-collection.d.ts +17 -0
- package/dist/jobs-collection.js +139 -0
- package/dist/lexical/classifier.d.ts +3 -0
- package/dist/lexical/classifier.js +108 -0
- package/dist/lexical/deserializer.d.ts +4 -0
- package/dist/lexical/deserializer.js +263 -0
- package/dist/lexical/placeholder-integrity.d.ts +6 -0
- package/dist/lexical/placeholder-integrity.js +21 -0
- package/dist/lexical/placeholders.d.ts +21 -0
- package/dist/lexical/placeholders.js +117 -0
- package/dist/lexical/serializer.d.ts +21 -0
- package/dist/lexical/serializer.js +233 -0
- package/dist/lexical/types.d.ts +32 -0
- package/dist/lexical/types.js +1 -0
- package/dist/lib/auth-diagnostics.d.ts +14 -0
- package/dist/lib/auth-diagnostics.js +19 -0
- package/dist/lib/batch-counts.d.ts +58 -0
- package/dist/lib/batch-counts.js +105 -0
- package/dist/lib/bulk-translate-migrations.d.ts +92 -0
- package/dist/lib/bulk-translate-migrations.js +153 -0
- package/dist/lib/coalescing-queue.d.ts +38 -0
- package/dist/lib/coalescing-queue.js +69 -0
- package/dist/lib/content-extractor.d.ts +16 -0
- package/dist/lib/content-extractor.js +410 -0
- package/dist/lib/content-hash.d.ts +1 -0
- package/dist/lib/content-hash.js +19 -0
- package/dist/lib/content-patcher.d.ts +15 -0
- package/dist/lib/content-patcher.js +293 -0
- package/dist/lib/cost-guards.d.ts +2 -0
- package/dist/lib/cost-guards.js +18 -0
- package/dist/lib/daily-spend-cap.d.ts +58 -0
- package/dist/lib/daily-spend-cap.js +233 -0
- package/dist/lib/effective-locales.d.ts +181 -0
- package/dist/lib/effective-locales.js +302 -0
- package/dist/lib/error-messages.d.ts +245 -0
- package/dist/lib/error-messages.js +626 -0
- package/dist/lib/events.d.ts +39 -0
- package/dist/lib/events.js +146 -0
- package/dist/lib/exclude-fields.d.ts +3 -0
- package/dist/lib/exclude-fields.js +64 -0
- package/dist/lib/field-breadcrumb.d.ts +31 -0
- package/dist/lib/field-breadcrumb.js +227 -0
- package/dist/lib/field-diff.d.ts +1 -0
- package/dist/lib/field-diff.js +25 -0
- package/dist/lib/field-empty.d.ts +2 -0
- package/dist/lib/field-empty.js +68 -0
- package/dist/lib/field-resolver.d.ts +3 -0
- package/dist/lib/field-resolver.js +164 -0
- package/dist/lib/group-soft-skips.d.ts +39 -0
- package/dist/lib/group-soft-skips.js +45 -0
- package/dist/lib/locale-merge.d.ts +44 -0
- package/dist/lib/locale-merge.js +357 -0
- package/dist/lib/locale-row-check.d.ts +30 -0
- package/dist/lib/locale-row-check.js +64 -0
- package/dist/lib/logger.d.ts +74 -0
- package/dist/lib/logger.js +97 -0
- package/dist/lib/manual-edit-guard.d.ts +128 -0
- package/dist/lib/manual-edit-guard.js +393 -0
- package/dist/lib/output-validation.d.ts +48 -0
- package/dist/lib/output-validation.js +148 -0
- package/dist/lib/payload-read.d.ts +16 -0
- package/dist/lib/payload-read.js +51 -0
- package/dist/lib/per-doc-claim.d.ts +90 -0
- package/dist/lib/per-doc-claim.js +140 -0
- package/dist/lib/per-doc-lock.d.ts +94 -0
- package/dist/lib/per-doc-lock.js +119 -0
- package/dist/lib/persist-usage.d.ts +91 -0
- package/dist/lib/persist-usage.js +116 -0
- package/dist/lib/progress-store.d.ts +103 -0
- package/dist/lib/progress-store.js +314 -0
- package/dist/lib/rate-limiter.d.ts +3 -0
- package/dist/lib/rate-limiter.js +53 -0
- package/dist/lib/snapshot-select.d.ts +43 -0
- package/dist/lib/snapshot-select.js +108 -0
- package/dist/lib/translate-prompt.d.ts +31 -0
- package/dist/lib/translate-prompt.js +66 -0
- package/dist/lib/translation-token-bucket.d.ts +57 -0
- package/dist/lib/translation-token-bucket.js +365 -0
- package/dist/lib/truncate-source-value.d.ts +1 -0
- package/dist/lib/truncate-source-value.js +27 -0
- package/dist/manual-edit-collection.d.ts +22 -0
- package/dist/manual-edit-collection.js +124 -0
- package/dist/plugin.d.ts +3 -0
- package/dist/plugin.js +934 -0
- package/dist/providers/ai-sdk-adapter.d.ts +35 -0
- package/dist/providers/ai-sdk-adapter.js +100 -0
- package/dist/providers/anthropic.d.ts +31 -0
- package/dist/providers/anthropic.js +66 -0
- package/dist/providers/custom.d.ts +36 -0
- package/dist/providers/custom.js +24 -0
- package/dist/providers/gemini.d.ts +20 -0
- package/dist/providers/gemini.js +48 -0
- package/dist/providers/mock.d.ts +2 -0
- package/dist/providers/mock.js +29 -0
- package/dist/providers/openai.d.ts +28 -0
- package/dist/providers/openai.js +69 -0
- package/dist/settings-global.d.ts +74 -0
- package/dist/settings-global.js +216 -0
- package/dist/tasks/bulk-translate-coordinator.d.ts +115 -0
- package/dist/tasks/bulk-translate-coordinator.js +708 -0
- package/dist/tasks/bulk-translate-doc-task.d.ts +142 -0
- package/dist/tasks/bulk-translate-doc-task.js +1000 -0
- package/dist/tasks/bulk-translate-janitor.d.ts +87 -0
- package/dist/tasks/bulk-translate-janitor.js +311 -0
- package/dist/tasks/translate-job-task.d.ts +51 -0
- package/dist/tasks/translate-job-task.js +154 -0
- package/dist/translate.d.ts +113 -0
- package/dist/translate.js +911 -0
- package/dist/translation-daily-spend-collection.d.ts +24 -0
- package/dist/translation-daily-spend-collection.js +133 -0
- package/dist/translation-rate-limits-collection.d.ts +30 -0
- package/dist/translation-rate-limits-collection.js +144 -0
- package/dist/types.d.ts +672 -0
- package/dist/types.js +1 -0
- package/dist/usage-collection.d.ts +14 -0
- package/dist/usage-collection.js +377 -0
- package/dist/views/BulkRunsHub/BatchRow.d.ts +32 -0
- package/dist/views/BulkRunsHub/BatchRow.js +1222 -0
- package/dist/views/BulkRunsHub/BucketRow.d.ts +62 -0
- package/dist/views/BulkRunsHub/BucketRow.js +982 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.d.ts +18 -0
- package/dist/views/BulkRunsHub/BulkRunsHub.client.js +331 -0
- package/dist/views/BulkRunsHub/EmptyState.d.ts +6 -0
- package/dist/views/BulkRunsHub/EmptyState.js +64 -0
- package/dist/views/BulkRunsHub/FilterBar.d.ts +16 -0
- package/dist/views/BulkRunsHub/FilterBar.js +284 -0
- package/dist/views/BulkRunsHub/InFlightBanner.d.ts +14 -0
- package/dist/views/BulkRunsHub/InFlightBanner.js +59 -0
- package/dist/views/BulkRunsHub/StatusBadge.d.ts +64 -0
- package/dist/views/BulkRunsHub/StatusBadge.js +248 -0
- package/dist/views/BulkRunsHub/SummaryStrip.d.ts +22 -0
- package/dist/views/BulkRunsHub/SummaryStrip.js +249 -0
- package/dist/views/BulkRunsHub/bucket-grouping.d.ts +200 -0
- package/dist/views/BulkRunsHub/bucket-grouping.js +344 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.d.ts +9 -0
- package/dist/views/BulkRunsHub/bucketFailureSummary.js +36 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.d.ts +5 -0
- package/dist/views/BulkRunsHub/dedupedStatusFetch.js +45 -0
- package/dist/views/BulkRunsHub/index.d.ts +17 -0
- package/dist/views/BulkRunsHub/index.js +80 -0
- package/dist/views/BulkRunsHub/urlFilters.d.ts +14 -0
- package/dist/views/BulkRunsHub/urlFilters.js +50 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.d.ts +26 -0
- package/dist/views/BulkRunsHub/useBulkRunsList.js +204 -0
- package/dist/views/BulkRunsHub/useUrlFilters.d.ts +10 -0
- package/dist/views/BulkRunsHub/useUrlFilters.js +88 -0
- package/dist/views/TranslationHub/ActiveJobs.d.ts +6 -0
- package/dist/views/TranslationHub/ActiveJobs.js +320 -0
- package/dist/views/TranslationHub/AdvancedPanel.d.ts +17 -0
- package/dist/views/TranslationHub/AdvancedPanel.js +996 -0
- package/dist/views/TranslationHub/AlertBanner.d.ts +6 -0
- package/dist/views/TranslationHub/AlertBanner.js +568 -0
- package/dist/views/TranslationHub/AuditPanel.d.ts +6 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.d.ts +44 -0
- package/dist/views/TranslationHub/AuditPanel.helpers.js +71 -0
- package/dist/views/TranslationHub/AuditPanel.js +1367 -0
- package/dist/views/TranslationHub/BulkTranslate.types.d.ts +242 -0
- package/dist/views/TranslationHub/BulkTranslate.types.js +36 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.d.ts +19 -0
- package/dist/views/TranslationHub/BulkTranslateFailureDrawer.js +332 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.d.ts +28 -0
- package/dist/views/TranslationHub/BulkTranslateMonitor.js +305 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.d.ts +3 -0
- package/dist/views/TranslationHub/BulkTranslateNarrowViewportBanner.js +42 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.d.ts +26 -0
- package/dist/views/TranslationHub/BulkTranslatePostEnqueueTransition.js +95 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.d.ts +22 -0
- package/dist/views/TranslationHub/BulkTranslatePreflightModal.js +879 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.d.ts +29 -0
- package/dist/views/TranslationHub/BulkTranslateTerminalCard.js +445 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.d.ts +66 -0
- package/dist/views/TranslationHub/BulkTranslateTrigger.js +161 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.d.ts +33 -0
- package/dist/views/TranslationHub/EditorRecentRunsPanel.js +290 -0
- package/dist/views/TranslationHub/Hub.client.d.ts +74 -0
- package/dist/views/TranslationHub/Hub.client.js +357 -0
- package/dist/views/TranslationHub/ModelCombobox.d.ts +14 -0
- package/dist/views/TranslationHub/ModelCombobox.js +415 -0
- package/dist/views/TranslationHub/PerCollectionConfig.d.ts +10 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.d.ts +16 -0
- package/dist/views/TranslationHub/PerCollectionConfig.helpers.js +19 -0
- package/dist/views/TranslationHub/PerCollectionConfig.js +759 -0
- package/dist/views/TranslationHub/SettingsRail.d.ts +11 -0
- package/dist/views/TranslationHub/SettingsRail.js +382 -0
- package/dist/views/TranslationHub/StatusStrip.d.ts +6 -0
- package/dist/views/TranslationHub/StatusStrip.js +451 -0
- package/dist/views/TranslationHub/UsageTable.d.ts +6 -0
- package/dist/views/TranslationHub/UsageTable.helpers.d.ts +69 -0
- package/dist/views/TranslationHub/UsageTable.helpers.js +49 -0
- package/dist/views/TranslationHub/UsageTable.js +1240 -0
- package/dist/views/TranslationHub/alertGrouping.d.ts +70 -0
- package/dist/views/TranslationHub/alertGrouping.js +99 -0
- package/dist/views/TranslationHub/index.d.ts +20 -0
- package/dist/views/TranslationHub/index.js +109 -0
- package/dist/views/TranslationHub/tabNavigation.d.ts +53 -0
- package/dist/views/TranslationHub/tabNavigation.js +74 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.d.ts +33 -0
- package/dist/views/TranslationHub/terminalBannerVisibility.js +124 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.d.ts +49 -0
- package/dist/views/TranslationHub/useBulkTranslateActive.js +251 -0
- package/dist/views/TranslationHub/useFocusTrap.d.ts +6 -0
- package/dist/views/TranslationHub/useFocusTrap.js +81 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.d.ts +77 -0
- package/dist/views/TranslationHub/useTranslationHubUsageSummary.js +267 -0
- package/dist/views/shared/EditorError.d.ts +97 -0
- package/dist/views/shared/EditorError.js +205 -0
- package/dist/views/shared/ModelCell.d.ts +18 -0
- package/dist/views/shared/ModelCell.js +31 -0
- package/dist/views/shared/docHref.d.ts +16 -0
- package/dist/views/shared/docHref.js +26 -0
- package/dist/views/shared/fetch-error-body.d.ts +25 -0
- package/dist/views/shared/fetch-error-body.js +42 -0
- package/dist/views/shared/filterPillStyle.d.ts +35 -0
- package/dist/views/shared/filterPillStyle.js +40 -0
- package/dist/views/shared/format.d.ts +75 -0
- package/dist/views/shared/format.js +131 -0
- package/package.json +141 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
import { DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG } from '../../bulk-translate-batches-collection.js';
|
|
2
|
+
import { collectBlocksConfig, extractTranslationUnits } from '../../lib/content-extractor.js';
|
|
3
|
+
import { checkAndIncrementDailySpend } from '../../lib/daily-spend-cap.js';
|
|
4
|
+
import { getEffectiveExcludePatternsForSurface, getGlobalKillSwitches } from '../../lib/effective-locales.js';
|
|
5
|
+
import { resolveTranslatableFields } from '../../lib/field-resolver.js';
|
|
6
|
+
import { findByIdNoFallback } from '../../lib/payload-read.js';
|
|
7
|
+
import { DEFAULT_SETTINGS_GLOBAL_SLUG } from '../../settings-global.js';
|
|
8
|
+
import { BULK_TRANSLATE_COORDINATOR_SLUG } from '../../tasks/bulk-translate-coordinator.js';
|
|
9
|
+
import { errorResponse, getAiTranslateConfig, isEditorOrAdmin, readJsonBody, unauthorizedResponse, verifyTotpCode } from './_helpers.js';
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Handler
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
/**
|
|
14
|
+
* `POST /api/translation-hub/bulk-translate` — enqueue a bulk run.
|
|
15
|
+
*
|
|
16
|
+
* Flow (see Design-2026-05-27-bulk-translate.md §4 PR2):
|
|
17
|
+
* 1. Auth — admin role (Decision #31).
|
|
18
|
+
* 2. TOTP friction — gated by `bulk.requireTotp` (Decision #13 v2).
|
|
19
|
+
* Friction-not-boundary: the daily USD cap is the real guard.
|
|
20
|
+
* 3. Body validation — discriminated by `mode`.
|
|
21
|
+
* 4. Concurrency refusal — one bulk run at a time per Decision #2.
|
|
22
|
+
* 5. Cost estimate — sum provider's `estimate()` across the resolved
|
|
23
|
+
* doc list. Hard-reject if `undefined` (Decision #29). Canary
|
|
24
|
+
* mode skips estimation — too cheap to matter.
|
|
25
|
+
* 6. Daily cap — `checkAndIncrementDailySpend` (Decision #14).
|
|
26
|
+
* 7. Snapshot pinned config — `{ providerKey, modelId, sourceLocale }`
|
|
27
|
+
* captured at enqueue (Decision #5).
|
|
28
|
+
* 8. Create batch row → queue coordinator task → 202.
|
|
29
|
+
*/ export const getBulkTranslateEnqueueHandler = (options = {})=>async (req)=>{
|
|
30
|
+
const batchesSlug = options.batchesCollectionSlug ?? DEFAULT_BULK_TRANSLATE_BATCHES_COLLECTION_SLUG;
|
|
31
|
+
const coordinatorTaskSlug = options.coordinatorTaskSlug ?? BULK_TRANSLATE_COORDINATOR_SLUG;
|
|
32
|
+
if (!req.user) {
|
|
33
|
+
return unauthorizedResponse(req);
|
|
34
|
+
}
|
|
35
|
+
if (!isEditorOrAdmin(req.user)) {
|
|
36
|
+
return errorResponse('forbidden', "You don't have permission to start a bulk translation. Contact an admin.", 403);
|
|
37
|
+
}
|
|
38
|
+
const config = getAiTranslateConfig(req.payload);
|
|
39
|
+
if (!config) {
|
|
40
|
+
return errorResponse('not_configured', "The translation system isn't set up for this site. Contact engineering.", 500);
|
|
41
|
+
}
|
|
42
|
+
// Bulk engine is on whenever the consumer passes a `bulk` block
|
|
43
|
+
// and hasn't explicitly opted out (`enabled: false`). Mirrors the
|
|
44
|
+
// boot-time gate in `plugin.ts`.
|
|
45
|
+
const bulkConfig = config.bulk;
|
|
46
|
+
if (!bulkConfig || bulkConfig.enabled === false) {
|
|
47
|
+
return errorResponse('not_configured', "Bulk translation isn't enabled for this site. Contact engineering.", 403);
|
|
48
|
+
}
|
|
49
|
+
// Plugin-wide bulk-translate kill switch (v1.2.8). When admin
|
|
50
|
+
// flipped `globalBulkTranslateEnabled` off in the settings global,
|
|
51
|
+
// NEW bulk runs are rejected here. In-flight runs that were
|
|
52
|
+
// already enqueued continue to completion — the worker
|
|
53
|
+
// (`bulk-translate-doc-task`) does not poll this flag, so this
|
|
54
|
+
// gate is "block new enqueues only" by design. Manual Translate
|
|
55
|
+
// and auto-translate are unaffected.
|
|
56
|
+
const killSwitches = await getGlobalKillSwitches(req.payload, config);
|
|
57
|
+
if (!killSwitches.bulkEnabled) {
|
|
58
|
+
return errorResponse('bulk_translate_disabled', 'Bulk Translate is paused site-wide. An admin can re-enable it in Translation Settings. Any run that was already in progress will continue to completion.', 403);
|
|
59
|
+
}
|
|
60
|
+
const parsed = await readJsonBody(req);
|
|
61
|
+
if (!parsed.ok) return parsed.res;
|
|
62
|
+
const body = parsed.body;
|
|
63
|
+
const validationError = validateEnqueueBody(body, config);
|
|
64
|
+
if (validationError) return validationError;
|
|
65
|
+
// ----- TOTP gate (Decision #13 v2) -----
|
|
66
|
+
// Accept both `totpCode` (canonical) and `totp` (UI alias) — the
|
|
67
|
+
// modal sends the shorter name.
|
|
68
|
+
const totpCode = body.totpCode ?? body.totp;
|
|
69
|
+
const totpResult = await verifyTotpCode(req.payload, req.user.id, totpCode, {
|
|
70
|
+
required: bulkConfig.requireTotp === true,
|
|
71
|
+
// Skip when TOTP plugin isn't installed — Decision #13 v2
|
|
72
|
+
// explicitly downgrades TOTP to friction-not-boundary; the
|
|
73
|
+
// daily USD cap is the real enforcement and applies below.
|
|
74
|
+
allowSkipWhenUnavailable: true
|
|
75
|
+
});
|
|
76
|
+
if (!totpResult.ok) {
|
|
77
|
+
const status = totpResult.code === 'missing' ? 400 : 401;
|
|
78
|
+
return errorResponse('totp_' + totpResult.code, totpResult.message, status);
|
|
79
|
+
}
|
|
80
|
+
// ----- Concurrency refusal (Decision #2) -----
|
|
81
|
+
const inFlight = await findInFlightBatchForUser(req.payload, batchesSlug, String(req.user.id));
|
|
82
|
+
if (inFlight) {
|
|
83
|
+
return errorResponse('concurrent_batch', 'A translation run is already in progress. Cancel the current run before starting a new one.', 409, {
|
|
84
|
+
existingBatchId: String(inFlight.id)
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
// ----- Resolve scope → document list -----
|
|
88
|
+
const resolvedScope = resolveScope(body.scope, config, bulkConfig);
|
|
89
|
+
if (resolvedScope.collections.length === 0 && resolvedScope.globals.length === 0) {
|
|
90
|
+
return errorResponse('empty_scope', 'The selected scope contains nothing to translate — pick at least one collection or global.', 400, {
|
|
91
|
+
scope: 'empty'
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
// ----- Cost estimate (Decision #29: hard-reject undefined) -----
|
|
95
|
+
// Canary mode skips estimation (Decision #26: small fixed sample).
|
|
96
|
+
let estimatedCostUsd;
|
|
97
|
+
let estimatedDocs = 0;
|
|
98
|
+
if (body.mode === 'canary') {
|
|
99
|
+
estimatedCostUsd = 0; // free pass — canary is bounded by canaryLimit
|
|
100
|
+
estimatedDocs = body.canaryLimit ?? bulkConfig.canaryDefaultSize ?? 10;
|
|
101
|
+
} else {
|
|
102
|
+
const estimate = await estimateBatchCost(req.payload, config, resolvedScope);
|
|
103
|
+
if (estimate.estimatedCostUsd === undefined) {
|
|
104
|
+
return errorResponse('cost_unavailable', "A cost estimate couldn't be calculated for the current AI model. The run is blocked as a safety measure — contact engineering to check the model's pricing configuration.", 402);
|
|
105
|
+
}
|
|
106
|
+
estimatedCostUsd = estimate.estimatedCostUsd;
|
|
107
|
+
estimatedDocs = estimate.documentCount;
|
|
108
|
+
}
|
|
109
|
+
// ----- Daily cap (Decision #14) -----
|
|
110
|
+
const capResult = await checkAndIncrementDailySpend(req.payload, estimatedCostUsd, {
|
|
111
|
+
capUsd: bulkConfig.dailyUsdCap
|
|
112
|
+
});
|
|
113
|
+
if (!capResult.allowed) {
|
|
114
|
+
// Fire onCapExceeded so consumers can page on-call before the
|
|
115
|
+
// cap silently locks out the day.
|
|
116
|
+
try {
|
|
117
|
+
await bulkConfig.onCapExceeded?.({
|
|
118
|
+
todaySpentUsd: capResult.todaySpentUsd,
|
|
119
|
+
capUsd: capResult.capUsd,
|
|
120
|
+
rejectedEstimateUsd: estimatedCostUsd,
|
|
121
|
+
requestPath: 'bulk-endpoint'
|
|
122
|
+
});
|
|
123
|
+
} catch {
|
|
124
|
+
// Best-effort hook — never block the response on a consumer
|
|
125
|
+
// callback exception.
|
|
126
|
+
}
|
|
127
|
+
return errorResponse(capResult.reason === 'cap_exceeded' ? 'daily_cap_exceeded' : 'invalid_estimate', capResult.message, 402, {
|
|
128
|
+
todaySpentUsd: String(capResult.todaySpentUsd),
|
|
129
|
+
remainingUsd: String(capResult.remainingUsd),
|
|
130
|
+
capUsd: String(capResult.capUsd)
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
// ----- Snapshot pinned config (Decision #5) -----
|
|
134
|
+
const snapshot = await snapshotActiveProviderConfig(req.payload, config);
|
|
135
|
+
// ----- Create batch row -----
|
|
136
|
+
const enqueuedAt = new Date().toISOString();
|
|
137
|
+
const userEmail = typeof req.user.email === 'string' ? req.user.email : undefined;
|
|
138
|
+
const totalLocales = resolvedScope.locales.length;
|
|
139
|
+
const totalUnits = estimatedDocs * Math.max(1, totalLocales);
|
|
140
|
+
const batch = await req.payload.create({
|
|
141
|
+
collection: batchesSlug,
|
|
142
|
+
data: {
|
|
143
|
+
scope: {
|
|
144
|
+
collections: resolvedScope.collections,
|
|
145
|
+
globals: resolvedScope.globals,
|
|
146
|
+
locales: resolvedScope.locales,
|
|
147
|
+
excludeCollections: resolvedScope.excludeCollections,
|
|
148
|
+
documentIds: resolvedScope.documentIdsFlat,
|
|
149
|
+
sourceLocale: snapshot.sourceLocale,
|
|
150
|
+
mode: body.mode,
|
|
151
|
+
canaryLimit: body.mode === 'canary' ? body.canaryLimit ?? bulkConfig.canaryDefaultSize ?? 10 : undefined
|
|
152
|
+
},
|
|
153
|
+
mode: body.mode,
|
|
154
|
+
canaryLimit: body.mode === 'canary' ? body.canaryLimit ?? bulkConfig.canaryDefaultSize ?? 10 : undefined,
|
|
155
|
+
snapshotProviderKey: snapshot.providerKey,
|
|
156
|
+
snapshotModelId: snapshot.modelId,
|
|
157
|
+
snapshotSourceLocale: snapshot.sourceLocale,
|
|
158
|
+
estimatedCostUsd,
|
|
159
|
+
actualCostUsd: 0,
|
|
160
|
+
status: 'queued',
|
|
161
|
+
totalUnits,
|
|
162
|
+
completedUnits: 0,
|
|
163
|
+
failedUnits: 0,
|
|
164
|
+
skippedUnits: 0,
|
|
165
|
+
triggeredByUserId: String(req.user.id),
|
|
166
|
+
triggeredByEmail: userEmail,
|
|
167
|
+
triggerReason: body.triggerReason,
|
|
168
|
+
enqueuedAt
|
|
169
|
+
},
|
|
170
|
+
overrideAccess: true
|
|
171
|
+
});
|
|
172
|
+
// ----- Queue coordinator -----
|
|
173
|
+
try {
|
|
174
|
+
await req.payload.jobs.queue({
|
|
175
|
+
task: coordinatorTaskSlug,
|
|
176
|
+
input: {
|
|
177
|
+
batchId: String(batch.id)
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
// Coordinator queue failed — mark batch failed immediately so
|
|
182
|
+
// the user gets a real error rather than a phantom queued row.
|
|
183
|
+
await req.payload.update({
|
|
184
|
+
collection: batchesSlug,
|
|
185
|
+
id: batch.id,
|
|
186
|
+
data: {
|
|
187
|
+
status: 'failed',
|
|
188
|
+
completedAt: new Date().toISOString(),
|
|
189
|
+
failures: {
|
|
190
|
+
queue_error: 1,
|
|
191
|
+
message: err instanceof Error ? err.message : String(err)
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
overrideAccess: true
|
|
195
|
+
});
|
|
196
|
+
return errorResponse('queue_failed', "The translation run couldn't be started. Try again, or contact engineering if it keeps happening.", 500);
|
|
197
|
+
}
|
|
198
|
+
const batchId = String(batch.id);
|
|
199
|
+
return Response.json({
|
|
200
|
+
data: {
|
|
201
|
+
batchId,
|
|
202
|
+
status: 'queued',
|
|
203
|
+
statusUrl: `/api/translation-hub/bulk-translate/${batchId}/status`,
|
|
204
|
+
estimatedCostUsd,
|
|
205
|
+
estimatedDocs,
|
|
206
|
+
totalUnits,
|
|
207
|
+
enqueuedAt
|
|
208
|
+
}
|
|
209
|
+
}, {
|
|
210
|
+
status: 202
|
|
211
|
+
});
|
|
212
|
+
};
|
|
213
|
+
function validateEnqueueBody(body, config) {
|
|
214
|
+
if (!body || typeof body !== 'object') {
|
|
215
|
+
return errorResponse('invalid_body', 'Request body must be an object.', 400);
|
|
216
|
+
}
|
|
217
|
+
// Default scope to empty object when omitted. `resolveScope()` fills
|
|
218
|
+
// it in from the plugin config — clicking "Bulk Translate" from the
|
|
219
|
+
// Hub with no narrowing means "everything tracked", and the UI
|
|
220
|
+
// shouldn't need to re-state the plugin's collection list.
|
|
221
|
+
if (!body.scope) {
|
|
222
|
+
body.scope = {};
|
|
223
|
+
}
|
|
224
|
+
if (typeof body.scope !== 'object') {
|
|
225
|
+
return errorResponse('invalid_body', '`scope` must be an object when provided.', 400, {
|
|
226
|
+
scope: 'invalid'
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
const mode = body.mode;
|
|
230
|
+
if (mode !== 'changed' && mode !== 'force' && mode !== 'canary') {
|
|
231
|
+
return errorResponse('invalid_body', 'mode must be one of: changed | force | canary.', 400, {
|
|
232
|
+
mode: 'invalid'
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
if (mode === 'canary') {
|
|
236
|
+
const limit = body.canaryLimit;
|
|
237
|
+
if (limit !== undefined && (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0)) {
|
|
238
|
+
return errorResponse('invalid_body', 'canaryLimit must be a positive number when provided.', 400, {
|
|
239
|
+
canaryLimit: 'invalid'
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Validate locales against the plugin's configured `targetLocales`.
|
|
244
|
+
// The coordinator already filters source-locale matches, so we only
|
|
245
|
+
// gate against typos / locales the plugin can't translate to.
|
|
246
|
+
const locales = body.scope.locales;
|
|
247
|
+
if (locales !== undefined) {
|
|
248
|
+
if (!Array.isArray(locales)) {
|
|
249
|
+
return errorResponse('invalid_body', 'scope.locales must be an array of locale codes.', 400, {
|
|
250
|
+
'scope.locales': 'invalid'
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const allowed = new Set(config.targetLocales);
|
|
254
|
+
const invalid = locales.filter((l)=>!allowed.has(l));
|
|
255
|
+
if (invalid.length > 0) {
|
|
256
|
+
return errorResponse('invalid_locale', `Locale(s) not configured in plugin.targetLocales: ${invalid.join(', ')}.`, 400, {
|
|
257
|
+
'scope.locales': 'invalid'
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
// An explicit empty list is a contradiction, not a default — a run
|
|
261
|
+
// that translates into zero locales can't mean anything. Reject it
|
|
262
|
+
// rather than silently falling back to all targetLocales.
|
|
263
|
+
if (locales.length === 0) {
|
|
264
|
+
return errorResponse('invalid_body', 'scope.locales must contain at least one locale when provided.', 400, {
|
|
265
|
+
'scope.locales': 'empty'
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const key of [
|
|
270
|
+
'collections',
|
|
271
|
+
'globals',
|
|
272
|
+
'excludeCollections'
|
|
273
|
+
]){
|
|
274
|
+
const v = body.scope[key];
|
|
275
|
+
if (v !== undefined && (!Array.isArray(v) || v.some((s)=>typeof s !== 'string'))) {
|
|
276
|
+
return errorResponse('invalid_body', `scope.${key} must be an array of strings when provided.`, 400, {
|
|
277
|
+
[`scope.${key}`]: 'invalid'
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (body.scope.documentIds !== undefined) {
|
|
282
|
+
if (typeof body.scope.documentIds !== 'object' || body.scope.documentIds === null || Array.isArray(body.scope.documentIds)) {
|
|
283
|
+
return errorResponse('invalid_body', 'scope.documentIds must be a record of collection → string[].', 400, {
|
|
284
|
+
'scope.documentIds': 'invalid'
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
for (const [collection, ids] of Object.entries(body.scope.documentIds)){
|
|
288
|
+
if (!Array.isArray(ids) || ids.some((id)=>typeof id !== 'string')) {
|
|
289
|
+
return errorResponse('invalid_body', `scope.documentIds.${collection} must be a string[].`, 400, {
|
|
290
|
+
[`scope.documentIds.${collection}`]: 'invalid'
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
export function resolveScope(scope, config, bulk) {
|
|
298
|
+
const pluginCollections = config.collections ?? [];
|
|
299
|
+
const pluginGlobals = config.globals ?? [];
|
|
300
|
+
const pluginExcludes = new Set(bulk.excludeCollections ?? []);
|
|
301
|
+
// ABSENT means "everything the plugin tracks" (back-compat default);
|
|
302
|
+
// an EXPLICIT array — including an empty one — means exactly what the
|
|
303
|
+
// caller selected. The previous `length > 0` check collapsed "the user
|
|
304
|
+
// deselected every global" into "unspecified", so a run scoped to a
|
|
305
|
+
// single collection silently fanned out to all globals (and vice
|
|
306
|
+
// versa for collections).
|
|
307
|
+
const requestedCollections = Array.isArray(scope.collections) ? scope.collections : pluginCollections;
|
|
308
|
+
const requestedGlobals = Array.isArray(scope.globals) ? scope.globals : pluginGlobals;
|
|
309
|
+
const excludeCollections = new Set([
|
|
310
|
+
...scope.excludeCollections ?? [],
|
|
311
|
+
...pluginExcludes
|
|
312
|
+
]);
|
|
313
|
+
// Filter by both the requested set AND the plugin's tracked
|
|
314
|
+
// collections — a caller can't bulk-translate a collection the
|
|
315
|
+
// plugin doesn't know about (and shouldn't be able to). Plugin-
|
|
316
|
+
// tracked-only also closes the security N5 doppelganger here.
|
|
317
|
+
const trackedCollections = new Set(pluginCollections);
|
|
318
|
+
const trackedGlobals = new Set(pluginGlobals);
|
|
319
|
+
const collectionsList = requestedCollections.filter((c)=>trackedCollections.has(c) && !excludeCollections.has(c));
|
|
320
|
+
const globalsList = requestedGlobals.filter((g)=>trackedGlobals.has(g));
|
|
321
|
+
const locales = scope.locales && scope.locales.length > 0 ? scope.locales : [
|
|
322
|
+
...config.targetLocales
|
|
323
|
+
];
|
|
324
|
+
// Flatten documentIds map → flat array of ids the coordinator can
|
|
325
|
+
// consume. We don't preserve the collection→ids mapping in the
|
|
326
|
+
// batch row's scope.documentIds because the coordinator currently
|
|
327
|
+
// accepts only a flat list (matches its existing semantics —
|
|
328
|
+
// documents-explicit mode walks the same collection it's enumerating).
|
|
329
|
+
let documentIdsFlat = [];
|
|
330
|
+
if (scope.documentIds) {
|
|
331
|
+
for (const [coll, ids] of Object.entries(scope.documentIds)){
|
|
332
|
+
if (!collectionsList.includes(coll)) continue;
|
|
333
|
+
documentIdsFlat = documentIdsFlat.concat(ids);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return {
|
|
337
|
+
collections: collectionsList,
|
|
338
|
+
globals: globalsList,
|
|
339
|
+
locales,
|
|
340
|
+
excludeCollections: Array.from(excludeCollections),
|
|
341
|
+
documentIdsFlat
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
async function findInFlightBatchForUser(payload, batchesSlug, userId) {
|
|
345
|
+
const result = await payload.find({
|
|
346
|
+
collection: batchesSlug,
|
|
347
|
+
where: {
|
|
348
|
+
and: [
|
|
349
|
+
{
|
|
350
|
+
triggeredByUserId: {
|
|
351
|
+
equals: userId
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
status: {
|
|
356
|
+
in: [
|
|
357
|
+
'queued',
|
|
358
|
+
'running',
|
|
359
|
+
'cancelling'
|
|
360
|
+
]
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
]
|
|
364
|
+
},
|
|
365
|
+
limit: 1,
|
|
366
|
+
depth: 0,
|
|
367
|
+
overrideAccess: true
|
|
368
|
+
});
|
|
369
|
+
const doc = result.docs[0];
|
|
370
|
+
return doc;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Enumerate the scope's docs, run the provider's `estimate()` once
|
|
374
|
+
* with the aggregated items, multiply by locale count. Mirrors the
|
|
375
|
+
* pattern in `translate.ts:estimateRequestCost` but at batch scale.
|
|
376
|
+
*
|
|
377
|
+
* Returns `estimatedCostUsd: undefined` only when the provider has no
|
|
378
|
+
* `estimate()` method — the enqueue handler then hard-rejects per
|
|
379
|
+
* Decision #29.
|
|
380
|
+
*/ export async function estimateBatchCost(payload, config, scope) {
|
|
381
|
+
if (!config.provider.estimate) {
|
|
382
|
+
return {
|
|
383
|
+
estimatedCostUsd: undefined,
|
|
384
|
+
documentCount: 0
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
const allItems = [];
|
|
388
|
+
let documentCount = 0;
|
|
389
|
+
for (const collectionSlug of scope.collections){
|
|
390
|
+
const collections = payload.config?.collections ?? [];
|
|
391
|
+
const collectionConfig = collections.find((c)=>c.slug === collectionSlug);
|
|
392
|
+
if (!collectionConfig) continue;
|
|
393
|
+
const translatableFields = resolveTranslatableFields(collectionConfig.fields);
|
|
394
|
+
const blocksConfig = collectBlocksConfig(collectionConfig.fields);
|
|
395
|
+
const excludePatterns = await getEffectiveExcludePatternsForSurface(payload, config, collectionSlug);
|
|
396
|
+
// If documentIdsFlat is non-empty, only estimate those docs in
|
|
397
|
+
// this collection. Otherwise paginate the whole collection.
|
|
398
|
+
const idsForThisCollection = scope.documentIdsFlat;
|
|
399
|
+
const docs = [];
|
|
400
|
+
if (idsForThisCollection.length > 0) {
|
|
401
|
+
for (const id of idsForThisCollection){
|
|
402
|
+
const doc = await findByIdNoFallback(payload, collectionSlug, id, config.sourceLocale, {
|
|
403
|
+
draft: true
|
|
404
|
+
});
|
|
405
|
+
if (doc) docs.push(doc);
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
// Paginate. Cap at 500 docs per collection to bound the
|
|
409
|
+
// estimate's wall-clock — the enqueue handler is request-
|
|
410
|
+
// scoped and a 5000-doc estimate would push past serverless
|
|
411
|
+
// limits.
|
|
412
|
+
const maxDocsPerCollection = 500;
|
|
413
|
+
let page = 1;
|
|
414
|
+
const limit = 100;
|
|
415
|
+
while(docs.length < maxDocsPerCollection){
|
|
416
|
+
const result = await payload.find({
|
|
417
|
+
collection: collectionSlug,
|
|
418
|
+
page,
|
|
419
|
+
limit,
|
|
420
|
+
depth: 0,
|
|
421
|
+
locale: config.sourceLocale,
|
|
422
|
+
overrideAccess: true,
|
|
423
|
+
draft: true
|
|
424
|
+
});
|
|
425
|
+
docs.push(...result.docs);
|
|
426
|
+
if (!result.hasNextPage) break;
|
|
427
|
+
page += 1;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
for (const doc of docs){
|
|
431
|
+
const units = extractTranslationUnits(doc, translatableFields, excludePatterns, blocksConfig);
|
|
432
|
+
for (const unit of units){
|
|
433
|
+
allItems.push({
|
|
434
|
+
id: unit.id,
|
|
435
|
+
text: unit.text,
|
|
436
|
+
kind: unit.kind
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
documentCount += 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
for (const globalSlug of scope.globals){
|
|
443
|
+
const globals = payload.config?.globals ?? [];
|
|
444
|
+
const globalConfig = globals.find((g)=>g.slug === globalSlug);
|
|
445
|
+
if (!globalConfig) continue;
|
|
446
|
+
const translatableFields = resolveTranslatableFields(globalConfig.fields);
|
|
447
|
+
const blocksConfig = collectBlocksConfig(globalConfig.fields);
|
|
448
|
+
const excludePatterns = await getEffectiveExcludePatternsForSurface(payload, config, globalSlug);
|
|
449
|
+
let doc;
|
|
450
|
+
try {
|
|
451
|
+
doc = await payload.findGlobal({
|
|
452
|
+
slug: globalSlug,
|
|
453
|
+
locale: config.sourceLocale,
|
|
454
|
+
fallbackLocale: null,
|
|
455
|
+
overrideAccess: true
|
|
456
|
+
});
|
|
457
|
+
} catch {
|
|
458
|
+
doc = undefined;
|
|
459
|
+
}
|
|
460
|
+
if (!doc) continue;
|
|
461
|
+
const units = extractTranslationUnits(doc, translatableFields, excludePatterns, blocksConfig);
|
|
462
|
+
for (const unit of units){
|
|
463
|
+
allItems.push({
|
|
464
|
+
id: unit.id,
|
|
465
|
+
text: unit.text,
|
|
466
|
+
kind: unit.kind
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
documentCount += 1;
|
|
470
|
+
}
|
|
471
|
+
if (allItems.length === 0) {
|
|
472
|
+
// No content to translate. Return 0 cost (the batch will land
|
|
473
|
+
// as `completed` with `totalUnits=0`).
|
|
474
|
+
return {
|
|
475
|
+
estimatedCostUsd: 0,
|
|
476
|
+
documentCount
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
const mockRequest = {
|
|
480
|
+
items: allItems,
|
|
481
|
+
sourceLocale: config.sourceLocale,
|
|
482
|
+
targetLocale: scope.locales[0] ?? '',
|
|
483
|
+
context: {
|
|
484
|
+
collectionSlug: scope.collections[0] ?? '',
|
|
485
|
+
fieldPath: '*'
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
const estimate = await config.provider.estimate(mockRequest);
|
|
489
|
+
const totalCost = estimate.estimatedCostUsd !== undefined ? estimate.estimatedCostUsd * Math.max(1, scope.locales.length) : undefined;
|
|
490
|
+
return {
|
|
491
|
+
estimatedCostUsd: totalCost,
|
|
492
|
+
documentCount
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Decision #5 + F-DA-MODEL-SWAP: pin the active `{provider, model,
|
|
497
|
+
* sourceLocale}` into the batch row at enqueue. Mid-run admin toggles
|
|
498
|
+
* must not bisect a batch across configurations.
|
|
499
|
+
*
|
|
500
|
+
* Resolution order matches `api.ts:resolveProvider`:
|
|
501
|
+
* 1. `translation-settings.activeProvider` keyed into `config.providers`
|
|
502
|
+
* 2. fallback to `config.provider`
|
|
503
|
+
*/ async function snapshotActiveProviderConfig(payload, config) {
|
|
504
|
+
let providerKey = 'default';
|
|
505
|
+
let activeProvider = config.provider;
|
|
506
|
+
if (config.providers && Object.keys(config.providers).length > 0) {
|
|
507
|
+
const settingsSlug = config.settings?.globalSlug ?? DEFAULT_SETTINGS_GLOBAL_SLUG;
|
|
508
|
+
try {
|
|
509
|
+
const settings = await payload.findGlobal({
|
|
510
|
+
slug: settingsSlug,
|
|
511
|
+
depth: 0,
|
|
512
|
+
overrideAccess: true
|
|
513
|
+
});
|
|
514
|
+
const active = settings?.activeProvider;
|
|
515
|
+
if (active && config.providers[active]) {
|
|
516
|
+
providerKey = active;
|
|
517
|
+
activeProvider = config.providers[active];
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
// Settings global not present — fall back to default below.
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const modelId = typeof activeProvider.model === 'string' && activeProvider.model.length > 0 ? activeProvider.model : 'unknown';
|
|
524
|
+
return {
|
|
525
|
+
providerKey,
|
|
526
|
+
modelId,
|
|
527
|
+
sourceLocale: config.sourceLocale
|
|
528
|
+
};
|
|
529
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PayloadHandler } from 'payload';
|
|
2
|
+
export interface BulkFailuresHandlerOptions {
|
|
3
|
+
unitsCollectionSlug?: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* `GET /api/translation-hub/bulk-translate/:id/failures`
|
|
7
|
+
*
|
|
8
|
+
* Paginated drill-down of failed units in a batch. Used by the failure
|
|
9
|
+
* drawer in the terminal card. Response shape:
|
|
10
|
+
* { rows: BulkTranslateFailureRow[], nextCursor: string | null }
|
|
11
|
+
*/
|
|
12
|
+
export declare const getBulkTranslateFailuresHandler: (options?: BulkFailuresHandlerOptions) => PayloadHandler;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG } from '../../bulk-translate-units-collection.js';
|
|
2
|
+
import { decodeCursor, encodeCursor, errorResponse, isEditorOrAdmin, unauthorizedResponse } from './_helpers.js';
|
|
3
|
+
import { extractBatchId } from './status.js';
|
|
4
|
+
/**
|
|
5
|
+
* `GET /api/translation-hub/bulk-translate/:id/failures`
|
|
6
|
+
*
|
|
7
|
+
* Paginated drill-down of failed units in a batch. Used by the failure
|
|
8
|
+
* drawer in the terminal card. Response shape:
|
|
9
|
+
* { rows: BulkTranslateFailureRow[], nextCursor: string | null }
|
|
10
|
+
*/ export const getBulkTranslateFailuresHandler = (options = {})=>async (req)=>{
|
|
11
|
+
const unitsSlug = options.unitsCollectionSlug ?? DEFAULT_BULK_TRANSLATE_UNITS_COLLECTION_SLUG;
|
|
12
|
+
if (!req.user) {
|
|
13
|
+
return unauthorizedResponse(req);
|
|
14
|
+
}
|
|
15
|
+
if (!isEditorOrAdmin(req.user)) {
|
|
16
|
+
return errorResponse('forbidden', "You don't have permission to view failure details. Contact an admin.", 403);
|
|
17
|
+
}
|
|
18
|
+
const batchId = extractBatchId(req.url ?? '');
|
|
19
|
+
if (!batchId) {
|
|
20
|
+
return errorResponse('invalid_batch_id', 'Batch ID could not be parsed from the request URL.', 400);
|
|
21
|
+
}
|
|
22
|
+
const url = new URL(req.url ?? 'http://localhost');
|
|
23
|
+
const cursor = url.searchParams.get('cursor') ?? undefined;
|
|
24
|
+
const limitRaw = url.searchParams.get('limit');
|
|
25
|
+
let limit = limitRaw ? Number.parseInt(limitRaw, 10) : 25;
|
|
26
|
+
if (!Number.isFinite(limit) || limit <= 0) limit = 25;
|
|
27
|
+
if (limit > 100) limit = 100;
|
|
28
|
+
const offset = decodeCursor(cursor);
|
|
29
|
+
const page = Math.floor(offset / limit) + 1;
|
|
30
|
+
const result = await req.payload.find({
|
|
31
|
+
collection: unitsSlug,
|
|
32
|
+
where: {
|
|
33
|
+
and: [
|
|
34
|
+
{
|
|
35
|
+
batchId: {
|
|
36
|
+
equals: batchId
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
status: {
|
|
41
|
+
equals: 'failed'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
sort: 'createdAt',
|
|
47
|
+
page,
|
|
48
|
+
limit,
|
|
49
|
+
depth: 0,
|
|
50
|
+
overrideAccess: true
|
|
51
|
+
});
|
|
52
|
+
const rows = result.docs.map((u)=>({
|
|
53
|
+
unitId: String(u.id),
|
|
54
|
+
collection: String(u.collection ?? ''),
|
|
55
|
+
documentId: String(u.documentId ?? ''),
|
|
56
|
+
documentTitle: null,
|
|
57
|
+
locale: String(u.locale ?? ''),
|
|
58
|
+
failureCode: typeof u.failureCode === 'string' ? u.failureCode : null,
|
|
59
|
+
failureMessage: typeof u.failureMessage === 'string' ? u.failureMessage : '',
|
|
60
|
+
attempts: Number(u.attempts ?? 0)
|
|
61
|
+
}));
|
|
62
|
+
const nextCursor = rows.length === limit ? encodeCursor(offset + limit) : null;
|
|
63
|
+
return Response.json({
|
|
64
|
+
rows,
|
|
65
|
+
nextCursor
|
|
66
|
+
});
|
|
67
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PayloadHandler } from 'payload';
|
|
2
|
+
export interface BulkForceResetHandlerOptions {
|
|
3
|
+
batchesCollectionSlug?: string;
|
|
4
|
+
unitsCollectionSlug?: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* `POST /api/translation-hub/bulk-translate/:id/force-reset`
|
|
8
|
+
*
|
|
9
|
+
* Recovery action — F-SEC-CRON-RESET. Marks every `running` unit in
|
|
10
|
+
* the batch as `failed` with `failureCode: 'transient.crashed'` and
|
|
11
|
+
* the batch itself as `failed`. Used by on-call when the worker /
|
|
12
|
+
* coordinator stalled and the normal janitor sweep hasn't picked it
|
|
13
|
+
* up.
|
|
14
|
+
*
|
|
15
|
+
* Auth: admin only — explicitly NO TOTP gate (this is a recovery
|
|
16
|
+
* action; admins must be able to unblock production without a TOTP
|
|
17
|
+
* dance if their mobile device is unavailable). The daily USD cap
|
|
18
|
+
* already enforced spending bounds for the batch in question.
|
|
19
|
+
*/
|
|
20
|
+
export declare const getBulkTranslateForceResetHandler: (options?: BulkForceResetHandlerOptions) => PayloadHandler;
|