@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
package/dist/api.js
ADDED
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
import { DEFAULT_CONCURRENCY, DEFAULT_RETRY, DEFAULT_TARGET_POLICY, REENTRY_FLAG } from './defaults.js';
|
|
2
|
+
import { collectBlocksConfig, extractTranslationUnits } from './lib/content-extractor.js';
|
|
3
|
+
import { checkPerDocLimit } from './lib/cost-guards.js';
|
|
4
|
+
import { getEffectiveExcludePatternsForSurface, getExcludedFieldPaths, isPathExcluded } from './lib/effective-locales.js';
|
|
5
|
+
import { emitAlert, emitEvent } from './lib/events.js';
|
|
6
|
+
import { diffFields } from './lib/field-diff.js';
|
|
7
|
+
import { isFieldEmpty } from './lib/field-empty.js';
|
|
8
|
+
import { resolveTranslatableFields } from './lib/field-resolver.js';
|
|
9
|
+
import { mergeTranslationsIntoTargetDoc } from './lib/locale-merge.js';
|
|
10
|
+
import { applyHashSkip, recordHashesAfterWrite } from './lib/manual-edit-guard.js';
|
|
11
|
+
import { findByIdNoFallback, findGlobalNoFallback } from './lib/payload-read.js';
|
|
12
|
+
import { bucketLocaleOutcomes, persistTranslationUsage } from './lib/persist-usage.js';
|
|
13
|
+
import { completeJobNow, createJob, updateJob } from './lib/progress-store.js';
|
|
14
|
+
import { truncateSourceValue } from './lib/truncate-source-value.js';
|
|
15
|
+
import { DEFAULT_MANUAL_EDIT_COLLECTION_SLUG } from './manual-edit-collection.js';
|
|
16
|
+
import { DEFAULT_SETTINGS_GLOBAL_SLUG } from './settings-global.js';
|
|
17
|
+
import { batchUnits, translateBatch, translateForLocale } from './translate.js';
|
|
18
|
+
/**
|
|
19
|
+
* Pick the provider this translation run should use.
|
|
20
|
+
*
|
|
21
|
+
* Resolution order:
|
|
22
|
+
* 1. If `config.providers` is configured AND the `translation-settings`
|
|
23
|
+
* global has `activeProvider` set to one of those keys → that provider.
|
|
24
|
+
* 2. Otherwise → `config.provider` (the static fallback).
|
|
25
|
+
*
|
|
26
|
+
* The settings read is wrapped in try/catch because the global may not
|
|
27
|
+
* exist yet (first request after install before migrations land) or the
|
|
28
|
+
* read may fail for unrelated reasons. In those cases we fall back to
|
|
29
|
+
* the static provider rather than failing the translation.
|
|
30
|
+
*/ async function resolveProvider(payload, config) {
|
|
31
|
+
if (!config.providers || Object.keys(config.providers).length === 0) {
|
|
32
|
+
return config.provider;
|
|
33
|
+
}
|
|
34
|
+
const slug = config.settings?.globalSlug ?? DEFAULT_SETTINGS_GLOBAL_SLUG;
|
|
35
|
+
try {
|
|
36
|
+
const settings = await payload.findGlobal({
|
|
37
|
+
slug: slug,
|
|
38
|
+
depth: 0,
|
|
39
|
+
overrideAccess: true
|
|
40
|
+
});
|
|
41
|
+
const activeName = settings?.activeProvider;
|
|
42
|
+
if (activeName && config.providers[activeName]) {
|
|
43
|
+
return config.providers[activeName];
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
// Global not yet created or read failed — fall back to default.
|
|
47
|
+
}
|
|
48
|
+
return config.provider;
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Public API
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
export async function translateDocument(payload, options) {
|
|
54
|
+
const baseConfig = payload.config?.custom?.aiTranslate;
|
|
55
|
+
if (!baseConfig) {
|
|
56
|
+
throw new Error('[ai-translate] Plugin not configured. Add aiTranslatePlugin() to your Payload config.');
|
|
57
|
+
}
|
|
58
|
+
// Resolve the runtime provider ONCE per document. All locales of this
|
|
59
|
+
// run use the same provider — a switch shouldn't happen mid-job. The
|
|
60
|
+
// resolved provider is spliced into a shallow config copy that flows
|
|
61
|
+
// through the rest of the pipeline; no other call site needs to
|
|
62
|
+
// change.
|
|
63
|
+
const runtimeProvider = await resolveProvider(payload, baseConfig);
|
|
64
|
+
const config = runtimeProvider === baseConfig.provider ? baseConfig : {
|
|
65
|
+
...baseConfig,
|
|
66
|
+
provider: runtimeProvider
|
|
67
|
+
};
|
|
68
|
+
const { collection, id, signal } = options;
|
|
69
|
+
const targetLocales = options.targetLocales ?? config.targetLocales;
|
|
70
|
+
const targetPolicy = options.targetPolicy ?? config.automation?.targetPolicy ?? DEFAULT_TARGET_POLICY;
|
|
71
|
+
const startedAt = Date.now();
|
|
72
|
+
// Read source document. Default to reading the draft so admin-UI flows
|
|
73
|
+
// ("draft → translate → review → publish") work on versioned collections.
|
|
74
|
+
// Hooks that fire on the published transition can pass `draft: false`
|
|
75
|
+
// explicitly if they need the published shape.
|
|
76
|
+
const sourceDoc = await findByIdNoFallback(payload, collection, id, config.sourceLocale, {
|
|
77
|
+
draft: options.draft ?? true
|
|
78
|
+
});
|
|
79
|
+
if (!sourceDoc) {
|
|
80
|
+
throw new Error(`[ai-translate] Document not found: ${collection}/${String(id)}`);
|
|
81
|
+
}
|
|
82
|
+
// Resolve collection field config
|
|
83
|
+
const collectionFields = getCollectionFields(payload, collection);
|
|
84
|
+
if (!collectionFields) {
|
|
85
|
+
throw new Error(`[ai-translate] Collection "${collection}" not found in Payload config.`);
|
|
86
|
+
}
|
|
87
|
+
// Find translatable fields
|
|
88
|
+
let translatableFields = resolveTranslatableFields(collectionFields);
|
|
89
|
+
// Surface-aware patterns: respects D7 `translateSlug` opt-in on the
|
|
90
|
+
// matching `perCollection` row, so slugs only flow through when
|
|
91
|
+
// explicitly enabled per surface.
|
|
92
|
+
const excludePatterns = await getEffectiveExcludePatternsForSurface(payload, config, collection);
|
|
93
|
+
// Block schemas for the schema-aware extractor path. Walks every
|
|
94
|
+
// `type: 'blocks'` field in the collection config and gathers its
|
|
95
|
+
// registered `Block` configs so the extractor can gate which keys
|
|
96
|
+
// inside a block are translatable based on the schema (fixes BUG-04
|
|
97
|
+
// family — non-text strings being treated as translatable). When the
|
|
98
|
+
// map has no entry for a given blockType, the extractor falls back to
|
|
99
|
+
// the previous heuristic walk.
|
|
100
|
+
const blocksConfig = collectBlocksConfig(collectionFields);
|
|
101
|
+
// Capture the full set of localized top-level fields BEFORE narrowing to
|
|
102
|
+
// `options.fields`. The locale-write merge needs this even when a per-
|
|
103
|
+
// field translate restricts work to a single field — Payload validates
|
|
104
|
+
// required-localized siblings on partial writes, so we must include them
|
|
105
|
+
// in writeData with their existing-target or source values.
|
|
106
|
+
const allLocalizedTopFields = new Set(translatableFields.filter((f)=>f.localized).map((f)=>f.path.split('.')[0]));
|
|
107
|
+
// Admin-configured per-surface exclusions (D6 feature). Applies to
|
|
108
|
+
// BOTH manual and automation paths — distinct from `excludePatterns`
|
|
109
|
+
// (static plugin config) and from the locale gates in
|
|
110
|
+
// `effective-locales` (automation-only). Applied BEFORE the
|
|
111
|
+
// `options.fields` narrowing below so that a manual per-field
|
|
112
|
+
// request for an excluded path returns an empty job rather than
|
|
113
|
+
// silently translating a field the admin opted out.
|
|
114
|
+
const adminExcludedPaths = await getExcludedFieldPaths(payload, config, collection);
|
|
115
|
+
if (adminExcludedPaths.size > 0) {
|
|
116
|
+
translatableFields = translatableFields.filter((f)=>!isPathExcluded(f.path, adminExcludedPaths));
|
|
117
|
+
}
|
|
118
|
+
// If caller specified specific fields, filter down
|
|
119
|
+
if (options.fields?.length) {
|
|
120
|
+
const requested = new Set(options.fields);
|
|
121
|
+
translatableFields = translatableFields.filter((f)=>requested.has(f.path));
|
|
122
|
+
}
|
|
123
|
+
if (translatableFields.length === 0) {
|
|
124
|
+
// Caller may have seeded a progress-store job (e.g. on-change hook) and
|
|
125
|
+
// threaded its id through `options.jobId`. Without this nudge the job
|
|
126
|
+
// stays `running` until the 2-minute TTL — the SSE stream keeps emitting
|
|
127
|
+
// it and the editor sees a phantom "Translating: 0/N" widget.
|
|
128
|
+
if (options.jobId) completeJobNow(options.jobId);
|
|
129
|
+
return {
|
|
130
|
+
documentId: id,
|
|
131
|
+
collection,
|
|
132
|
+
sourceLocale: config.sourceLocale,
|
|
133
|
+
succeeded: [],
|
|
134
|
+
preserved: [],
|
|
135
|
+
failed: [],
|
|
136
|
+
usage: {
|
|
137
|
+
inputTokens: 0,
|
|
138
|
+
outputTokens: 0,
|
|
139
|
+
estimatedCostUsd: 0
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
// Extract translation units
|
|
144
|
+
let units = extractTranslationUnits(sourceDoc, translatableFields, excludePatterns, blocksConfig);
|
|
145
|
+
// If previousDoc is available, filter to changed fields only.
|
|
146
|
+
// A unit whose fieldPath either (a) equals a changed field path or (b) is
|
|
147
|
+
// nested under one (e.g. unit "general.hello" under changed field "general")
|
|
148
|
+
// is kept. Plain string equality breaks for JSON / nested group units.
|
|
149
|
+
if (options.previousDoc) {
|
|
150
|
+
const fieldPaths = translatableFields.map((f)=>f.path);
|
|
151
|
+
const changedPaths = diffFields(options.previousDoc, sourceDoc, fieldPaths);
|
|
152
|
+
units = units.filter((u)=>changedPaths.some((cp)=>u.fieldPath === cp || u.fieldPath.startsWith(`${cp}.`)));
|
|
153
|
+
}
|
|
154
|
+
if (units.length === 0) {
|
|
155
|
+
if (options.jobId) completeJobNow(options.jobId);
|
|
156
|
+
return {
|
|
157
|
+
documentId: id,
|
|
158
|
+
collection,
|
|
159
|
+
sourceLocale: config.sourceLocale,
|
|
160
|
+
succeeded: [],
|
|
161
|
+
preserved: [],
|
|
162
|
+
failed: [],
|
|
163
|
+
usage: {
|
|
164
|
+
inputTokens: 0,
|
|
165
|
+
outputTokens: 0,
|
|
166
|
+
estimatedCostUsd: 0
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
// Check per-document character ceiling
|
|
171
|
+
const totalChars = units.reduce((sum, u)=>sum + u.text.length, 0);
|
|
172
|
+
try {
|
|
173
|
+
checkPerDocLimit(totalChars, config.costLimits.perDocCharCeiling);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
const costError = error;
|
|
176
|
+
emitAlert(config, {
|
|
177
|
+
type: 'translation.cost-guard-abort',
|
|
178
|
+
code: costError.code === 'PER_CALL_LIMIT' ? 'cost-guard.per-call' : 'cost-guard.per-doc',
|
|
179
|
+
context: {
|
|
180
|
+
chars: costError.characterCount,
|
|
181
|
+
limit: costError.limit,
|
|
182
|
+
collection,
|
|
183
|
+
documentId: id
|
|
184
|
+
},
|
|
185
|
+
message: costError.message,
|
|
186
|
+
documentId: id,
|
|
187
|
+
collection,
|
|
188
|
+
timestamp: new Date(),
|
|
189
|
+
metadata: {
|
|
190
|
+
characterCount: costError.characterCount,
|
|
191
|
+
limit: costError.limit
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
// Create progress tracking job — or reuse one passed in by the caller
|
|
197
|
+
// (the after-change hook seeds a job for instant UI feedback and threads
|
|
198
|
+
// its id through here so we don't end up with a phantom + real job pair)
|
|
199
|
+
const jobId = options.jobId ?? createJob(collection, id, targetLocales);
|
|
200
|
+
// Emit started event
|
|
201
|
+
emitEvent(config, {
|
|
202
|
+
type: 'translation.started',
|
|
203
|
+
documentId: id,
|
|
204
|
+
collection,
|
|
205
|
+
sourceLocale: config.sourceLocale,
|
|
206
|
+
targetLocales,
|
|
207
|
+
timestamp: new Date()
|
|
208
|
+
});
|
|
209
|
+
// Translate for each target locale with concurrency control
|
|
210
|
+
const concurrency = config.concurrency?.perDocument ?? DEFAULT_CONCURRENCY.perDocument;
|
|
211
|
+
const allSucceeded = [];
|
|
212
|
+
const allPreserved = [];
|
|
213
|
+
const allFailed = [];
|
|
214
|
+
const totalUsage = {
|
|
215
|
+
inputTokens: 0,
|
|
216
|
+
outputTokens: 0,
|
|
217
|
+
estimatedCostUsd: 0
|
|
218
|
+
};
|
|
219
|
+
let providerLatencyMs = 0;
|
|
220
|
+
const localeQueue = [
|
|
221
|
+
...targetLocales
|
|
222
|
+
];
|
|
223
|
+
const running = new Set();
|
|
224
|
+
while(localeQueue.length > 0 || running.size > 0){
|
|
225
|
+
// Fill up to concurrency limit
|
|
226
|
+
while(localeQueue.length > 0 && running.size < concurrency){
|
|
227
|
+
if (signal?.aborted) break;
|
|
228
|
+
const locale = localeQueue.shift();
|
|
229
|
+
const promise = (async ()=>{
|
|
230
|
+
try {
|
|
231
|
+
const result = await translateForLocale({
|
|
232
|
+
payload,
|
|
233
|
+
config,
|
|
234
|
+
sourceDoc,
|
|
235
|
+
documentId: id,
|
|
236
|
+
collection,
|
|
237
|
+
targetLocale: locale,
|
|
238
|
+
units,
|
|
239
|
+
fields: translatableFields,
|
|
240
|
+
allLocalizedTopFields,
|
|
241
|
+
writeMode: options.writeMode,
|
|
242
|
+
targetPolicy,
|
|
243
|
+
signal,
|
|
244
|
+
req: options.req,
|
|
245
|
+
force: options.force
|
|
246
|
+
});
|
|
247
|
+
allSucceeded.push(...result.succeeded);
|
|
248
|
+
allPreserved.push(...result.preserved ?? []);
|
|
249
|
+
allFailed.push(...result.failed);
|
|
250
|
+
totalUsage.inputTokens += result.usage.inputTokens;
|
|
251
|
+
totalUsage.outputTokens += result.usage.outputTokens;
|
|
252
|
+
totalUsage.estimatedCostUsd = (totalUsage.estimatedCostUsd ?? 0) + (result.usage.estimatedCostUsd ?? 0);
|
|
253
|
+
totalUsage.model = totalUsage.model ?? result.usage.model;
|
|
254
|
+
providerLatencyMs += result.providerLatencyMs;
|
|
255
|
+
// Per-locale tick: only real failures gate the locale's bar from
|
|
256
|
+
// green. Skipped fields (verbatim echoes, etc.) are info-level
|
|
257
|
+
// and shouldn't false-alarm the editor.
|
|
258
|
+
const realFailedLocale = result.failed.filter((f)=>f.status === 'failed');
|
|
259
|
+
updateJob(jobId, locale, realFailedLocale.length === 0, realFailedLocale.length > 0 ? `${realFailedLocale.length} field(s) failed` : undefined);
|
|
260
|
+
} catch (error) {
|
|
261
|
+
const errMsg = error instanceof Error ? error.message : 'Unknown locale error';
|
|
262
|
+
allFailed.push({
|
|
263
|
+
fieldPath: '*',
|
|
264
|
+
locale,
|
|
265
|
+
status: 'failed',
|
|
266
|
+
error: errMsg
|
|
267
|
+
});
|
|
268
|
+
updateJob(jobId, locale, false, errMsg);
|
|
269
|
+
payload.logger.error(`[ai-translate] Locale "${locale}" failed for ${collection}/${String(id)}: ${errMsg}`);
|
|
270
|
+
}
|
|
271
|
+
})();
|
|
272
|
+
const tracked = promise.then(()=>{
|
|
273
|
+
running.delete(tracked);
|
|
274
|
+
}, ()=>{
|
|
275
|
+
running.delete(tracked);
|
|
276
|
+
});
|
|
277
|
+
running.add(tracked);
|
|
278
|
+
}
|
|
279
|
+
if (running.size > 0) {
|
|
280
|
+
await Promise.race(running);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Fallback: when every locale fails before its provider call returns
|
|
284
|
+
// (e.g. 429 quota, network error), `totalUsage.model` is never stamped
|
|
285
|
+
// from a response. Surface the configured model so usage rows show what
|
|
286
|
+
// *would have* run instead of `<No Model>`.
|
|
287
|
+
if (!totalUsage.model && config.provider.model) {
|
|
288
|
+
totalUsage.model = config.provider.model;
|
|
289
|
+
}
|
|
290
|
+
// Real failures only — `skipped` results (verbatim echo, etc.) carry
|
|
291
|
+
// `status: 'skipped'` and shouldn't drive doc-level status to "failed".
|
|
292
|
+
const realFailed = allFailed.filter((f)=>f.status === 'failed');
|
|
293
|
+
// Soft skips (verbatim echo, etc.) — surface separately so the event
|
|
294
|
+
// consumer can flag "doc succeeded but N fields stayed in source".
|
|
295
|
+
const skippedReports = allFailed.filter((f)=>f.status === 'skipped').map((f)=>({
|
|
296
|
+
fieldPath: f.fieldPath,
|
|
297
|
+
locale: f.locale,
|
|
298
|
+
reason: f.error,
|
|
299
|
+
reasonCode: f.errorCode,
|
|
300
|
+
sourceValue: f.sourceValue
|
|
301
|
+
}));
|
|
302
|
+
// Emit final event
|
|
303
|
+
const eventType = realFailed.length === 0 ? 'translation.succeeded' : 'translation.failed';
|
|
304
|
+
emitEvent(config, {
|
|
305
|
+
type: eventType,
|
|
306
|
+
documentId: id,
|
|
307
|
+
collection,
|
|
308
|
+
sourceLocale: config.sourceLocale,
|
|
309
|
+
targetLocales,
|
|
310
|
+
timestamp: new Date(),
|
|
311
|
+
fields: [
|
|
312
|
+
...allSucceeded,
|
|
313
|
+
...allFailed
|
|
314
|
+
],
|
|
315
|
+
usage: totalUsage,
|
|
316
|
+
error: realFailed.length > 0 ? `${realFailed.length} field(s) failed` : undefined,
|
|
317
|
+
...skippedReports.length > 0 ? {
|
|
318
|
+
skippedFields: skippedReports
|
|
319
|
+
} : {}
|
|
320
|
+
});
|
|
321
|
+
// Persist a usage row when the consumer opted in. The helper short-circuits
|
|
322
|
+
// when usageTracking is disabled and swallows its own errors — translation
|
|
323
|
+
// results are returned to the caller regardless of whether the row landed.
|
|
324
|
+
await persistTranslationUsage({
|
|
325
|
+
payload,
|
|
326
|
+
config,
|
|
327
|
+
kind: 'collection',
|
|
328
|
+
slug: collection,
|
|
329
|
+
documentId: id,
|
|
330
|
+
jobId,
|
|
331
|
+
status: realFailed.length === 0 ? 'succeeded' : 'failed',
|
|
332
|
+
sourceLocale: config.sourceLocale,
|
|
333
|
+
localeOutcomes: bucketLocaleOutcomes(targetLocales, allSucceeded, realFailed),
|
|
334
|
+
succeededCount: allSucceeded.length,
|
|
335
|
+
failedCount: realFailed.length,
|
|
336
|
+
succeeded: allSucceeded,
|
|
337
|
+
preserved: allPreserved,
|
|
338
|
+
softSkippedFields: skippedReports,
|
|
339
|
+
usage: totalUsage,
|
|
340
|
+
durationMs: Date.now() - startedAt,
|
|
341
|
+
error: realFailed.length > 0 ? `${realFailed.length} field(s) failed` : undefined
|
|
342
|
+
});
|
|
343
|
+
return {
|
|
344
|
+
jobId,
|
|
345
|
+
documentId: id,
|
|
346
|
+
collection,
|
|
347
|
+
sourceLocale: config.sourceLocale,
|
|
348
|
+
succeeded: allSucceeded,
|
|
349
|
+
preserved: allPreserved,
|
|
350
|
+
failed: allFailed,
|
|
351
|
+
usage: totalUsage,
|
|
352
|
+
providerLatencyMs
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// Globals API
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
const NO_FALLBACK = null;
|
|
359
|
+
export async function translateGlobal(payload, options) {
|
|
360
|
+
const baseConfig = payload.config?.custom?.aiTranslate;
|
|
361
|
+
if (!baseConfig) {
|
|
362
|
+
throw new Error('[ai-translate] Plugin not configured. Add aiTranslatePlugin() to your Payload config.');
|
|
363
|
+
}
|
|
364
|
+
// Same runtime-provider resolution as translateDocument — see comment there.
|
|
365
|
+
const runtimeProvider = await resolveProvider(payload, baseConfig);
|
|
366
|
+
const config = runtimeProvider === baseConfig.provider ? baseConfig : {
|
|
367
|
+
...baseConfig,
|
|
368
|
+
provider: runtimeProvider
|
|
369
|
+
};
|
|
370
|
+
const { signal } = options;
|
|
371
|
+
const targetLocales = options.targetLocales ?? config.targetLocales;
|
|
372
|
+
const startedAt = Date.now();
|
|
373
|
+
const targetPolicy = options.targetPolicy ?? config.automation?.targetPolicy ?? DEFAULT_TARGET_POLICY;
|
|
374
|
+
// Read source global
|
|
375
|
+
const sourceDoc = await payload.findGlobal({
|
|
376
|
+
slug: options.global,
|
|
377
|
+
locale: config.sourceLocale,
|
|
378
|
+
fallbackLocale: NO_FALLBACK
|
|
379
|
+
});
|
|
380
|
+
if (!sourceDoc) {
|
|
381
|
+
throw new Error(`[ai-translate] Global not found: ${options.global}`);
|
|
382
|
+
}
|
|
383
|
+
// Resolve global field config
|
|
384
|
+
const globalFields = getGlobalFields(payload, options.global);
|
|
385
|
+
if (!globalFields) {
|
|
386
|
+
throw new Error(`[ai-translate] Global "${options.global}" not found in Payload config.`);
|
|
387
|
+
}
|
|
388
|
+
// Find translatable fields
|
|
389
|
+
let translatableFields = resolveTranslatableFields(globalFields);
|
|
390
|
+
const excludePatterns = await getEffectiveExcludePatternsForSurface(payload, config, options.global);
|
|
391
|
+
// See comment in `translateDocument` — globals can also embed
|
|
392
|
+
// `type: 'blocks'` fields (header zones, layout blocks).
|
|
393
|
+
const blocksConfig = collectBlocksConfig(globalFields);
|
|
394
|
+
// Capture the full localized-top-field set BEFORE narrowing — see comment
|
|
395
|
+
// on the matching block in `translateDocument`.
|
|
396
|
+
const allLocalizedTopFields = new Set(translatableFields.filter((f)=>f.localized).map((f)=>f.path.split('.')[0]));
|
|
397
|
+
// Admin-configured exclusions — see comment in `translateDocument`.
|
|
398
|
+
// Applies to manual + automation. For globals, `surfaceSlug` is the
|
|
399
|
+
// global slug itself (globals only have one row in `perCollection`).
|
|
400
|
+
const adminExcludedPaths = await getExcludedFieldPaths(payload, config, options.global);
|
|
401
|
+
if (adminExcludedPaths.size > 0) {
|
|
402
|
+
translatableFields = translatableFields.filter((f)=>!isPathExcluded(f.path, adminExcludedPaths));
|
|
403
|
+
}
|
|
404
|
+
// If caller specified specific fields, filter down
|
|
405
|
+
if (options.fields?.length) {
|
|
406
|
+
const requested = new Set(options.fields);
|
|
407
|
+
translatableFields = translatableFields.filter((f)=>requested.has(f.path));
|
|
408
|
+
}
|
|
409
|
+
if (translatableFields.length === 0) {
|
|
410
|
+
// See comment in `translateDocument` for the same early-return —
|
|
411
|
+
// without this nudge the seeded job stays `running` until TTL.
|
|
412
|
+
if (options.jobId) completeJobNow(options.jobId);
|
|
413
|
+
return {
|
|
414
|
+
documentId: options.global,
|
|
415
|
+
collection: options.global,
|
|
416
|
+
sourceLocale: config.sourceLocale,
|
|
417
|
+
succeeded: [],
|
|
418
|
+
preserved: [],
|
|
419
|
+
failed: [],
|
|
420
|
+
usage: {
|
|
421
|
+
inputTokens: 0,
|
|
422
|
+
outputTokens: 0,
|
|
423
|
+
estimatedCostUsd: 0
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
// Extract translation units
|
|
428
|
+
let units = extractTranslationUnits(sourceDoc, translatableFields, excludePatterns, blocksConfig);
|
|
429
|
+
// diffOnly: if previousDoc is provided, narrow units to only changed fields.
|
|
430
|
+
// Match by exact path or nested-under prefix (e.g. unit "general.title"
|
|
431
|
+
// under changed field "general") so JSON / nested group units are kept.
|
|
432
|
+
if (options.previousDoc) {
|
|
433
|
+
const fieldPaths = translatableFields.map((f)=>f.path);
|
|
434
|
+
const changedPaths = diffFields(options.previousDoc, sourceDoc, fieldPaths);
|
|
435
|
+
units = units.filter((u)=>changedPaths.some((cp)=>u.fieldPath === cp || u.fieldPath.startsWith(`${cp}.`)));
|
|
436
|
+
}
|
|
437
|
+
if (units.length === 0) {
|
|
438
|
+
if (options.jobId) completeJobNow(options.jobId);
|
|
439
|
+
return {
|
|
440
|
+
documentId: options.global,
|
|
441
|
+
collection: options.global,
|
|
442
|
+
sourceLocale: config.sourceLocale,
|
|
443
|
+
succeeded: [],
|
|
444
|
+
preserved: [],
|
|
445
|
+
failed: [],
|
|
446
|
+
usage: {
|
|
447
|
+
inputTokens: 0,
|
|
448
|
+
outputTokens: 0,
|
|
449
|
+
estimatedCostUsd: 0
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
// Check per-document character ceiling
|
|
454
|
+
const totalChars = units.reduce((sum, u)=>sum + u.text.length, 0);
|
|
455
|
+
try {
|
|
456
|
+
checkPerDocLimit(totalChars, config.costLimits.perDocCharCeiling);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
const costError = error;
|
|
459
|
+
emitAlert(config, {
|
|
460
|
+
type: 'translation.cost-guard-abort',
|
|
461
|
+
code: costError.code === 'PER_CALL_LIMIT' ? 'cost-guard.per-call' : 'cost-guard.per-doc',
|
|
462
|
+
context: {
|
|
463
|
+
chars: costError.characterCount,
|
|
464
|
+
limit: costError.limit,
|
|
465
|
+
globalSlug: options.global
|
|
466
|
+
},
|
|
467
|
+
message: costError.message,
|
|
468
|
+
documentId: options.global,
|
|
469
|
+
collection: options.global,
|
|
470
|
+
timestamp: new Date(),
|
|
471
|
+
metadata: {
|
|
472
|
+
characterCount: costError.characterCount,
|
|
473
|
+
limit: costError.limit
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
// Progress tracking job — reuse caller's jobId when provided (e.g. the
|
|
479
|
+
// on-change hook seeds one for instant UI feedback).
|
|
480
|
+
const jobId = options.jobId ?? createJob(options.global, options.global, targetLocales);
|
|
481
|
+
// Emit started event
|
|
482
|
+
emitEvent(config, {
|
|
483
|
+
type: 'translation.started',
|
|
484
|
+
documentId: options.global,
|
|
485
|
+
collection: options.global,
|
|
486
|
+
sourceLocale: config.sourceLocale,
|
|
487
|
+
targetLocales,
|
|
488
|
+
timestamp: new Date()
|
|
489
|
+
});
|
|
490
|
+
// Translate for each target locale with concurrency control
|
|
491
|
+
const concurrency = config.concurrency?.perDocument ?? DEFAULT_CONCURRENCY.perDocument;
|
|
492
|
+
const allSucceeded = [];
|
|
493
|
+
const allPreserved = [];
|
|
494
|
+
const allFailed = [];
|
|
495
|
+
const totalUsage = {
|
|
496
|
+
inputTokens: 0,
|
|
497
|
+
outputTokens: 0,
|
|
498
|
+
estimatedCostUsd: 0
|
|
499
|
+
};
|
|
500
|
+
let providerLatencyMs = 0;
|
|
501
|
+
const localeQueue = [
|
|
502
|
+
...targetLocales
|
|
503
|
+
];
|
|
504
|
+
const running = new Set();
|
|
505
|
+
while(localeQueue.length > 0 || running.size > 0){
|
|
506
|
+
while(localeQueue.length > 0 && running.size < concurrency){
|
|
507
|
+
if (signal?.aborted) break;
|
|
508
|
+
const locale = localeQueue.shift();
|
|
509
|
+
const promise = (async ()=>{
|
|
510
|
+
try {
|
|
511
|
+
const result = await translateGlobalForLocale({
|
|
512
|
+
payload,
|
|
513
|
+
config,
|
|
514
|
+
sourceDoc,
|
|
515
|
+
globalSlug: options.global,
|
|
516
|
+
targetLocale: locale,
|
|
517
|
+
units,
|
|
518
|
+
fields: translatableFields,
|
|
519
|
+
allLocalizedTopFields,
|
|
520
|
+
writeMode: options.writeMode,
|
|
521
|
+
targetPolicy,
|
|
522
|
+
signal,
|
|
523
|
+
req: options.req,
|
|
524
|
+
force: options.force
|
|
525
|
+
});
|
|
526
|
+
allSucceeded.push(...result.succeeded);
|
|
527
|
+
allPreserved.push(...result.preserved ?? []);
|
|
528
|
+
allFailed.push(...result.failed);
|
|
529
|
+
totalUsage.inputTokens += result.usage.inputTokens;
|
|
530
|
+
totalUsage.outputTokens += result.usage.outputTokens;
|
|
531
|
+
totalUsage.estimatedCostUsd = (totalUsage.estimatedCostUsd ?? 0) + (result.usage.estimatedCostUsd ?? 0);
|
|
532
|
+
totalUsage.model = totalUsage.model ?? result.usage.model;
|
|
533
|
+
providerLatencyMs += result.providerLatencyMs;
|
|
534
|
+
// See collection branch above — skipped fields are info-level,
|
|
535
|
+
// not real failures, and must not flip the locale's bar to red.
|
|
536
|
+
const realFailedLocale = result.failed.filter((f)=>f.status === 'failed');
|
|
537
|
+
updateJob(jobId, locale, realFailedLocale.length === 0, realFailedLocale.length > 0 ? `${realFailedLocale.length} field(s) failed` : undefined);
|
|
538
|
+
} catch (error) {
|
|
539
|
+
const errMsg = error instanceof Error ? error.message : 'Unknown locale error';
|
|
540
|
+
allFailed.push({
|
|
541
|
+
fieldPath: '*',
|
|
542
|
+
locale,
|
|
543
|
+
status: 'failed',
|
|
544
|
+
error: errMsg
|
|
545
|
+
});
|
|
546
|
+
updateJob(jobId, locale, false, errMsg);
|
|
547
|
+
payload.logger.error(`[ai-translate] Locale "${locale}" failed for global "${options.global}": ${errMsg}`);
|
|
548
|
+
}
|
|
549
|
+
})();
|
|
550
|
+
const tracked = promise.then(()=>{
|
|
551
|
+
running.delete(tracked);
|
|
552
|
+
}, ()=>{
|
|
553
|
+
running.delete(tracked);
|
|
554
|
+
});
|
|
555
|
+
running.add(tracked);
|
|
556
|
+
}
|
|
557
|
+
if (running.size > 0) {
|
|
558
|
+
await Promise.race(running);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Same model fallback as the collection path — see comment above.
|
|
562
|
+
if (!totalUsage.model && config.provider.model) {
|
|
563
|
+
totalUsage.model = config.provider.model;
|
|
564
|
+
}
|
|
565
|
+
// Real failures only — see comment in collection branch above.
|
|
566
|
+
const realFailed = allFailed.filter((f)=>f.status === 'failed');
|
|
567
|
+
const skippedReports = allFailed.filter((f)=>f.status === 'skipped').map((f)=>({
|
|
568
|
+
fieldPath: f.fieldPath,
|
|
569
|
+
locale: f.locale,
|
|
570
|
+
reason: f.error,
|
|
571
|
+
reasonCode: f.errorCode,
|
|
572
|
+
sourceValue: f.sourceValue
|
|
573
|
+
}));
|
|
574
|
+
// Emit final event
|
|
575
|
+
const eventType = realFailed.length === 0 ? 'translation.succeeded' : 'translation.failed';
|
|
576
|
+
emitEvent(config, {
|
|
577
|
+
type: eventType,
|
|
578
|
+
documentId: options.global,
|
|
579
|
+
collection: options.global,
|
|
580
|
+
sourceLocale: config.sourceLocale,
|
|
581
|
+
targetLocales,
|
|
582
|
+
timestamp: new Date(),
|
|
583
|
+
fields: [
|
|
584
|
+
...allSucceeded,
|
|
585
|
+
...allFailed
|
|
586
|
+
],
|
|
587
|
+
usage: totalUsage,
|
|
588
|
+
error: realFailed.length > 0 ? `${realFailed.length} field(s) failed` : undefined,
|
|
589
|
+
...skippedReports.length > 0 ? {
|
|
590
|
+
skippedFields: skippedReports
|
|
591
|
+
} : {}
|
|
592
|
+
});
|
|
593
|
+
// Persist usage row when feature is enabled. Same contract as the
|
|
594
|
+
// collection branch — helper short-circuits + swallows errors.
|
|
595
|
+
await persistTranslationUsage({
|
|
596
|
+
payload,
|
|
597
|
+
config,
|
|
598
|
+
kind: 'global',
|
|
599
|
+
slug: options.global,
|
|
600
|
+
documentId: options.global,
|
|
601
|
+
jobId,
|
|
602
|
+
status: realFailed.length === 0 ? 'succeeded' : 'failed',
|
|
603
|
+
sourceLocale: config.sourceLocale,
|
|
604
|
+
localeOutcomes: bucketLocaleOutcomes(targetLocales, allSucceeded, realFailed),
|
|
605
|
+
succeededCount: allSucceeded.length,
|
|
606
|
+
failedCount: realFailed.length,
|
|
607
|
+
succeeded: allSucceeded,
|
|
608
|
+
preserved: allPreserved,
|
|
609
|
+
softSkippedFields: skippedReports,
|
|
610
|
+
usage: totalUsage,
|
|
611
|
+
durationMs: Date.now() - startedAt,
|
|
612
|
+
error: realFailed.length > 0 ? `${realFailed.length} field(s) failed` : undefined
|
|
613
|
+
});
|
|
614
|
+
return {
|
|
615
|
+
jobId,
|
|
616
|
+
documentId: options.global,
|
|
617
|
+
collection: options.global,
|
|
618
|
+
sourceLocale: config.sourceLocale,
|
|
619
|
+
succeeded: allSucceeded,
|
|
620
|
+
preserved: allPreserved,
|
|
621
|
+
failed: allFailed,
|
|
622
|
+
usage: totalUsage,
|
|
623
|
+
providerLatencyMs
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
async function translateGlobalForLocale(params) {
|
|
627
|
+
const { payload, config, sourceDoc, globalSlug, targetLocale, fields, allLocalizedTopFields, writeMode, targetPolicy, signal, req, force } = params;
|
|
628
|
+
let { units } = params;
|
|
629
|
+
const succeeded = [];
|
|
630
|
+
const preserved = [];
|
|
631
|
+
const failed = [];
|
|
632
|
+
const totalUsage = {
|
|
633
|
+
inputTokens: 0,
|
|
634
|
+
outputTokens: 0,
|
|
635
|
+
estimatedCostUsd: 0
|
|
636
|
+
};
|
|
637
|
+
let providerLatencyMs = 0;
|
|
638
|
+
if (targetLocale === config.sourceLocale) {
|
|
639
|
+
return {
|
|
640
|
+
succeeded,
|
|
641
|
+
preserved,
|
|
642
|
+
failed,
|
|
643
|
+
usage: totalUsage,
|
|
644
|
+
providerLatencyMs
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
// Preserve policy: skip fields that already have content in the target locale
|
|
648
|
+
if (targetPolicy === 'preserve') {
|
|
649
|
+
units = await filterPreservedGlobalFields(payload, globalSlug, targetLocale, units, fields);
|
|
650
|
+
}
|
|
651
|
+
if (units.length === 0) {
|
|
652
|
+
return {
|
|
653
|
+
succeeded,
|
|
654
|
+
preserved,
|
|
655
|
+
failed,
|
|
656
|
+
usage: totalUsage,
|
|
657
|
+
providerLatencyMs
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
// BUG-21 hash-skip preflight for globals (1.2.8). Previously globals
|
|
661
|
+
// bypassed this path entirely — every re-translate re-hit the LLM for
|
|
662
|
+
// every field even when the source content was unchanged. Now mirrors
|
|
663
|
+
// the collection path in `translate.ts`: load stored source+target
|
|
664
|
+
// hashes, drop units whose top-level field hashes match on both sides,
|
|
665
|
+
// surface those as hash-skipped successes (characterCount = 0,
|
|
666
|
+
// durationMs = 0). `force: true` callers bypass — see
|
|
667
|
+
// `TranslateGlobalOptions.force`.
|
|
668
|
+
if (config.preserveManualEdits && !force) {
|
|
669
|
+
const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
|
|
670
|
+
const skip = await applyHashSkip({
|
|
671
|
+
payload,
|
|
672
|
+
metaCollection: metaSlug,
|
|
673
|
+
surfaceSlug: globalSlug,
|
|
674
|
+
documentId: globalSlug,
|
|
675
|
+
targetLocale,
|
|
676
|
+
units,
|
|
677
|
+
sourceDoc,
|
|
678
|
+
loadTargetDoc: ()=>findGlobalNoFallback(payload, globalSlug, targetLocale)
|
|
679
|
+
});
|
|
680
|
+
units = skip.remainingUnits;
|
|
681
|
+
succeeded.push(...skip.hashSkipped);
|
|
682
|
+
if (units.length === 0) {
|
|
683
|
+
return {
|
|
684
|
+
succeeded,
|
|
685
|
+
preserved,
|
|
686
|
+
failed,
|
|
687
|
+
usage: totalUsage,
|
|
688
|
+
providerLatencyMs
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const batches = batchUnits(units, config.costLimits.perCallCharLimit, config.costLimits.perCallItemLimit);
|
|
693
|
+
const retryConfig = {
|
|
694
|
+
...DEFAULT_RETRY,
|
|
695
|
+
...config.retry ?? {}
|
|
696
|
+
};
|
|
697
|
+
const translatedMap = new Map();
|
|
698
|
+
for (const batch of batches){
|
|
699
|
+
if (signal?.aborted) {
|
|
700
|
+
for (const unit of batch){
|
|
701
|
+
failed.push({
|
|
702
|
+
fieldPath: unit.fieldPath,
|
|
703
|
+
locale: targetLocale,
|
|
704
|
+
status: 'failed',
|
|
705
|
+
error: 'Aborted'
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
const batchResult = await translateBatch({
|
|
711
|
+
batch,
|
|
712
|
+
config,
|
|
713
|
+
collection: globalSlug,
|
|
714
|
+
targetLocale,
|
|
715
|
+
signal,
|
|
716
|
+
retryConfig
|
|
717
|
+
});
|
|
718
|
+
totalUsage.inputTokens += batchResult.usage.inputTokens;
|
|
719
|
+
totalUsage.outputTokens += batchResult.usage.outputTokens;
|
|
720
|
+
totalUsage.estimatedCostUsd = (totalUsage.estimatedCostUsd ?? 0) + (batchResult.usage.estimatedCostUsd ?? 0);
|
|
721
|
+
totalUsage.model = totalUsage.model ?? batchResult.usage.model;
|
|
722
|
+
providerLatencyMs += batchResult.latencyMs;
|
|
723
|
+
for (const unit of batch){
|
|
724
|
+
const translated = batchResult.translations.get(unit.id);
|
|
725
|
+
if (translated !== undefined) {
|
|
726
|
+
translatedMap.set(unit.id, translated);
|
|
727
|
+
succeeded.push({
|
|
728
|
+
fieldPath: unit.fieldPath,
|
|
729
|
+
locale: targetLocale,
|
|
730
|
+
status: 'success',
|
|
731
|
+
characterCount: translated.length,
|
|
732
|
+
durationMs: batchResult.latencyMs,
|
|
733
|
+
...unit.kind === 'plain' ? {
|
|
734
|
+
translatedText: translated
|
|
735
|
+
} : {}
|
|
736
|
+
});
|
|
737
|
+
} else if (batchResult.skipped.has(unit.id)) {
|
|
738
|
+
failed.push({
|
|
739
|
+
fieldPath: unit.fieldPath,
|
|
740
|
+
locale: targetLocale,
|
|
741
|
+
status: 'skipped',
|
|
742
|
+
error: batchResult.skipped.get(unit.id),
|
|
743
|
+
sourceValue: truncateSourceValue(unit.text)
|
|
744
|
+
});
|
|
745
|
+
} else {
|
|
746
|
+
const err = batchResult.errors.get(unit.id);
|
|
747
|
+
failed.push({
|
|
748
|
+
fieldPath: unit.fieldPath,
|
|
749
|
+
locale: targetLocale,
|
|
750
|
+
status: 'failed',
|
|
751
|
+
error: err ?? 'No translation returned'
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
// Read the existing target-locale doc and merge translations onto it,
|
|
757
|
+
// then write only the translated top-level fields. Reading from the
|
|
758
|
+
// target locale (rather than cloning the source) is what keeps Payload
|
|
759
|
+
// internal columns from leaking into the update body and lets
|
|
760
|
+
// required-localized siblings stay populated when we only change a
|
|
761
|
+
// subset of fields.
|
|
762
|
+
if (translatedMap.size > 0) {
|
|
763
|
+
const translatedTopFields = new Set();
|
|
764
|
+
for (const unit of units){
|
|
765
|
+
if (translatedMap.has(unit.id)) {
|
|
766
|
+
translatedTopFields.add(unit.fieldPath.split('.')[0]);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
let targetDoc = null;
|
|
770
|
+
try {
|
|
771
|
+
targetDoc = await findGlobalNoFallback(payload, globalSlug, targetLocale);
|
|
772
|
+
} catch {
|
|
773
|
+
targetDoc = null;
|
|
774
|
+
}
|
|
775
|
+
const writeData = mergeTranslationsIntoTargetDoc({
|
|
776
|
+
sourceDoc,
|
|
777
|
+
targetDoc,
|
|
778
|
+
translatedMap,
|
|
779
|
+
fields,
|
|
780
|
+
translatedTopFields,
|
|
781
|
+
allLocalizedTopFields,
|
|
782
|
+
writeMode
|
|
783
|
+
});
|
|
784
|
+
// BUG-25 fix: skip the DB write when writeData is empty (everything
|
|
785
|
+
// got preserved by manual-edit guard or merge produced no diff).
|
|
786
|
+
// Mirrors the collection-level fix in translate.ts.
|
|
787
|
+
if (Object.keys(writeData).length === 0) {
|
|
788
|
+
payload.logger?.info?.(`[ai-translate] No fields to write for global "${globalSlug}" locale=${targetLocale} — skipping payload.updateGlobal.`);
|
|
789
|
+
} else {
|
|
790
|
+
try {
|
|
791
|
+
// See comment in translate.ts — fresh transactionID per locale
|
|
792
|
+
// write so concurrent updates don't collide on shared junction
|
|
793
|
+
// tables. `req.user` and friends are preserved for audit-log.
|
|
794
|
+
const writeReq = req ? {
|
|
795
|
+
...req,
|
|
796
|
+
transactionID: undefined
|
|
797
|
+
} : undefined;
|
|
798
|
+
// Same allowlist pattern as translateForLocale (translate.ts:478).
|
|
799
|
+
// Only `disableRevalidate` flows through — naive `...writeReq.context`
|
|
800
|
+
// would propagate audit-log's AUDIT_CONTEXT_KEY into the global
|
|
801
|
+
// write's afterChange and mis-attribute the audit row.
|
|
802
|
+
const forwardedContext = {};
|
|
803
|
+
if (writeReq?.context && 'disableRevalidate' in writeReq.context) {
|
|
804
|
+
forwardedContext.disableRevalidate = writeReq.context.disableRevalidate;
|
|
805
|
+
}
|
|
806
|
+
await payload.updateGlobal({
|
|
807
|
+
slug: globalSlug,
|
|
808
|
+
locale: targetLocale,
|
|
809
|
+
data: writeData,
|
|
810
|
+
context: {
|
|
811
|
+
...forwardedContext,
|
|
812
|
+
[REENTRY_FLAG]: true
|
|
813
|
+
},
|
|
814
|
+
...writeReq ? {
|
|
815
|
+
req: writeReq
|
|
816
|
+
} : {}
|
|
817
|
+
});
|
|
818
|
+
// Persist source+target hashes for next-translation hash-skip
|
|
819
|
+
// (1.2.8). Mirrors `translate.ts` for collections. Best-effort —
|
|
820
|
+
// failure here doesn't roll back the successful translation write
|
|
821
|
+
// above.
|
|
822
|
+
if (config.preserveManualEdits) {
|
|
823
|
+
const metaSlug = config.manualEditCollectionSlug ?? DEFAULT_MANUAL_EDIT_COLLECTION_SLUG;
|
|
824
|
+
await recordHashesAfterWrite({
|
|
825
|
+
payload,
|
|
826
|
+
metaCollection: metaSlug,
|
|
827
|
+
surfaceSlug: globalSlug,
|
|
828
|
+
documentId: globalSlug,
|
|
829
|
+
targetLocale,
|
|
830
|
+
writeData,
|
|
831
|
+
sourceDoc
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
} catch (error) {
|
|
835
|
+
const errMsg = error instanceof Error ? error.message : 'Unknown write error';
|
|
836
|
+
payload.logger.error(`[ai-translate] Failed to write translations for global "${globalSlug}" locale=${targetLocale}: ${errMsg}`);
|
|
837
|
+
for (const result of succeeded){
|
|
838
|
+
failed.push({
|
|
839
|
+
...result,
|
|
840
|
+
status: 'failed',
|
|
841
|
+
error: `Write failed: ${errMsg}`
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
succeeded.length = 0;
|
|
845
|
+
}
|
|
846
|
+
} // end else (non-empty)
|
|
847
|
+
}
|
|
848
|
+
// Emit per-field events
|
|
849
|
+
const isCanary = targetLocale === config.quality?.canaryLocale;
|
|
850
|
+
for (const result of [
|
|
851
|
+
...succeeded,
|
|
852
|
+
...failed
|
|
853
|
+
]){
|
|
854
|
+
emitEvent(config, {
|
|
855
|
+
type: 'translation.field',
|
|
856
|
+
documentId: globalSlug,
|
|
857
|
+
collection: globalSlug,
|
|
858
|
+
sourceLocale: config.sourceLocale,
|
|
859
|
+
targetLocales: [
|
|
860
|
+
targetLocale
|
|
861
|
+
],
|
|
862
|
+
timestamp: new Date(),
|
|
863
|
+
fields: [
|
|
864
|
+
result
|
|
865
|
+
],
|
|
866
|
+
canary: isCanary || undefined
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
return {
|
|
870
|
+
succeeded,
|
|
871
|
+
preserved,
|
|
872
|
+
failed,
|
|
873
|
+
usage: totalUsage,
|
|
874
|
+
providerLatencyMs
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
// ---------------------------------------------------------------------------
|
|
878
|
+
// Global preserve-policy filter
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
async function filterPreservedGlobalFields(payload, globalSlug, targetLocale, units, fields) {
|
|
881
|
+
try {
|
|
882
|
+
const targetDoc = await payload.findGlobal({
|
|
883
|
+
slug: globalSlug,
|
|
884
|
+
locale: targetLocale,
|
|
885
|
+
fallbackLocale: NO_FALLBACK
|
|
886
|
+
});
|
|
887
|
+
return units.filter((unit)=>{
|
|
888
|
+
const field = fields.find((f)=>unit.fieldPath === f.path || unit.fieldPath.startsWith(`${f.path}.`));
|
|
889
|
+
if (!field) return true;
|
|
890
|
+
const targetValue = resolvePathValue(targetDoc, field.path);
|
|
891
|
+
return isFieldEmpty(targetValue, field.type);
|
|
892
|
+
});
|
|
893
|
+
} catch {
|
|
894
|
+
return units;
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
function resolvePathValue(obj, path) {
|
|
898
|
+
const segments = path.split('.');
|
|
899
|
+
let current = obj;
|
|
900
|
+
for (const segment of segments){
|
|
901
|
+
if (current == null || typeof current !== 'object') return undefined;
|
|
902
|
+
current = current[segment];
|
|
903
|
+
}
|
|
904
|
+
return current;
|
|
905
|
+
}
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
// Helpers
|
|
908
|
+
// ---------------------------------------------------------------------------
|
|
909
|
+
function getCollectionFields(payload, collectionSlug) {
|
|
910
|
+
const collections = payload.config?.collections ?? [];
|
|
911
|
+
const collection = collections.find((c)=>c.slug === collectionSlug);
|
|
912
|
+
return collection?.fields;
|
|
913
|
+
}
|
|
914
|
+
function getGlobalFields(payload, globalSlug) {
|
|
915
|
+
const globals = payload.config?.globals ?? [];
|
|
916
|
+
const global = globals.find((g)=>g.slug === globalSlug);
|
|
917
|
+
return global?.fields;
|
|
918
|
+
}
|