@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,164 @@
|
|
|
1
|
+
const TRANSLATABLE_LEAF_TYPES = new Set([
|
|
2
|
+
'text',
|
|
3
|
+
'textarea',
|
|
4
|
+
'richText',
|
|
5
|
+
'json'
|
|
6
|
+
]);
|
|
7
|
+
const CONTAINER_TYPES = new Set([
|
|
8
|
+
'group',
|
|
9
|
+
'array',
|
|
10
|
+
'blocks'
|
|
11
|
+
]);
|
|
12
|
+
const TRANSPARENT_TYPES = new Set([
|
|
13
|
+
'row',
|
|
14
|
+
'collapsible'
|
|
15
|
+
]);
|
|
16
|
+
function buildPath(parent, segment) {
|
|
17
|
+
return parent ? `${parent}.${segment}` : segment;
|
|
18
|
+
}
|
|
19
|
+
function isNamedTab(tab) {
|
|
20
|
+
return 'name' in tab && typeof tab.name === 'string';
|
|
21
|
+
}
|
|
22
|
+
function hasFieldsProperty(field) {
|
|
23
|
+
return 'fields' in field && Array.isArray(field.fields);
|
|
24
|
+
}
|
|
25
|
+
function hasBlocksProperty(field) {
|
|
26
|
+
return 'blocks' in field && Array.isArray(field.blocks);
|
|
27
|
+
}
|
|
28
|
+
function hasTabsProperty(field) {
|
|
29
|
+
return 'tabs' in field && Array.isArray(field.tabs);
|
|
30
|
+
}
|
|
31
|
+
function getFieldName(field) {
|
|
32
|
+
return 'name' in field ? field.name : undefined;
|
|
33
|
+
}
|
|
34
|
+
function isLocalized(field) {
|
|
35
|
+
return 'localized' in field && field.localized === true;
|
|
36
|
+
}
|
|
37
|
+
export function resolveTranslatableFields(fields, parentPath = '', inheritedLocalized = false) {
|
|
38
|
+
const result = [];
|
|
39
|
+
for (const field of fields){
|
|
40
|
+
const { type } = field;
|
|
41
|
+
// Transparent layout containers — recurse without adding a path segment
|
|
42
|
+
if (TRANSPARENT_TYPES.has(type)) {
|
|
43
|
+
if (hasFieldsProperty(field)) {
|
|
44
|
+
result.push(...resolveTranslatableFields(field.fields, parentPath, inheritedLocalized));
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// Tabs — each tab's fields are traversed; named tabs add a path segment
|
|
49
|
+
if (type === 'tabs' && hasTabsProperty(field)) {
|
|
50
|
+
for (const tab of field.tabs){
|
|
51
|
+
const tabPath = isNamedTab(tab) ? buildPath(parentPath, tab.name) : parentPath;
|
|
52
|
+
result.push(...resolveTranslatableFields(tab.fields, tabPath, inheritedLocalized));
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// From here, only named fields matter
|
|
57
|
+
const name = getFieldName(field);
|
|
58
|
+
if (!name) continue;
|
|
59
|
+
// Payload auto-injects an `id` text field onto every array item at runtime.
|
|
60
|
+
// It carries the row's UUID and must never be sent through translation —
|
|
61
|
+
// the locale-update would also reject the write since `id` is read-only on
|
|
62
|
+
// partial updates.
|
|
63
|
+
if (name === 'id' && type === 'text') continue;
|
|
64
|
+
const fieldPath = buildPath(parentPath, name);
|
|
65
|
+
// A field is translatable if it's marked localized OR an ancestor
|
|
66
|
+
// container (array/blocks/group) is localized. Payload localizes the
|
|
67
|
+
// entire container per locale, so descendants implicitly need translation
|
|
68
|
+
// even when not individually flagged `localized: true`.
|
|
69
|
+
const localized = isLocalized(field) || inheritedLocalized;
|
|
70
|
+
// Translatable leaf types
|
|
71
|
+
if (TRANSLATABLE_LEAF_TYPES.has(type)) {
|
|
72
|
+
if (localized) {
|
|
73
|
+
result.push({
|
|
74
|
+
path: fieldPath,
|
|
75
|
+
type: type,
|
|
76
|
+
localized: true
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
// Group
|
|
82
|
+
if (type === 'group' && hasFieldsProperty(field)) {
|
|
83
|
+
if (localized) {
|
|
84
|
+
result.push({
|
|
85
|
+
path: fieldPath,
|
|
86
|
+
type: 'group',
|
|
87
|
+
localized: true
|
|
88
|
+
});
|
|
89
|
+
result.push(...resolveTranslatableFields(field.fields, fieldPath, true));
|
|
90
|
+
} else {
|
|
91
|
+
result.push(...resolveTranslatableFields(field.fields, fieldPath, false));
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// Array
|
|
96
|
+
if (type === 'array' && hasFieldsProperty(field)) {
|
|
97
|
+
if (localized) {
|
|
98
|
+
result.push({
|
|
99
|
+
path: fieldPath,
|
|
100
|
+
type: 'array',
|
|
101
|
+
localized: true
|
|
102
|
+
});
|
|
103
|
+
// Recurse so descendant leaves are discoverable by the extractor.
|
|
104
|
+
// All descendants inherit localization from this array.
|
|
105
|
+
result.push(...resolveTranslatableFields(field.fields, fieldPath, true));
|
|
106
|
+
} else {
|
|
107
|
+
// Items are indexed at runtime; recurse with arrayName prefix so
|
|
108
|
+
// individual localized fields inside the array are discovered.
|
|
109
|
+
const children = resolveTranslatableFields(field.fields, fieldPath, false);
|
|
110
|
+
// If any descendant is localized (e.g. a navigation `label` field
|
|
111
|
+
// marked `localized: true` while the parent array is shared across
|
|
112
|
+
// locales — the canonical Payload pattern for "structure shared,
|
|
113
|
+
// labels per-locale"), we still need to emit the array entry so
|
|
114
|
+
// `extractTranslationUnits` calls `extractArrayUnits` and iterates
|
|
115
|
+
// items by index. Without this, leaf paths like
|
|
116
|
+
// `items.label` are emitted but never resolved — `getValueAtPath`
|
|
117
|
+
// bails at the array because `'label'` isn't a numeric segment.
|
|
118
|
+
if (children.some((c)=>c.localized)) {
|
|
119
|
+
result.push({
|
|
120
|
+
path: fieldPath,
|
|
121
|
+
type: 'array',
|
|
122
|
+
localized: false
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
result.push(...children);
|
|
126
|
+
}
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Blocks
|
|
130
|
+
if (type === 'blocks' && hasBlocksProperty(field)) {
|
|
131
|
+
if (localized) {
|
|
132
|
+
result.push({
|
|
133
|
+
path: fieldPath,
|
|
134
|
+
type: 'blocks',
|
|
135
|
+
localized: true
|
|
136
|
+
});
|
|
137
|
+
for (const block of field.blocks){
|
|
138
|
+
result.push(...resolveTranslatableFields(block.fields, fieldPath, true));
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
// Same rationale as the array branch above: when any block's
|
|
142
|
+
// descendant is individually localized, emit the blocks entry so
|
|
143
|
+
// `extractBlocksUnits` runs and resolves leaves through runtime
|
|
144
|
+
// indexing. Without it, paths inside blocks-children are emitted
|
|
145
|
+
// but never resolved by the extractor.
|
|
146
|
+
const children = [];
|
|
147
|
+
for (const block of field.blocks){
|
|
148
|
+
children.push(...resolveTranslatableFields(block.fields, fieldPath, false));
|
|
149
|
+
}
|
|
150
|
+
if (children.some((c)=>c.localized)) {
|
|
151
|
+
result.push({
|
|
152
|
+
path: fieldPath,
|
|
153
|
+
type: 'blocks',
|
|
154
|
+
localized: false
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
result.push(...children);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// All other types (number, email, date, select, radio, checkbox,
|
|
161
|
+
// json, code, point, relationship, upload, ui, join) — skip
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group soft-skipped fields across locales by their source value.
|
|
3
|
+
*
|
|
4
|
+
* The Translation Hub renders these for editors who want to understand
|
|
5
|
+
* which source strings the AI declined to translate. Today the AI may
|
|
6
|
+
* echo a brand name ("Wheel Of") across every locale — surfacing that
|
|
7
|
+
* once per (field × locale) drowns the editor in repetition. Grouping
|
|
8
|
+
* by value collapses the noise:
|
|
9
|
+
*
|
|
10
|
+
* "Wheel Of"
|
|
11
|
+
* used in: sidebar.featured_sidebar_items.3.label
|
|
12
|
+
* kept in: de, es, fr (3 locales)
|
|
13
|
+
*
|
|
14
|
+
* Falls back to grouping by path for legacy rows where `sourceValue`
|
|
15
|
+
* was never persisted (pre-1.2.8) — those entries display the path
|
|
16
|
+
* alone, same as the pre-grouping rendering.
|
|
17
|
+
*/
|
|
18
|
+
export type SoftSkipEntry = {
|
|
19
|
+
path: string;
|
|
20
|
+
locale: string;
|
|
21
|
+
reason?: string | null;
|
|
22
|
+
reasonCode?: string | null;
|
|
23
|
+
sourceValue?: string | null;
|
|
24
|
+
};
|
|
25
|
+
export type SoftSkipGroup = {
|
|
26
|
+
/** Source-locale text. Null when the entry is a legacy row with no value. */
|
|
27
|
+
sourceValue: string | null;
|
|
28
|
+
/** All field paths this value appears in (deduped, original order). */
|
|
29
|
+
paths: string[];
|
|
30
|
+
/** All locales the AI kept this value verbatim in (deduped, original order). */
|
|
31
|
+
locales: string[];
|
|
32
|
+
/** The first non-empty reason code in the group — used for the editor message. */
|
|
33
|
+
reasonCode: string | null;
|
|
34
|
+
/** First raw reason string seen in the group — used as the technical-details fallback. */
|
|
35
|
+
reason: string | null;
|
|
36
|
+
/** Total number of (path × locale) entries collapsed into this group. */
|
|
37
|
+
count: number;
|
|
38
|
+
};
|
|
39
|
+
export declare function groupSoftSkipsByValue(entries: ReadonlyArray<SoftSkipEntry>): SoftSkipGroup[];
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group soft-skipped fields across locales by their source value.
|
|
3
|
+
*
|
|
4
|
+
* The Translation Hub renders these for editors who want to understand
|
|
5
|
+
* which source strings the AI declined to translate. Today the AI may
|
|
6
|
+
* echo a brand name ("Wheel Of") across every locale — surfacing that
|
|
7
|
+
* once per (field × locale) drowns the editor in repetition. Grouping
|
|
8
|
+
* by value collapses the noise:
|
|
9
|
+
*
|
|
10
|
+
* "Wheel Of"
|
|
11
|
+
* used in: sidebar.featured_sidebar_items.3.label
|
|
12
|
+
* kept in: de, es, fr (3 locales)
|
|
13
|
+
*
|
|
14
|
+
* Falls back to grouping by path for legacy rows where `sourceValue`
|
|
15
|
+
* was never persisted (pre-1.2.8) — those entries display the path
|
|
16
|
+
* alone, same as the pre-grouping rendering.
|
|
17
|
+
*/ export function groupSoftSkipsByValue(entries) {
|
|
18
|
+
const buckets = new Map();
|
|
19
|
+
for (const e of entries){
|
|
20
|
+
// Bucket on source value when present; fall back to path so legacy
|
|
21
|
+
// rows (no sourceValue) still appear once per path rather than
|
|
22
|
+
// collapsing every legacy entry into a single noise bucket.
|
|
23
|
+
const bucketKey = e.sourceValue ? `v:${e.sourceValue}` : `p:${e.path}`;
|
|
24
|
+
let group = buckets.get(bucketKey);
|
|
25
|
+
if (!group) {
|
|
26
|
+
group = {
|
|
27
|
+
sourceValue: e.sourceValue ?? null,
|
|
28
|
+
paths: [],
|
|
29
|
+
locales: [],
|
|
30
|
+
reasonCode: e.reasonCode ?? null,
|
|
31
|
+
reason: e.reason ?? null,
|
|
32
|
+
count: 0
|
|
33
|
+
};
|
|
34
|
+
buckets.set(bucketKey, group);
|
|
35
|
+
}
|
|
36
|
+
if (!group.paths.includes(e.path)) group.paths.push(e.path);
|
|
37
|
+
if (!group.locales.includes(e.locale)) group.locales.push(e.locale);
|
|
38
|
+
if (!group.reasonCode && e.reasonCode) group.reasonCode = e.reasonCode;
|
|
39
|
+
if (!group.reason && e.reason) group.reason = e.reason;
|
|
40
|
+
group.count += 1;
|
|
41
|
+
}
|
|
42
|
+
return [
|
|
43
|
+
...buckets.values()
|
|
44
|
+
];
|
|
45
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { TranslatableField } from '../types.js';
|
|
2
|
+
export type MergeParams = {
|
|
3
|
+
sourceDoc: Record<string, unknown>;
|
|
4
|
+
targetDoc: Record<string, unknown> | null | undefined;
|
|
5
|
+
translatedMap: Map<string, string>;
|
|
6
|
+
fields: TranslatableField[];
|
|
7
|
+
translatedTopFields: Set<string>;
|
|
8
|
+
/**
|
|
9
|
+
* When provided, overrides the union derived from `fields`. Used by callers
|
|
10
|
+
* that filter `fields` down to a subset (e.g. per-field translate) but
|
|
11
|
+
* still need the merge to include all localized top-level fields so
|
|
12
|
+
* required-localized siblings stay populated in the locale-write body.
|
|
13
|
+
*/
|
|
14
|
+
allLocalizedTopFields?: Set<string>;
|
|
15
|
+
/**
|
|
16
|
+
* `'full'` (default): emit every localized top-level field, filling
|
|
17
|
+
* untranslated siblings from existing target / source fallback. Safe
|
|
18
|
+
* default — Payload's locale-write validation always passes.
|
|
19
|
+
*
|
|
20
|
+
* `'minimal'`: emit ONLY top-level fields that actually got translated.
|
|
21
|
+
* Caller accepts that Payload may reject the write when other localized
|
|
22
|
+
* required fields are empty in target. Useful when target is known to be
|
|
23
|
+
* fully populated and the caller doesn't want to touch siblings.
|
|
24
|
+
*/
|
|
25
|
+
writeMode?: 'full' | 'minimal';
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Build the writeData object for a locale-specific update.
|
|
29
|
+
*
|
|
30
|
+
* Strategy: for each top-level field that has at least one translated unit,
|
|
31
|
+
* start from the existing target-locale value (so we preserve any prior
|
|
32
|
+
* translations + required-localized siblings), fall back to the source value
|
|
33
|
+
* when target is empty/missing, then overlay the new translations via the
|
|
34
|
+
* existing patcher. Strip Payload internal columns recursively before
|
|
35
|
+
* returning.
|
|
36
|
+
*
|
|
37
|
+
* Why merge target-first: `payload.update({ locale, data })` validates the
|
|
38
|
+
* data against the full schema, so if a required-localized field is missing
|
|
39
|
+
* in the target locale Payload rejects the write. Reading the target locale
|
|
40
|
+
* (via `findByIdNoFallback`/`findGlobalNoFallback`) gives us the existing
|
|
41
|
+
* shape we can safely overlay. When the target is fresh (no rows yet) we
|
|
42
|
+
* fall back to the source clone — Payload generates ids on insert.
|
|
43
|
+
*/
|
|
44
|
+
export declare function mergeTranslationsIntoTargetDoc(params: MergeParams): Record<string, unknown>;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { patchTranslatedContent } from './content-patcher.js';
|
|
2
|
+
// Fields Payload either auto-generates or rejects on update. Stripped
|
|
3
|
+
// recursively from the merge output before write so the locale update body
|
|
4
|
+
// never carries internals that round-tripped from a `findByID` read.
|
|
5
|
+
//
|
|
6
|
+
// `_locale`, `_parent_id`, `_uuid` are the row-level columns Payload exposes
|
|
7
|
+
// for nested localized rows (arrays, blocks, groups). They surface in the
|
|
8
|
+
// JSON shape returned by the local API but Payload's update validator
|
|
9
|
+
// rejects them as invalid input.
|
|
10
|
+
// Fields Payload either auto-generates or rejects on update. They CAN
|
|
11
|
+
// surface in the source doc the merge is built from (relationship
|
|
12
|
+
// hydration, nested upload metadata) but must never be sent back via
|
|
13
|
+
// `payload.update` — Payload either generates them server-side or
|
|
14
|
+
// rejects them as invalid input.
|
|
15
|
+
//
|
|
16
|
+
// Notably absent: `url`, `filename`, `thumbnailURL`. Those would catch
|
|
17
|
+
// auto-generated media-doc fields IF a Media collection were translated
|
|
18
|
+
// directly, but they're also legitimate text-field names users give to
|
|
19
|
+
// regular content (link `url`, slug-like `filename`). Stripping them
|
|
20
|
+
// blanket breaks normal writes. The locale-write only emits translated
|
|
21
|
+
// top-level fields anyway, so a Media doc's auto-generated `url` never
|
|
22
|
+
// enters writeData unless the consumer explicitly translates it.
|
|
23
|
+
const STRIP_FIELDS = new Set([
|
|
24
|
+
// Doc-level system fields
|
|
25
|
+
'createdAt',
|
|
26
|
+
'updatedAt',
|
|
27
|
+
'_status',
|
|
28
|
+
// Localization internals
|
|
29
|
+
'_locale',
|
|
30
|
+
'_parent_id',
|
|
31
|
+
'_uuid',
|
|
32
|
+
// nestedDocs plugin
|
|
33
|
+
'slugLock',
|
|
34
|
+
'breadcrumbs'
|
|
35
|
+
]);
|
|
36
|
+
/**
|
|
37
|
+
* Build the writeData object for a locale-specific update.
|
|
38
|
+
*
|
|
39
|
+
* Strategy: for each top-level field that has at least one translated unit,
|
|
40
|
+
* start from the existing target-locale value (so we preserve any prior
|
|
41
|
+
* translations + required-localized siblings), fall back to the source value
|
|
42
|
+
* when target is empty/missing, then overlay the new translations via the
|
|
43
|
+
* existing patcher. Strip Payload internal columns recursively before
|
|
44
|
+
* returning.
|
|
45
|
+
*
|
|
46
|
+
* Why merge target-first: `payload.update({ locale, data })` validates the
|
|
47
|
+
* data against the full schema, so if a required-localized field is missing
|
|
48
|
+
* in the target locale Payload rejects the write. Reading the target locale
|
|
49
|
+
* (via `findByIdNoFallback`/`findGlobalNoFallback`) gives us the existing
|
|
50
|
+
* shape we can safely overlay. When the target is fresh (no rows yet) we
|
|
51
|
+
* fall back to the source clone — Payload generates ids on insert.
|
|
52
|
+
*/ export function mergeTranslationsIntoTargetDoc(params) {
|
|
53
|
+
const { sourceDoc, targetDoc, translatedMap, fields, translatedTopFields, allLocalizedTopFields, writeMode = 'full' } = params;
|
|
54
|
+
// Top-level fields that are localized in the schema. The `'full'` mode
|
|
55
|
+
// (default) includes ALL of them in the write data — not just those with
|
|
56
|
+
// translated units — so Payload's required-localized-field validation
|
|
57
|
+
// passes when the target locale row is fresh. Each value still uses the
|
|
58
|
+
// target-first / source-fallback strategy below, so we never overwrite
|
|
59
|
+
// existing target content with source values.
|
|
60
|
+
//
|
|
61
|
+
// Why `'full'` matters: per-field translate restricts `translatedTopFields`
|
|
62
|
+
// to a single key (e.g. `{'title'}`). Without this expansion, writing to
|
|
63
|
+
// a locale that doesn't yet have a row would fail with
|
|
64
|
+
// "field is invalid: <other-required-localized-field>" because Payload
|
|
65
|
+
// validates the full schema on the locale-update body.
|
|
66
|
+
//
|
|
67
|
+
// `'minimal'` mode skips the expansion entirely — only translated top-
|
|
68
|
+
// fields are written. Caller is responsible for ensuring Payload accepts
|
|
69
|
+
// a partial write (target row already populated, no required-but-empty
|
|
70
|
+
// siblings).
|
|
71
|
+
const localizedTopFields = new Set(translatedTopFields);
|
|
72
|
+
if (writeMode === 'full') {
|
|
73
|
+
if (allLocalizedTopFields) {
|
|
74
|
+
for (const name of allLocalizedTopFields)localizedTopFields.add(name);
|
|
75
|
+
} else {
|
|
76
|
+
for (const f of fields){
|
|
77
|
+
if (f.localized) {
|
|
78
|
+
localizedTopFields.add(f.path.split('.')[0]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Build the merge base: target value if present, else source value.
|
|
84
|
+
// For array-typed fields and any nested arrays we encounter while walking,
|
|
85
|
+
// align the base shape to the source shape (see `alignBaseShapeToSource`).
|
|
86
|
+
// For object-typed fields (groups, JSON containers), align recursively via
|
|
87
|
+
// `alignObjectArraysToSource` so target objects reshape to match source —
|
|
88
|
+
// dropped keys are pruned, nested arrays reshape, and new keys get seeded
|
|
89
|
+
// from source. The patcher walks paths derived from source extraction;
|
|
90
|
+
// when target shape diverges from source, path-keyed translations silently
|
|
91
|
+
// drop on the floor and stale data persists. Aligning shape recursively
|
|
92
|
+
// kills that bug class at every nesting level instead of only the top.
|
|
93
|
+
const base = {};
|
|
94
|
+
for (const topField of localizedTopFields){
|
|
95
|
+
const targetValue = targetDoc?.[topField];
|
|
96
|
+
if (targetValue !== undefined && targetValue !== null && !isEmpty(targetValue)) {
|
|
97
|
+
base[topField] = structuredClone(targetValue);
|
|
98
|
+
const sourceValue = sourceDoc[topField];
|
|
99
|
+
if (Array.isArray(base[topField]) && Array.isArray(sourceValue)) {
|
|
100
|
+
alignBaseShapeToSource(base[topField], sourceValue);
|
|
101
|
+
} else if (base[topField] && sourceValue && typeof base[topField] === 'object' && typeof sourceValue === 'object' && !Array.isArray(base[topField]) && !Array.isArray(sourceValue)) {
|
|
102
|
+
// Object reshape: drop target-only keys, propagate source nesting,
|
|
103
|
+
// align nested arrays. Skips Lexical roots (`root` key) — those have
|
|
104
|
+
// their own deserializer path via `patchTranslatedContent`.
|
|
105
|
+
alignObjectArraysToSource(base[topField], sourceValue);
|
|
106
|
+
}
|
|
107
|
+
} else if (topField in sourceDoc) {
|
|
108
|
+
// Fresh target locale (no prior translation data). Use source
|
|
109
|
+
// as the shape skeleton, but STRIP array-row `id` fields so
|
|
110
|
+
// Payload generates fresh ones for the target locale. If we
|
|
111
|
+
// kept the source ids, the locale write would try to insert
|
|
112
|
+
// rows into shared `<col>_blocks_*` / `<col>_*` parent tables
|
|
113
|
+
// with ids that ALREADY EXIST (those are the source-locale's
|
|
114
|
+
// rows) — Payload's drizzle `upsertRow` then throws
|
|
115
|
+
// "Value must be unique: id" and the whole locale write
|
|
116
|
+
// fails. Reproduced 2026-06-04 on `pages/34` for every
|
|
117
|
+
// non-EN locale that had no prior data; debug trail confirmed
|
|
118
|
+
// writeData carried source-locale block ids verbatim.
|
|
119
|
+
//
|
|
120
|
+
// We DON'T strip ids in the targetValue-present branch above
|
|
121
|
+
// because reusing existing target row ids is safe (and
|
|
122
|
+
// intentional — stripping ids on a row-present write forces a
|
|
123
|
+
// delete+recreate that cascades into other locales' `_locales`
|
|
124
|
+
// children, the 2026-06-04 nav-data-loss incident the v1.2.7
|
|
125
|
+
// STRIP_FIELDS comment warns about).
|
|
126
|
+
base[topField] = stripArrayItemIds(structuredClone(sourceDoc[topField]));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
// Apply translations onto the base. The patcher mutates a clone of `base`
|
|
130
|
+
// and writes translated leaves into it. `sourceDoc` is passed separately
|
|
131
|
+
// so the patcher can recover structural skeletons (Lexical trees, etc.)
|
|
132
|
+
// from the TRUE source when the merge base has an empty / placeholder
|
|
133
|
+
// shape from a fresh target locale — without this, Lexical content gets
|
|
134
|
+
// flattened to a single empty paragraph.
|
|
135
|
+
const patched = patchTranslatedContent(base, translatedMap, fields, sourceDoc);
|
|
136
|
+
// Emit all localized top-level fields. System columns stripped recursively;
|
|
137
|
+
// array-item ids unconditionally stripped (see comment above STRIP_FIELDS).
|
|
138
|
+
const writeData = {};
|
|
139
|
+
for (const topField of localizedTopFields){
|
|
140
|
+
if (topField in patched) {
|
|
141
|
+
writeData[topField] = stripSystem(patched[topField]);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return writeData;
|
|
145
|
+
}
|
|
146
|
+
function isEmpty(value) {
|
|
147
|
+
if (value === null || value === undefined) return true;
|
|
148
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
149
|
+
if (typeof value === 'object') {
|
|
150
|
+
return Object.keys(value).length === 0;
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Mutates `base` so its array shape matches `source` at every nesting level.
|
|
156
|
+
* Truncates excess items, fills sparse/null slots from source, and replaces
|
|
157
|
+
* items where shape (array vs object vs primitive) or `blockType` diverges.
|
|
158
|
+
* Recurses into matching object slots and matching array slots so deletions,
|
|
159
|
+
* reorders, and type swaps inside nested arrays propagate too — not just at
|
|
160
|
+
* the top level.
|
|
161
|
+
*
|
|
162
|
+
* Why this exists: the patcher walks paths derived from the source. When
|
|
163
|
+
* target shape diverges from source, path-keyed translations land on stale
|
|
164
|
+
* shapes (or vanish silently). Aligning recursively kills that bug class at
|
|
165
|
+
* every nesting level instead of patching one boundary.
|
|
166
|
+
*/ function alignBaseShapeToSource(base, source) {
|
|
167
|
+
base.length = source.length;
|
|
168
|
+
for(let i = 0; i < source.length; i++){
|
|
169
|
+
const baseItem = base[i];
|
|
170
|
+
const sourceItem = source[i];
|
|
171
|
+
if (baseItem === undefined || baseItem === null) {
|
|
172
|
+
// Gap fill: target has no counterpart at this index. The new row
|
|
173
|
+
// must NOT inherit source's array-row ids — `_parent_id` on nested
|
|
174
|
+
// child tables points at the source-locale's parent row, so writing
|
|
175
|
+
// these ids under the target locale fails with "field is invalid:
|
|
176
|
+
// id". Strip top-level + nested ids so Payload generates fresh ones.
|
|
177
|
+
base[i] = cloneSourceItemAsNewLocaleRow(sourceItem);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const baseIsArray = Array.isArray(baseItem);
|
|
181
|
+
const sourceIsArray = Array.isArray(sourceItem);
|
|
182
|
+
// Shape divergence: array-vs-not, object-vs-primitive — replace from source.
|
|
183
|
+
// Same id-strip reasoning as the gap-fill branch above.
|
|
184
|
+
if (baseIsArray !== sourceIsArray || typeof baseItem !== typeof sourceItem) {
|
|
185
|
+
base[i] = cloneSourceItemAsNewLocaleRow(sourceItem);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (baseIsArray && sourceIsArray) {
|
|
189
|
+
alignBaseShapeToSource(baseItem, sourceItem);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (baseItem && sourceItem && typeof baseItem === 'object' && !baseIsArray) {
|
|
193
|
+
const baseObj = baseItem;
|
|
194
|
+
const sourceObj = sourceItem;
|
|
195
|
+
const baseType = baseObj.blockType;
|
|
196
|
+
const sourceType = sourceObj.blockType;
|
|
197
|
+
if (baseType !== undefined && sourceType !== undefined && baseType !== sourceType) {
|
|
198
|
+
// Block type changed — base item's ids belong to the OLD blockType
|
|
199
|
+
// and can't be reused for the new shape. Strip and let Payload
|
|
200
|
+
// regenerate.
|
|
201
|
+
base[i] = cloneSourceItemAsNewLocaleRow(sourceItem);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
alignObjectArraysToSource(baseObj, sourceObj);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Walks an object and recurses into any arrays/objects on the source side,
|
|
210
|
+
* aligning the base's matching array fields and pruning keys the source no
|
|
211
|
+
* longer has. Skips Lexical roots (`root` key) which have their own
|
|
212
|
+
* deserializer; flattening those would break rich text.
|
|
213
|
+
*
|
|
214
|
+
* Key pruning is required for free-shape fields (`type: 'json'`) where the
|
|
215
|
+
* source object's keys can change between publishes. Without pruning, target
|
|
216
|
+
* keeps stale keys removed from source. For schema-fixed fields (block sub-
|
|
217
|
+
* fields, group fields), pruning is harmless because Payload validates
|
|
218
|
+
* writes against the schema regardless.
|
|
219
|
+
*/ function alignObjectArraysToSource(base, source) {
|
|
220
|
+
if ('root' in source) return; // Lexical — leave to deserializer
|
|
221
|
+
// Prune keys present in base but absent from source.
|
|
222
|
+
for (const key of Object.keys(base)){
|
|
223
|
+
if (!(key in source)) {
|
|
224
|
+
delete base[key];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const key of Object.keys(source)){
|
|
228
|
+
const baseVal = base[key];
|
|
229
|
+
const sourceVal = source[key];
|
|
230
|
+
if (Array.isArray(sourceVal)) {
|
|
231
|
+
if (Array.isArray(baseVal)) {
|
|
232
|
+
alignBaseShapeToSource(baseVal, sourceVal);
|
|
233
|
+
} else {
|
|
234
|
+
// Shape divergence at this key — base had something non-array,
|
|
235
|
+
// source has an array. Strip element ids on the source clone so
|
|
236
|
+
// Payload regenerates them for the new locale's rows.
|
|
237
|
+
base[key] = stripArrayItemIds(structuredClone(sourceVal));
|
|
238
|
+
}
|
|
239
|
+
} else if (sourceVal && baseVal && typeof sourceVal === 'object' && typeof baseVal === 'object' && !Array.isArray(sourceVal) && !Array.isArray(baseVal)) {
|
|
240
|
+
alignObjectArraysToSource(baseVal, sourceVal);
|
|
241
|
+
} else if (baseVal === undefined && sourceVal !== undefined) {
|
|
242
|
+
// Source has a key base doesn't (after the prune step this means base
|
|
243
|
+
// never had it). Seed from source so subsequent translation units land.
|
|
244
|
+
// Nested array ids stripped — same _parent_id reasoning as in
|
|
245
|
+
// `alignBaseShapeToSource`.
|
|
246
|
+
base[key] = walkAndStripArrayIds(structuredClone(sourceVal));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function stripSystem(value) {
|
|
251
|
+
if (value === null || value === undefined) return value;
|
|
252
|
+
if (typeof value !== 'object') return value;
|
|
253
|
+
if (Array.isArray(value)) {
|
|
254
|
+
// Recurse — each child is an array item. We PRESERVE the item's `id`
|
|
255
|
+
// (see comment on the id-handling branch below).
|
|
256
|
+
return value.map((item)=>stripSystem(item));
|
|
257
|
+
}
|
|
258
|
+
const obj = value;
|
|
259
|
+
// RichText (Lexical) values have a `root` key and shouldn't be flattened.
|
|
260
|
+
// Their internal structure is a separate concern handled by the lexical
|
|
261
|
+
// serializer/deserializer.
|
|
262
|
+
if ('root' in obj) {
|
|
263
|
+
return obj;
|
|
264
|
+
}
|
|
265
|
+
const out = {};
|
|
266
|
+
for (const [key, val] of Object.entries(obj)){
|
|
267
|
+
if (STRIP_FIELDS.has(key)) continue;
|
|
268
|
+
// v1.2.7 critical fix — DO NOT strip array-item `id`.
|
|
269
|
+
//
|
|
270
|
+
// Pre-v1.2.7 this function stripped `id` from every array item before
|
|
271
|
+
// a locale-scoped `updateGlobal` / `updateOne` write, on the (wrong)
|
|
272
|
+
// theory that Payload would regenerate fresh ids per locale. In
|
|
273
|
+
// Payload's Postgres adapter the array-row id is SHARED across
|
|
274
|
+
// locales — the `_locales` child tables key on (_parent_id, _locale)
|
|
275
|
+
// with `ON DELETE CASCADE` on _parent_id. When the incoming array
|
|
276
|
+
// items have no id, Payload cannot match them to existing rows and
|
|
277
|
+
// DELETE+RECREATE the parent rows, which cascades into the `_locales`
|
|
278
|
+
// children of EVERY locale (including the source). Result: writing
|
|
279
|
+
// the German translation wipes the English source labels of every
|
|
280
|
+
// localized array on the global. This was the cause of the
|
|
281
|
+
// wild-payload-cms / wolf nav-data-loss incident (2026-06-04).
|
|
282
|
+
//
|
|
283
|
+
// Preserve `id` whenever the array item already carries one — that
|
|
284
|
+
// tells Payload "match this existing row, just update the per-locale
|
|
285
|
+
// leaf values". Items with no id are genuinely new (rare on a
|
|
286
|
+
// translation write path) and Payload will create them.
|
|
287
|
+
out[key] = stripSystem(val);
|
|
288
|
+
}
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Recursively strip the `id` field from every array item in `value`,
|
|
293
|
+
* leaving object-level ids and primitive values untouched. Used by the
|
|
294
|
+
* merge function when a fresh target locale falls back to cloning the
|
|
295
|
+
* source — keeping the source's array-row ids would collide with the
|
|
296
|
+
* existing source rows in Payload's shared `<col>_blocks_*` /
|
|
297
|
+
* `<col>_<array>` tables (Postgres PK conflict). Stripping them lets
|
|
298
|
+
* Payload generate fresh ids for the new locale.
|
|
299
|
+
*
|
|
300
|
+
* Only `id` on array elements is stripped — non-array object `id`
|
|
301
|
+
* fields (e.g. Lexical node fragments that may carry an id for
|
|
302
|
+
* downstream tooling) stay intact. This narrower scope avoids the
|
|
303
|
+
* cascading delete+recreate of `_locales` children that the
|
|
304
|
+
* unconditional `stripSystem` strip caused in pre-1.2.7.
|
|
305
|
+
*/ function stripArrayItemIds(value) {
|
|
306
|
+
if (value === null || value === undefined || typeof value !== 'object') {
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
if (Array.isArray(value)) {
|
|
310
|
+
return value.map((item)=>{
|
|
311
|
+
if (item !== null && typeof item === 'object' && !Array.isArray(item) && 'id' in item) {
|
|
312
|
+
const { id: _stripped, ...rest } = item;
|
|
313
|
+
// Recurse into the stripped item's own nested arrays so deep
|
|
314
|
+
// array structures (e.g. blocks → array of items → array of
|
|
315
|
+
// sub-items) all get their array-row ids stripped, not just
|
|
316
|
+
// the top level.
|
|
317
|
+
return walkAndStripArrayIds(rest);
|
|
318
|
+
}
|
|
319
|
+
return walkAndStripArrayIds(item);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
return walkAndStripArrayIds(value);
|
|
323
|
+
}
|
|
324
|
+
function walkAndStripArrayIds(value) {
|
|
325
|
+
if (value === null || value === undefined || typeof value !== 'object') {
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
if (Array.isArray(value)) {
|
|
329
|
+
return stripArrayItemIds(value);
|
|
330
|
+
}
|
|
331
|
+
const out = {};
|
|
332
|
+
for (const [key, val] of Object.entries(value)){
|
|
333
|
+
out[key] = walkAndStripArrayIds(val);
|
|
334
|
+
}
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Clone an array element with its top-level `id` AND every nested
|
|
339
|
+
* array-row `id` stripped. Use when the alignment step fills a gap in
|
|
340
|
+
* the target array from a source item — Payload must generate fresh
|
|
341
|
+
* ids for the new locale's rows; carrying source ids forward causes
|
|
342
|
+
* "field is invalid: id" on locale-scoped writes whose nested arrays
|
|
343
|
+
* point at the wrong _parent_id.
|
|
344
|
+
*
|
|
345
|
+
* Symmetric to `stripArrayItemIds` but applied to a SINGLE item rather
|
|
346
|
+
* than mapping over an array. Preserves the v1.2.7 invariant that
|
|
347
|
+
* existing target-row ids are NOT stripped: callers only invoke this
|
|
348
|
+
* on items they are creating fresh, never on items already present in
|
|
349
|
+
* the target locale.
|
|
350
|
+
*/ function cloneSourceItemAsNewLocaleRow(item) {
|
|
351
|
+
const cloned = structuredClone(item);
|
|
352
|
+
if (cloned !== null && typeof cloned === 'object' && !Array.isArray(cloned) && 'id' in cloned) {
|
|
353
|
+
const { id: _stripped, ...rest } = cloned;
|
|
354
|
+
return walkAndStripArrayIds(rest);
|
|
355
|
+
}
|
|
356
|
+
return walkAndStripArrayIds(cloned);
|
|
357
|
+
}
|