@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,393 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { getValueAtPath } from './content-extractor.js';
|
|
3
|
+
/**
|
|
4
|
+
* SHA-1 of a stable JSON serialization of a value. Used to fingerprint
|
|
5
|
+
* field values for the `preserveManualEdits` feature so we can detect
|
|
6
|
+
* when a target locale row has drifted from the last plugin-written
|
|
7
|
+
* shape.
|
|
8
|
+
*
|
|
9
|
+
* Stable serialization: object keys sorted, undefined dropped. Same
|
|
10
|
+
* value → same hash across processes / machines.
|
|
11
|
+
*/ export function hashFieldValue(value) {
|
|
12
|
+
return createHash('sha1').update(stableStringify(value)).digest('hex');
|
|
13
|
+
}
|
|
14
|
+
function stableStringify(value) {
|
|
15
|
+
if (value === undefined) return 'undefined';
|
|
16
|
+
if (value === null) return 'null';
|
|
17
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
18
|
+
if (typeof value !== 'object') return JSON.stringify(value);
|
|
19
|
+
if (Array.isArray(value)) {
|
|
20
|
+
return '[' + value.map(stableStringify).join(',') + ']';
|
|
21
|
+
}
|
|
22
|
+
const entries = Object.entries(value).filter(([, v])=>v !== undefined).sort(([a], [b])=>a < b ? -1 : a > b ? 1 : 0);
|
|
23
|
+
return '{' + entries.map(([k, v])=>JSON.stringify(k) + ':' + stableStringify(v)).join(',') + '}';
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Fetch every (fieldPath → lastWrittenHash) row for one
|
|
27
|
+
* (collection, documentId, locale) tuple. Used by the writer before
|
|
28
|
+
* issuing the locale-update so it can filter out fields that have
|
|
29
|
+
* drifted from the stored hash.
|
|
30
|
+
*/ export async function loadFieldHashes(payload, metaCollection, collection, documentId, locale) {
|
|
31
|
+
const out = new Map();
|
|
32
|
+
try {
|
|
33
|
+
const result = await payload.find({
|
|
34
|
+
collection: metaCollection,
|
|
35
|
+
limit: 1000,
|
|
36
|
+
where: {
|
|
37
|
+
collection: {
|
|
38
|
+
equals: collection
|
|
39
|
+
},
|
|
40
|
+
documentId: {
|
|
41
|
+
equals: String(documentId)
|
|
42
|
+
},
|
|
43
|
+
locale: {
|
|
44
|
+
equals: locale
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
depth: 0,
|
|
48
|
+
overrideAccess: true
|
|
49
|
+
});
|
|
50
|
+
for (const raw of result.docs){
|
|
51
|
+
const row = raw;
|
|
52
|
+
if (row.fieldPath && row.lastWrittenHash) {
|
|
53
|
+
out.set(row.fieldPath, row.lastWrittenHash);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// First-time write or transient read failure — caller treats empty
|
|
58
|
+
// as "no prior hash, no manual-edit divergence possible yet."
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Inspect the current target doc and decide which top-level fields in
|
|
64
|
+
* `writeData` would overwrite a manually edited value. Returns the set
|
|
65
|
+
* of field names to DROP from `writeData` before issuing the update.
|
|
66
|
+
*
|
|
67
|
+
* Detection: for each top-level field path that has a stored hash, if
|
|
68
|
+
* the current target value's hash differs from the stored one, the
|
|
69
|
+
* field has been edited since the plugin's last write — skip it.
|
|
70
|
+
*/ export function pickManualEditedFields(writeData, targetDoc, storedHashes) {
|
|
71
|
+
const fieldsToDrop = [];
|
|
72
|
+
const skipReasons = new Map();
|
|
73
|
+
if (!targetDoc || storedHashes.size === 0) {
|
|
74
|
+
return {
|
|
75
|
+
fieldsToDrop,
|
|
76
|
+
skipReasons
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
for (const topField of Object.keys(writeData)){
|
|
80
|
+
const storedHash = storedHashes.get(topField);
|
|
81
|
+
if (!storedHash) continue; // never written before — not divergent
|
|
82
|
+
const currentValue = getValueAtPath(targetDoc, topField);
|
|
83
|
+
// BUG-24 fix: if the current target value is NULL / undefined / empty,
|
|
84
|
+
// the row was emptied (version restore, manual deletion, fresh insert
|
|
85
|
+
// after a delete) — there's nothing for the editor to have "manually
|
|
86
|
+
// edited," so the stale stored hash should not block recovery. Treat
|
|
87
|
+
// empty targets as "no preserve" so re-translation populates them.
|
|
88
|
+
if (isEmptyTargetValue(currentValue)) continue;
|
|
89
|
+
const currentHash = hashFieldValue(currentValue);
|
|
90
|
+
if (currentHash !== storedHash) {
|
|
91
|
+
fieldsToDrop.push(topField);
|
|
92
|
+
skipReasons.set(topField, 'Target value diverges from last plugin-written hash — assumed manual edit, preserving target');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
fieldsToDrop,
|
|
97
|
+
skipReasons
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* "Empty target" means the row has nothing the editor could have
|
|
102
|
+
* manually refined. Includes:
|
|
103
|
+
* - undefined / null
|
|
104
|
+
* - empty string
|
|
105
|
+
* - empty array
|
|
106
|
+
* - empty object (no enumerable keys)
|
|
107
|
+
*
|
|
108
|
+
* NOT empty:
|
|
109
|
+
* - 0, false (legitimate primitive values)
|
|
110
|
+
* - whitespace-only strings (still editor intent)
|
|
111
|
+
*/ function isEmptyTargetValue(value) {
|
|
112
|
+
if (value === undefined || value === null) return true;
|
|
113
|
+
if (typeof value === 'string') return value === '';
|
|
114
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
115
|
+
if (typeof value === 'object') return Object.keys(value).length === 0;
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Upsert per-field hashes after a successful locale write. One row per
|
|
120
|
+
* (collection, documentId, locale, fieldPath). Best-effort — failures
|
|
121
|
+
* here do NOT roll back the translation, they just mean the next write
|
|
122
|
+
* can't detect divergence for that field.
|
|
123
|
+
*
|
|
124
|
+
* `sourceValues` (BUG-21): map of `fieldPath → source value at time of
|
|
125
|
+
* write`. When provided, the upsert also stores `lastSourceHash` so the
|
|
126
|
+
* next translate can compare current-source-hash to stored-source-hash
|
|
127
|
+
* and skip the LLM call entirely if both source AND target are
|
|
128
|
+
* unchanged. Omitted when source values aren't available (rare).
|
|
129
|
+
*/ export async function recordFieldHashes(payload, metaCollection, collection, documentId, locale, writeData, sourceValues, /**
|
|
130
|
+
* Optional request context to thread into the inner find/update/create
|
|
131
|
+
* calls. When the caller wraps the locale-write in a fresh transaction,
|
|
132
|
+
* passing the same `req` here keeps the bookkeeping rows on the same
|
|
133
|
+
* connection lifecycle. Omit for ad-hoc / out-of-band invocations.
|
|
134
|
+
*/ req) {
|
|
135
|
+
const now = new Date().toISOString();
|
|
136
|
+
const fieldPaths = Object.entries(writeData);
|
|
137
|
+
let written = 0;
|
|
138
|
+
let failed = 0;
|
|
139
|
+
// Empty writeData is legitimate (all fields preserved as manual edits)
|
|
140
|
+
// — log once at debug and return cleanly so callers can distinguish
|
|
141
|
+
// "nothing to record" from "tried and failed."
|
|
142
|
+
if (fieldPaths.length === 0) {
|
|
143
|
+
return {
|
|
144
|
+
written: 0,
|
|
145
|
+
failed: 0
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
for (const [fieldPath, value] of fieldPaths){
|
|
149
|
+
const hash = hashFieldValue(value);
|
|
150
|
+
const sourceHash = sourceValues ? hashFieldValue(sourceValues[fieldPath]) : undefined;
|
|
151
|
+
try {
|
|
152
|
+
// Upsert via find + create/update. Payload doesn't have a
|
|
153
|
+
// native upsert; emulate it.
|
|
154
|
+
const existing = await payload.find({
|
|
155
|
+
collection: metaCollection,
|
|
156
|
+
limit: 1,
|
|
157
|
+
where: {
|
|
158
|
+
and: [
|
|
159
|
+
{
|
|
160
|
+
collection: {
|
|
161
|
+
equals: collection
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
documentId: {
|
|
166
|
+
equals: String(documentId)
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
locale: {
|
|
171
|
+
equals: locale
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
fieldPath: {
|
|
176
|
+
equals: fieldPath
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
]
|
|
180
|
+
},
|
|
181
|
+
depth: 0,
|
|
182
|
+
overrideAccess: true,
|
|
183
|
+
...req ? {
|
|
184
|
+
req
|
|
185
|
+
} : {}
|
|
186
|
+
});
|
|
187
|
+
const updateData = {
|
|
188
|
+
lastWrittenHash: hash,
|
|
189
|
+
lastWrittenAt: now
|
|
190
|
+
};
|
|
191
|
+
if (sourceHash !== undefined) updateData.lastSourceHash = sourceHash;
|
|
192
|
+
if (existing.docs.length > 0) {
|
|
193
|
+
await payload.update({
|
|
194
|
+
collection: metaCollection,
|
|
195
|
+
id: existing.docs[0].id,
|
|
196
|
+
data: updateData,
|
|
197
|
+
overrideAccess: true,
|
|
198
|
+
...req ? {
|
|
199
|
+
req
|
|
200
|
+
} : {}
|
|
201
|
+
});
|
|
202
|
+
} else {
|
|
203
|
+
await payload.create({
|
|
204
|
+
collection: metaCollection,
|
|
205
|
+
data: {
|
|
206
|
+
collection,
|
|
207
|
+
documentId: String(documentId),
|
|
208
|
+
locale,
|
|
209
|
+
fieldPath,
|
|
210
|
+
lastWrittenHash: hash,
|
|
211
|
+
...sourceHash !== undefined ? {
|
|
212
|
+
lastSourceHash: sourceHash
|
|
213
|
+
} : {},
|
|
214
|
+
lastWrittenAt: now
|
|
215
|
+
},
|
|
216
|
+
overrideAccess: true,
|
|
217
|
+
...req ? {
|
|
218
|
+
req
|
|
219
|
+
} : {}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
written++;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
// Manual-edit detection is best-effort, so we never re-throw — but
|
|
225
|
+
// silently swallowing made an empty `ai-translate-meta` collection
|
|
226
|
+
// undebuggable. Surface the per-field failure as an error so it
|
|
227
|
+
// shows up in prod logs without filtering. The loop keeps going so
|
|
228
|
+
// one bad field doesn't drop the rest.
|
|
229
|
+
failed++;
|
|
230
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
231
|
+
const stack = err instanceof Error && err.stack ? `\n${err.stack}` : '';
|
|
232
|
+
payload.logger?.error?.(`[ai-translate] recordFieldHashes upsert failed for ${collection}/${String(documentId)} locale=${locale} field=${fieldPath}: ${msg}${stack}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
written,
|
|
237
|
+
failed
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Fetch every `(fieldPath → { writtenHash, sourceHash })` row for one
|
|
242
|
+
* (collection, documentId, locale) tuple. Variant of `loadFieldHashes`
|
|
243
|
+
* that also returns the BUG-21 source hash so the LLM-skip check can
|
|
244
|
+
* compare current source content to what was translated last time.
|
|
245
|
+
*
|
|
246
|
+
* Returns an empty map on read failure (caller treats as "no prior data,
|
|
247
|
+
* proceed with full translate"). Rows pre-BUG-21 have `sourceHash:
|
|
248
|
+
* undefined` — those are treated as "can't safely skip; run translate."
|
|
249
|
+
*/ export async function loadFieldHashesWithSource(payload, metaCollection, collection, documentId, locale) {
|
|
250
|
+
const out = new Map();
|
|
251
|
+
try {
|
|
252
|
+
const result = await payload.find({
|
|
253
|
+
collection: metaCollection,
|
|
254
|
+
limit: 1000,
|
|
255
|
+
where: {
|
|
256
|
+
collection: {
|
|
257
|
+
equals: collection
|
|
258
|
+
},
|
|
259
|
+
documentId: {
|
|
260
|
+
equals: String(documentId)
|
|
261
|
+
},
|
|
262
|
+
locale: {
|
|
263
|
+
equals: locale
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
depth: 0,
|
|
267
|
+
overrideAccess: true
|
|
268
|
+
});
|
|
269
|
+
for (const raw of result.docs){
|
|
270
|
+
const row = raw;
|
|
271
|
+
if (row.fieldPath && row.lastWrittenHash) {
|
|
272
|
+
out.set(row.fieldPath, {
|
|
273
|
+
writtenHash: row.lastWrittenHash,
|
|
274
|
+
sourceHash: row.lastSourceHash
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// Best-effort
|
|
280
|
+
}
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Read stored source+target hashes for `(surfaceSlug, documentId,
|
|
285
|
+
* targetLocale)`, compare against current source + target, and remove
|
|
286
|
+
* units whose top-level field is unchanged on both sides. Returns the
|
|
287
|
+
* remaining units the caller still needs to translate plus synthetic
|
|
288
|
+
* `status: 'success'` results for the skipped units (characterCount = 0,
|
|
289
|
+
* durationMs = 0 — these are the "fieldsHashSkipped" rows in usage).
|
|
290
|
+
*
|
|
291
|
+
* Surface-agnostic: pass a `loadTargetDoc` closure so callers can supply
|
|
292
|
+
* `findByIdNoFallback(payload, collection, docId, locale)` for collections
|
|
293
|
+
* or `findGlobalNoFallback(payload, globalSlug, locale)` for globals.
|
|
294
|
+
*
|
|
295
|
+
* Wrapped in try/catch internally — best-effort optimization, never throws.
|
|
296
|
+
*/ export async function applyHashSkip(args) {
|
|
297
|
+
const { payload, metaCollection, surfaceSlug, documentId, targetLocale, units, sourceDoc, loadTargetDoc } = args;
|
|
298
|
+
const fallback = {
|
|
299
|
+
remainingUnits: units,
|
|
300
|
+
hashSkipped: []
|
|
301
|
+
};
|
|
302
|
+
try {
|
|
303
|
+
const sourceTargetHashes = await loadFieldHashesWithSource(payload, metaCollection, surfaceSlug, documentId, targetLocale);
|
|
304
|
+
if (sourceTargetHashes.size === 0) {
|
|
305
|
+
return fallback;
|
|
306
|
+
}
|
|
307
|
+
let targetDoc = null;
|
|
308
|
+
try {
|
|
309
|
+
targetDoc = await loadTargetDoc();
|
|
310
|
+
} catch {
|
|
311
|
+
targetDoc = null;
|
|
312
|
+
}
|
|
313
|
+
if (!targetDoc) {
|
|
314
|
+
return fallback;
|
|
315
|
+
}
|
|
316
|
+
// Group units by top-level field. A top-level field is either hash-
|
|
317
|
+
// skippable as a whole (every unit under it gets skipped) or it's
|
|
318
|
+
// not — units within the same top-field share the same source/target
|
|
319
|
+
// hash bookkeeping.
|
|
320
|
+
const unitsByTop = new Map();
|
|
321
|
+
for (const u of units){
|
|
322
|
+
const top = u.fieldPath.split('.')[0];
|
|
323
|
+
if (!top) continue;
|
|
324
|
+
const arr = unitsByTop.get(top) ?? [];
|
|
325
|
+
arr.push(u);
|
|
326
|
+
unitsByTop.set(top, arr);
|
|
327
|
+
}
|
|
328
|
+
const skippedUnitIds = new Set();
|
|
329
|
+
for (const [topField, fieldUnits] of unitsByTop){
|
|
330
|
+
const stored = sourceTargetHashes.get(topField);
|
|
331
|
+
if (!stored || !stored.sourceHash) continue; // pre-BUG21 row
|
|
332
|
+
const currentSourceHash = hashFieldValue(sourceDoc[topField]);
|
|
333
|
+
if (currentSourceHash !== stored.sourceHash) continue; // source changed
|
|
334
|
+
const currentTargetHash = hashFieldValue(targetDoc[topField]);
|
|
335
|
+
if (currentTargetHash !== stored.writtenHash) continue; // target changed
|
|
336
|
+
for (const u of fieldUnits){
|
|
337
|
+
skippedUnitIds.add(u.id);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (skippedUnitIds.size === 0) {
|
|
341
|
+
return fallback;
|
|
342
|
+
}
|
|
343
|
+
const hashSkipped = [];
|
|
344
|
+
for (const u of units){
|
|
345
|
+
if (!skippedUnitIds.has(u.id)) continue;
|
|
346
|
+
hashSkipped.push({
|
|
347
|
+
fieldPath: u.fieldPath,
|
|
348
|
+
locale: targetLocale,
|
|
349
|
+
status: 'success',
|
|
350
|
+
characterCount: 0,
|
|
351
|
+
durationMs: 0
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
const remainingUnits = units.filter((u)=>!skippedUnitIds.has(u.id));
|
|
355
|
+
payload.logger?.info?.(`[ai-translate] Hash-skip: ${skippedUnitIds.size}/${units.length} units for ${surfaceSlug}/${String(documentId)} locale=${targetLocale} unchanged since last write — skipping LLM call.`);
|
|
356
|
+
return {
|
|
357
|
+
remainingUnits,
|
|
358
|
+
hashSkipped
|
|
359
|
+
};
|
|
360
|
+
} catch {
|
|
361
|
+
// Hash-skip is purely an optimization — any failure here falls back
|
|
362
|
+
// to "translate everything," never blocks the LLM call.
|
|
363
|
+
return fallback;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Persist per-field source+target hashes after a successful locale
|
|
368
|
+
* write. Surface-agnostic — `surfaceSlug` is the collection slug for
|
|
369
|
+
* collection writes, or the global slug for global writes (matching the
|
|
370
|
+
* existing `bulk-translate` persist contract).
|
|
371
|
+
*
|
|
372
|
+
* Builds the `sourceValues` map from `sourceDoc` for exactly the fields
|
|
373
|
+
* in `writeData`, then defers to `recordFieldHashes`. Wraps the call so
|
|
374
|
+
* upstream errors are logged via the payload logger but never re-thrown
|
|
375
|
+
* — translation succeeded already, bookkeeping failure shouldn't surface
|
|
376
|
+
* to the editor.
|
|
377
|
+
*/ export async function recordHashesAfterWrite(args) {
|
|
378
|
+
const { payload, metaCollection, surfaceSlug, documentId, targetLocale, writeData, sourceDoc } = args;
|
|
379
|
+
const sourceValues = {};
|
|
380
|
+
for (const fieldPath of Object.keys(writeData)){
|
|
381
|
+
sourceValues[fieldPath] = sourceDoc[fieldPath];
|
|
382
|
+
}
|
|
383
|
+
try {
|
|
384
|
+
return await recordFieldHashes(payload, metaCollection, surfaceSlug, documentId, targetLocale, writeData, sourceValues);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
387
|
+
payload.logger?.error?.(`[ai-translate] recordHashesAfterWrite failed for ${surfaceSlug}/${String(documentId)} locale=${targetLocale}: ${msg}`);
|
|
388
|
+
return {
|
|
389
|
+
written: 0,
|
|
390
|
+
failed: Object.keys(writeData).length
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ValidationConfig } from '../types.js';
|
|
2
|
+
import type { EditorErrorCode } from './error-messages.js';
|
|
3
|
+
export type ValidateTranslationOptions = {
|
|
4
|
+
/**
|
|
5
|
+
* Source locale of the input text. When provided alongside `targetLocale`
|
|
6
|
+
* and the two differ, the validator rejects translations that are equal to
|
|
7
|
+
* the source after NFC + whitespace normalization — a common failure mode
|
|
8
|
+
* where the model echoes a batched item untranslated. v1.2.7: echo
|
|
9
|
+
* detection runs regardless of source length; a verbatim echo is never
|
|
10
|
+
* written through to the target locale, even for short strings like brand
|
|
11
|
+
* names. The soft-skip preserves the previous target value (or leaves the
|
|
12
|
+
* target empty so the UI falls back to source). This trades the "short
|
|
13
|
+
* loanword is a valid no-op translation" assumption for never silently
|
|
14
|
+
* overwriting an existing target translation with raw source text.
|
|
15
|
+
*/
|
|
16
|
+
sourceLocale?: string;
|
|
17
|
+
targetLocale?: string;
|
|
18
|
+
};
|
|
19
|
+
export type ValidationResult = {
|
|
20
|
+
valid: boolean;
|
|
21
|
+
reason?: string;
|
|
22
|
+
/**
|
|
23
|
+
* `true` when the rejection is informational rather than a real error.
|
|
24
|
+
* Today this fires for verbatim echoes — the model may have legitimately
|
|
25
|
+
* returned the same string for a proper noun / brand / URL fragment.
|
|
26
|
+
* Callers should classify these as `skipped`, not `failed`.
|
|
27
|
+
*/
|
|
28
|
+
soft?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Editor-facing code (one of the `soft-skip.*` values in
|
|
31
|
+
* `lib/error-messages.ts`). Set on every non-valid result so the UI
|
|
32
|
+
* can render a friendly message via `editorMessageFor(code, ctx)`
|
|
33
|
+
* instead of dumping the raw `reason` string. `reason` is kept for
|
|
34
|
+
* disclosure-style "Show technical details" rendering.
|
|
35
|
+
*/
|
|
36
|
+
code?: EditorErrorCode;
|
|
37
|
+
/**
|
|
38
|
+
* Context for templating the code's message (e.g. ratio observed,
|
|
39
|
+
* placeholder ids missing). Mirrors `EditorErrorContext` shape.
|
|
40
|
+
*/
|
|
41
|
+
context?: {
|
|
42
|
+
ratio?: number;
|
|
43
|
+
ratioThreshold?: number;
|
|
44
|
+
missingPlaceholders?: readonly string[];
|
|
45
|
+
extraPlaceholders?: readonly string[];
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
export declare function validateTranslation(source: string, translated: string, config?: ValidationConfig, options?: ValidateTranslationOptions): ValidationResult;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { validatePlaceholderIntegrity } from '../lexical/placeholder-integrity.js';
|
|
2
|
+
const DEFAULT_MIN_RATIO = 0.3;
|
|
3
|
+
const DEFAULT_MAX_RATIO = 3.0;
|
|
4
|
+
const DEFAULT_MIN_SOURCE_LENGTH = 10;
|
|
5
|
+
const REFUSAL_PATTERNS = [
|
|
6
|
+
/I cannot translate/i,
|
|
7
|
+
/As an AI/i,
|
|
8
|
+
/I'm unable to/i,
|
|
9
|
+
/I apologize/i,
|
|
10
|
+
/I'm sorry, but/i
|
|
11
|
+
];
|
|
12
|
+
const INJECTION_PATTERNS = [
|
|
13
|
+
/<tool_call>/i,
|
|
14
|
+
/<\/tool_call>/i,
|
|
15
|
+
/<function_call>/i,
|
|
16
|
+
/<\/function_call>/i,
|
|
17
|
+
/<system>/i,
|
|
18
|
+
/<\/system>/i
|
|
19
|
+
];
|
|
20
|
+
// Control characters except \n (0x0A), \t (0x09), \r (0x0D)
|
|
21
|
+
const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
|
|
22
|
+
/**
|
|
23
|
+
* NFC-normalize, collapse internal whitespace runs, and trim. Used for the
|
|
24
|
+
* echo-equality comparison so that models emitting a subtly-different but
|
|
25
|
+
* display-equivalent variant of the source (smart quote vs ASCII quote,
|
|
26
|
+
* non-breaking space, doubled space, leading/trailing whitespace) still
|
|
27
|
+
* trip echo detection. Without this, gemini-2.5-flash has been observed
|
|
28
|
+
* passing through "Slot Games (test 1780581867023)" as a "translation"
|
|
29
|
+
* with a NBSP swap that escaped a strict `===` check.
|
|
30
|
+
*/ function normalizeForEcho(s) {
|
|
31
|
+
return s.normalize('NFC').replace(/\s+/g, ' ').trim();
|
|
32
|
+
}
|
|
33
|
+
export function validateTranslation(source, translated, config, options) {
|
|
34
|
+
const minRatio = config?.minLengthRatio ?? DEFAULT_MIN_RATIO;
|
|
35
|
+
const maxRatio = config?.maxLengthRatio ?? DEFAULT_MAX_RATIO;
|
|
36
|
+
const minSourceLength = config?.minSourceLength ?? DEFAULT_MIN_SOURCE_LENGTH;
|
|
37
|
+
// Echo detection runs regardless of source length (v1.2.7). Short brand
|
|
38
|
+
// names / loanwords used to bypass this gate, which let gemini-2.5-flash
|
|
39
|
+
// overwrite existing target translations with verbatim source for fields
|
|
40
|
+
// like "Bonus Buy". A verbatim echo is a soft-skip: the previous target
|
|
41
|
+
// value is preserved; an empty target stays empty (and the UI falls back
|
|
42
|
+
// to source). The ratio checks below still respect minSourceLength.
|
|
43
|
+
if (options?.sourceLocale && options.targetLocale && options.sourceLocale !== options.targetLocale && normalizeForEcho(translated) === normalizeForEcho(source)) {
|
|
44
|
+
return {
|
|
45
|
+
valid: false,
|
|
46
|
+
soft: true,
|
|
47
|
+
code: 'soft-skip.echoed',
|
|
48
|
+
reason: `Translation matches source verbatim — model likely echoed input for "${options.targetLocale}"`
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (source.length >= minSourceLength) {
|
|
52
|
+
const ratio = translated.length / source.length;
|
|
53
|
+
if (ratio < minRatio) {
|
|
54
|
+
return {
|
|
55
|
+
// 2026-06-05: all `soft-skip.*` codes now set `soft: true` so the
|
|
56
|
+
// caller (translate.ts:899) routes them to the `skipped` map
|
|
57
|
+
// instead of the retry/error path. The naming `soft-skip.*` always
|
|
58
|
+
// implied this — prior to today only `echoed` carried the flag,
|
|
59
|
+
// which meant a Chinese translation legitimately denser than the
|
|
60
|
+
// English source (ratio 0.10 vs min 0.15) would be classified as
|
|
61
|
+
// a HARD validation failure and the unit aggregator stamped it
|
|
62
|
+
// `permanent.schema` with editor copy that pointed at "needs a
|
|
63
|
+
// configuration fix" — nonsensical for an output-quality issue.
|
|
64
|
+
valid: false,
|
|
65
|
+
soft: true,
|
|
66
|
+
code: 'soft-skip.too-short',
|
|
67
|
+
context: {
|
|
68
|
+
ratio,
|
|
69
|
+
ratioThreshold: minRatio
|
|
70
|
+
},
|
|
71
|
+
reason: `Translation too short: ratio ${ratio.toFixed(2)} (min ${minRatio})`
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
if (ratio > maxRatio) {
|
|
75
|
+
return {
|
|
76
|
+
valid: false,
|
|
77
|
+
soft: true,
|
|
78
|
+
code: 'soft-skip.too-long',
|
|
79
|
+
context: {
|
|
80
|
+
ratio,
|
|
81
|
+
ratioThreshold: maxRatio
|
|
82
|
+
},
|
|
83
|
+
reason: `Translation too long: ratio ${ratio.toFixed(2)} (max ${maxRatio})`
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (CONTROL_CHAR_RE.test(translated)) {
|
|
88
|
+
return {
|
|
89
|
+
valid: false,
|
|
90
|
+
soft: true,
|
|
91
|
+
code: 'soft-skip.control-chars',
|
|
92
|
+
reason: 'Translation contains disallowed control characters'
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
// Placeholder integrity: rich-text units carry inline marks (bold,
|
|
96
|
+
// links, etc.) as `<ph id="...">` tokens. The model must echo the
|
|
97
|
+
// exact same set verbatim — added or dropped placeholders mean the
|
|
98
|
+
// deserializer would silently corrupt the Lexical tree.
|
|
99
|
+
if (source.includes('<ph')) {
|
|
100
|
+
const integrity = validatePlaceholderIntegrity(source, translated);
|
|
101
|
+
if (!integrity.valid) {
|
|
102
|
+
const issues = [];
|
|
103
|
+
if (integrity.missingIds.length) issues.push(`missing [${integrity.missingIds.join(', ')}]`);
|
|
104
|
+
if (integrity.extraIds.length) issues.push(`extra [${integrity.extraIds.join(', ')}]`);
|
|
105
|
+
return {
|
|
106
|
+
valid: false,
|
|
107
|
+
soft: true,
|
|
108
|
+
code: 'soft-skip.placeholders',
|
|
109
|
+
context: {
|
|
110
|
+
missingPlaceholders: integrity.missingIds,
|
|
111
|
+
extraPlaceholders: integrity.extraIds
|
|
112
|
+
},
|
|
113
|
+
reason: `Translation lost placeholder integrity — ${issues.join('; ')}`
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const allRefusal = [
|
|
118
|
+
...REFUSAL_PATTERNS,
|
|
119
|
+
...config?.extraRefusalPatterns ?? []
|
|
120
|
+
];
|
|
121
|
+
for (const pattern of allRefusal){
|
|
122
|
+
if (pattern.test(translated)) {
|
|
123
|
+
return {
|
|
124
|
+
valid: false,
|
|
125
|
+
soft: true,
|
|
126
|
+
code: 'soft-skip.refusal',
|
|
127
|
+
reason: `Translation contains refusal pattern: ${pattern.source}`
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const allInjection = [
|
|
132
|
+
...INJECTION_PATTERNS,
|
|
133
|
+
...config?.extraInjectionPatterns ?? []
|
|
134
|
+
];
|
|
135
|
+
for (const pattern of allInjection){
|
|
136
|
+
if (pattern.test(translated)) {
|
|
137
|
+
return {
|
|
138
|
+
valid: false,
|
|
139
|
+
soft: true,
|
|
140
|
+
code: 'soft-skip.injection',
|
|
141
|
+
reason: `Translation contains injection pattern: ${pattern.source}`
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
valid: true
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Payload, Where } from 'payload';
|
|
2
|
+
export type ReadOptions = {
|
|
3
|
+
/**
|
|
4
|
+
* Read the latest draft version when the collection has drafts enabled.
|
|
5
|
+
* Pass-through to Payload's local API. No-op for non-versioned collections.
|
|
6
|
+
* Default: false (preserves existing read-published behavior for hooks
|
|
7
|
+
* that fire on already-committed docs).
|
|
8
|
+
*/
|
|
9
|
+
draft?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function findByIdNoFallback(payload: Payload, collection: string, id: string | number, locale: string, options?: ReadOptions): Promise<Record<string, unknown>>;
|
|
12
|
+
export declare function findNoFallback(payload: Payload, collection: string, where: Where, locale: string, options?: ReadOptions): Promise<{
|
|
13
|
+
docs: Record<string, unknown>[];
|
|
14
|
+
totalDocs: number;
|
|
15
|
+
}>;
|
|
16
|
+
export declare function findGlobalNoFallback(payload: Payload, slug: string, locale: string): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Payload's Local API disables locale fallback when `fallbackLocale: false`.
|
|
2
|
+
// Passing `null` does NOT disable fallback — the runtime falls back to the
|
|
3
|
+
// default chain (typically the source/EN locale), which silently returns
|
|
4
|
+
// source content under a target-locale query. That's exactly the wrong
|
|
5
|
+
// behavior here: the locale-merge logic relies on knowing whether the target
|
|
6
|
+
// locale actually has data so it can pick the fresh-target vs. target-present
|
|
7
|
+
// branch. With the old `null` value, every "fresh target" query returned EN
|
|
8
|
+
// data, the merge entered the target-present branch carrying EN row ids, and
|
|
9
|
+
// Payload rejected the locale write with `"The following field is invalid: id"`
|
|
10
|
+
// because those row ids belong to the source locale's rows.
|
|
11
|
+
//
|
|
12
|
+
// Payload's TypeScript types declare `fallbackLocale` as a locale string, so
|
|
13
|
+
// we cast once here rather than scattering `as unknown as string` across the
|
|
14
|
+
// codebase. The runtime accepts the boolean `false` and disables fallback.
|
|
15
|
+
const NO_FALLBACK = false;
|
|
16
|
+
export async function findByIdNoFallback(payload, collection, id, locale, options = {}) {
|
|
17
|
+
const result = await payload.findByID({
|
|
18
|
+
collection: collection,
|
|
19
|
+
id,
|
|
20
|
+
locale,
|
|
21
|
+
fallbackLocale: NO_FALLBACK,
|
|
22
|
+
draft: options.draft ?? false,
|
|
23
|
+
// depth: 0 returns relationships as scalar IDs (not populated docs).
|
|
24
|
+
// The default depth: 2 populates upload/relationship fields, which
|
|
25
|
+
// become invalid when the merge writes them back to Payload —
|
|
26
|
+
// upload validators reject populated docs containing admin-only
|
|
27
|
+
// fields and server-generated metadata (sizes.*, url, filename, ...).
|
|
28
|
+
depth: 0
|
|
29
|
+
});
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
export async function findNoFallback(payload, collection, where, locale, options = {}) {
|
|
33
|
+
const result = await payload.find({
|
|
34
|
+
collection: collection,
|
|
35
|
+
where,
|
|
36
|
+
locale,
|
|
37
|
+
fallbackLocale: NO_FALLBACK,
|
|
38
|
+
draft: options.draft ?? false,
|
|
39
|
+
depth: 0
|
|
40
|
+
});
|
|
41
|
+
return result;
|
|
42
|
+
}
|
|
43
|
+
export async function findGlobalNoFallback(payload, slug, locale) {
|
|
44
|
+
const result = await payload.findGlobal({
|
|
45
|
+
slug: slug,
|
|
46
|
+
locale,
|
|
47
|
+
fallbackLocale: NO_FALLBACK,
|
|
48
|
+
depth: 0
|
|
49
|
+
});
|
|
50
|
+
return result;
|
|
51
|
+
}
|