@focus-reactive/payload-plugin-translator 0.11.0 → 0.11.2
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/README.md +47 -1
- package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.js +2 -2
- package/dist/client/widgets/translate-document/ui/TranslateDocument.server.js +2 -2
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.js +10 -7
- package/dist/core/translation-pipeline/stages/field-collector/FieldChunkCollector.js +0 -5
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/server/features/translate-document/handler.d.ts +1 -0
- package/dist/server/features/translate-document/handler.js +55 -46
- package/dist/server/features/translate-document/targetLayer.d.ts +37 -0
- package/dist/server/features/translate-document/targetLayer.js +26 -0
- package/dist/server/features/translate-field/handler.js +4 -10
- package/dist/server/modules/auto-translate/AutoTranslateEnqueue.hook.js +2 -1
- package/dist/server/modules/lifecycle/withQueuedNotification.js +4 -1
- package/dist/server/modules/task-runner/TaskRunner.interface.d.ts +20 -2
- package/dist/server/modules/task-runner/TaskRunner.interface.js +3 -5
- package/dist/server/modules/task-runner/index.d.ts +2 -1
- package/dist/server/modules/task-runner/index.js +1 -0
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.d.ts +14 -75
- package/dist/server/modules/task-runner/payload-jobs-runner/PayloadJobsTaskRunner.js +42 -93
- package/dist/server/modules/task-runner/sync-runner/SyncTaskRunner.d.ts +2 -2
- package/dist/server/modules/task-runner/sync-runner/SyncTaskRunner.js +7 -1
- package/dist/server/modules/task-runner/toTaskFilter.d.ts +11 -0
- package/dist/server/modules/task-runner/toTaskFilter.js +15 -0
- package/dist/server/shared/guards/index.d.ts +0 -1
- package/dist/server/shared/guards/index.js +0 -1
- package/dist/server/shared/payload/sourceDocument.d.ts +2 -4
- package/dist/server/shared/payload/sourceDocument.js +10 -5
- package/package.json +1 -1
- package/dist/server/shared/guards/collection-guards.d.ts +0 -6
- package/dist/server/shared/guards/collection-guards.js +0 -16
package/README.md
CHANGED
|
@@ -205,6 +205,45 @@ translatorPlugin({
|
|
|
205
205
|
});
|
|
206
206
|
```
|
|
207
207
|
|
|
208
|
+
### Drafts and publishing
|
|
209
|
+
|
|
210
|
+
Applies to every way a translation is triggered — the admin controls, `POST /translate/enqueue`, and
|
|
211
|
+
auto-translate. It matters most if your collections have `versions.drafts` enabled.
|
|
212
|
+
|
|
213
|
+
**Without publish-on-translation**, the translation is written as a **draft version**. The document's
|
|
214
|
+
published state is left alone: a live page stays live, an unpublished one stays unpublished, and the
|
|
215
|
+
translated locale does not appear on the public site until someone publishes it.
|
|
216
|
+
|
|
217
|
+
**With publish-on-translation**, the translation is written as a draft and the target locale is then
|
|
218
|
+
published — a separate step, so it happens whether or not any field actually needed translating.
|
|
219
|
+
Only the locale that was translated is published; other locales keep whatever state they were in.
|
|
220
|
+
Translating a document that is not currently published does make the document live, with just that
|
|
221
|
+
locale's content in it.
|
|
222
|
+
|
|
223
|
+
A translation is taken from **the source locale's own current content** — the newer draft when one
|
|
224
|
+
exists, else the published row, never a value Payload substitutes from another locale. Translating
|
|
225
|
+
*from* a locale you have not filled in therefore translates nothing.
|
|
226
|
+
|
|
227
|
+
Two consequences worth knowing before you rely on them:
|
|
228
|
+
|
|
229
|
+
- **Publishing publishes the current draft, whatever is in it.** A translation is based on the
|
|
230
|
+
version the editor sees, and publishing puts that live — including pending edits nobody made for
|
|
231
|
+
the translation's sake, and including non-localized fields, which Payload stores once per document
|
|
232
|
+
and so cannot scope to a locale. That is what the flag asks for, but it is worth remembering
|
|
233
|
+
before running "translate and publish" over a long list of documents: every unpublished draft
|
|
234
|
+
among them goes live.
|
|
235
|
+
- **`skip_existing` counts anything non-empty as translated.** A translation waiting unpublished in
|
|
236
|
+
a draft counts, so a reviewer's corrected text is published as it stands rather than
|
|
237
|
+
re-translated. It has no notion of *reviewed*, and it does not consult stale-detection — a locale
|
|
238
|
+
the admin marks out of date is still skipped
|
|
239
|
+
([#118](https://github.com/focusreactive/payload-plugins/issues/118)).
|
|
240
|
+
|
|
241
|
+
> **Changed in 0.11.1.** Before this, translating one locale as a draft unpublished the document in
|
|
242
|
+
> every locale, and translating one locale with publishing pushed every other locale's unpublished
|
|
243
|
+
> draft live ([#102](https://github.com/focusreactive/payload-plugins/issues/102)). The source was
|
|
244
|
+
> also read from the published row with fallbacks, so translating from an empty locale translated
|
|
245
|
+
> the default locale's text.
|
|
246
|
+
|
|
208
247
|
### Stale-translation detection
|
|
209
248
|
|
|
210
249
|
_Since v0.8.0._
|
|
@@ -221,6 +260,10 @@ Dismiss acknowledges the drift without re-translating; the marker stays hidden u
|
|
|
221
260
|
changes again. When `provenance` is disabled nothing is shown. Note the fingerprint is text-only, so
|
|
222
261
|
formatting-only edits to rich text do not mark a locale stale.
|
|
223
262
|
|
|
263
|
+
> **Upgrading to 0.11.1.** Records written earlier fingerprinted the source differently, so a locale
|
|
264
|
+
> can read out of date once after upgrading with nothing actually needing re-translation. Dismissing
|
|
265
|
+
> the marker or re-translating settles it.
|
|
266
|
+
|
|
224
267
|
### Auto-translate on source change
|
|
225
268
|
|
|
226
269
|
_Since v0.9.0._
|
|
@@ -249,9 +292,12 @@ translatorPlugin({
|
|
|
249
292
|
Behaviour: fires only on a **published** source save (draft/autosave saves are ignored; a collection
|
|
250
293
|
without drafts treats every save as published); skips when no translatable content actually changed
|
|
251
294
|
(same fingerprint as stale-detection); coalesces rapid edits via `debounceMs`; the translation is saved
|
|
252
|
-
with the source document's status
|
|
295
|
+
with the source document's status, scoped to **only the translated locale**; never re-triggers on its
|
|
253
296
|
own translation writes; and never fails the editor's save (best-effort — failures are logged).
|
|
254
297
|
|
|
298
|
+
> See [Drafts and publishing](#drafts-and-publishing) for what a translation does to a document's
|
|
299
|
+
> published state.
|
|
300
|
+
|
|
255
301
|
> **Requires a working job runner.** Auto-translate only **enqueues** jobs — they run via the task
|
|
256
302
|
> runner (`createPayloadJobsRunner`) and its autorun loop. On serverless platforms such as **Vercel**,
|
|
257
303
|
> cron-based autorun may not run automatically, so enqueued translations can sit unexecuted until
|
package/dist/client/widgets/bulk-translation-dashboard/ui/BulkTranslationDashboard.server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { headers as getHeaders } from "next/headers";
|
|
3
|
-
import {
|
|
3
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
4
4
|
import { resolveAutoTranslateSummary } from "../../../entities/translation/model/autoTranslateSummary";
|
|
5
5
|
import BulkTranslationDashboard from "./BulkTranslationDashboard";
|
|
6
6
|
const BulkTranslationDashboardServer = async (props)=>{
|
|
@@ -15,7 +15,7 @@ const BulkTranslationDashboardServer = async (props)=>{
|
|
|
15
15
|
if (!hasAccess) return null;
|
|
16
16
|
if (!props.collectionSlug) return null;
|
|
17
17
|
const collection = props.payload.collections[props.collectionSlug]?.config;
|
|
18
|
-
const hasDrafts = collection ?
|
|
18
|
+
const hasDrafts = collection ? hasDraftsEnabled(collection) : false;
|
|
19
19
|
const autoTranslate = resolveAutoTranslateSummary(collection, props.payload.config.localization ? props.payload.config.localization.defaultLocale : undefined);
|
|
20
20
|
return /*#__PURE__*/ _jsx(BulkTranslationDashboard, {
|
|
21
21
|
hasDrafts: hasDrafts,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { headers as getHeaders } from "next/headers";
|
|
3
|
-
import {
|
|
3
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
4
4
|
import { resolveAutoTranslateSummary } from "../../../entities/translation/model/autoTranslateSummary";
|
|
5
5
|
import TranslateDocument from "./TranslateDocument";
|
|
6
6
|
async function TranslateDocumentServer(props) {
|
|
@@ -14,7 +14,7 @@ async function TranslateDocumentServer(props) {
|
|
|
14
14
|
});
|
|
15
15
|
if (!hasAccess) return null;
|
|
16
16
|
if (!props.id) return null;
|
|
17
|
-
const hasDrafts =
|
|
17
|
+
const hasDrafts = hasDraftsEnabled(props.collection);
|
|
18
18
|
const autoTranslate = resolveAutoTranslateSummary(props.collection, props.payload.config.localization ? props.payload.config.localization.defaultLocale : undefined);
|
|
19
19
|
return /*#__PURE__*/ _jsx(TranslateDocument, {
|
|
20
20
|
hasDrafts: hasDrafts,
|
|
@@ -47,8 +47,11 @@ import styles from "./styles.module.scss";
|
|
|
47
47
|
const { mutateAsync, isPending } = useTranslateField();
|
|
48
48
|
// `config.localization` is `false | {…}`, so the truthy check is load-bearing (not just a nil guard).
|
|
49
49
|
const defaultLocale = config.localization ? config.localization.defaultLocale : undefined;
|
|
50
|
+
// `useLocale()` is typed `Locale` but actually returns `false | {} | Locale` until the admin's
|
|
51
|
+
// locale provider resolves (see the V4 TODO on the hook in @payloadcms/ui), so `code` can be absent.
|
|
52
|
+
const targetCode = locale?.code ?? "";
|
|
50
53
|
const [isOpen, popup] = useToggle();
|
|
51
|
-
const [sourceLng, setSourceLng] = useState(()=>defaultLocale && defaultLocale !==
|
|
54
|
+
const [sourceLng, setSourceLng] = useState(()=>defaultLocale && defaultLocale !== targetCode ? defaultLocale : "");
|
|
52
55
|
// `null` = no undo available; an object wraps the value so a legitimately `undefined` field value
|
|
53
56
|
// (a real pre-translation state) stays distinguishable from "nothing to undo".
|
|
54
57
|
const [undo, setUndo] = useState(null);
|
|
@@ -57,7 +60,7 @@ import styles from "./styles.module.scss";
|
|
|
57
60
|
// once translating the unsaved value in place is supported.) All hooks above run unconditionally.
|
|
58
61
|
if (id === undefined || id === null) return null;
|
|
59
62
|
// Source options: every locale except the one being edited (it's the fixed target).
|
|
60
|
-
const sourceLocaleOptions = localeOptions.filter((option)=>option.value !==
|
|
63
|
+
const sourceLocaleOptions = localeOptions.filter((option)=>option.value !== targetCode);
|
|
61
64
|
const canTranslate = sourceLng !== "" && !isPending;
|
|
62
65
|
// One write path for every supported field type: a form UPDATE bumping value AND initialValue.
|
|
63
66
|
// A Lexical (richText) editor re-mounts on the initialValue change so it shows the new content;
|
|
@@ -79,7 +82,7 @@ import styles from "./styles.module.scss";
|
|
|
79
82
|
const { data } = await mutateAsync({
|
|
80
83
|
collectionSlug,
|
|
81
84
|
fieldPath: path,
|
|
82
|
-
targetLng:
|
|
85
|
+
targetLng: targetCode,
|
|
83
86
|
sourceLng,
|
|
84
87
|
docId: id
|
|
85
88
|
});
|
|
@@ -132,7 +135,7 @@ import styles from "./styles.module.scss";
|
|
|
132
135
|
className: styles.row,
|
|
133
136
|
children: [
|
|
134
137
|
/*#__PURE__*/ _jsxs("div", {
|
|
135
|
-
"aria-label": `Translation direction: from ${sourceLng || "a source locale"} into ${
|
|
138
|
+
"aria-label": `Translation direction: from ${sourceLng || "a source locale"} into ${targetCode}`,
|
|
136
139
|
className: styles.direction,
|
|
137
140
|
role: "group",
|
|
138
141
|
children: [
|
|
@@ -161,9 +164,9 @@ import styles from "./styles.module.scss";
|
|
|
161
164
|
children: "→"
|
|
162
165
|
}),
|
|
163
166
|
/*#__PURE__*/ _jsx("span", {
|
|
164
|
-
"aria-label": `Target locale: ${
|
|
167
|
+
"aria-label": `Target locale: ${targetCode} (the locale you're editing)`,
|
|
165
168
|
className: styles.current,
|
|
166
|
-
children:
|
|
169
|
+
children: targetCode.toLowerCase()
|
|
167
170
|
})
|
|
168
171
|
]
|
|
169
172
|
}),
|
|
@@ -176,7 +179,7 @@ import styles from "./styles.module.scss";
|
|
|
176
179
|
$isIconButton: true,
|
|
177
180
|
$isLoading: isPending,
|
|
178
181
|
disabled: !canTranslate,
|
|
179
|
-
"aria-label": `Translate from ${sourceLng || "the selected locale"} into ${
|
|
182
|
+
"aria-label": `Translate from ${sourceLng || "the selected locale"} into ${targetCode}`,
|
|
180
183
|
onClick: handleTranslate,
|
|
181
184
|
children: /*#__PURE__*/ _jsx(SendIcon, {})
|
|
182
185
|
})
|
|
@@ -30,9 +30,6 @@ const asObject = (value)=>isObject(value) ? value : {};
|
|
|
30
30
|
this.strategy = strategy;
|
|
31
31
|
}
|
|
32
32
|
/** Collects translatable field chunks that need translation. */ collect() {
|
|
33
|
-
// The read walk SELECTS translatable leaves (via the shared selection core) and records, per
|
|
34
|
-
// selected leaf, the source value to translate plus its write target. The mutation is applied
|
|
35
|
-
// in a separate explicit pass below — read and write are no longer fused inside the walk.
|
|
36
33
|
const selected = [];
|
|
37
34
|
const chunks = [];
|
|
38
35
|
const { strategy } = this;
|
|
@@ -119,8 +116,6 @@ const asObject = (value)=>isObject(value) ? value : {};
|
|
|
119
116
|
target: this.targetData,
|
|
120
117
|
path: []
|
|
121
118
|
}, walker);
|
|
122
|
-
// Apply pass: write each selected leaf's source value into filteredData — this is what gets
|
|
123
|
-
// translated. Kept separate from the read walk above so selection stays read-only.
|
|
124
119
|
for (const { dataRef, key, sourceValue } of selected){
|
|
125
120
|
dataRef[key] = sourceValue;
|
|
126
121
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,8 +11,8 @@ export { createTranslationProvider } from "./translation-providers";
|
|
|
11
11
|
export type { CompletionFn, CompletionRequest, TranslationProviderConfig, JsonSchemaObject, SystemPromptBuilder, SystemPromptContext, } from "./translation-providers";
|
|
12
12
|
export { TranslationProviderError, NoContentError, UnparseableReplyError, KeySetMismatchError, TransportError, ProviderConfigurationError, } from "./translation-providers";
|
|
13
13
|
export type { TranslationFailureCode } from "./translation-providers";
|
|
14
|
-
export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
|
|
15
|
-
export type { TaskRunnerProvider, PayloadJobsRunnerOptions } from "./server/modules/task-runner";
|
|
14
|
+
export { createPayloadJobsRunner, createSyncRunner, toTaskFilter, } from "./server/modules/task-runner";
|
|
15
|
+
export type { TaskRunnerProvider, PayloadJobsRunnerOptions, TaskFilter, } from "./server/modules/task-runner";
|
|
16
16
|
export { documentLevel, collectionLevel, fieldLevel } from "./composition/levels";
|
|
17
17
|
export type { TranslationLevel } from "./server/modules/translation-levels";
|
|
18
18
|
export { withFieldTranslation } from "./field-config";
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export { openAIComplete } from "./translation-providers";
|
|
|
4
4
|
export { createTranslationProvider } from "./translation-providers";
|
|
5
5
|
export { TranslationProviderError, NoContentError, UnparseableReplyError, KeySetMismatchError, TransportError, ProviderConfigurationError } from "./translation-providers";
|
|
6
6
|
// Task runners
|
|
7
|
-
export { createPayloadJobsRunner, createSyncRunner } from "./server/modules/task-runner";
|
|
7
|
+
export { createPayloadJobsRunner, createSyncRunner, toTaskFilter } from "./server/modules/task-runner";
|
|
8
8
|
// Translation levels
|
|
9
9
|
export { documentLevel, collectionLevel, fieldLevel } from "./composition/levels";
|
|
10
10
|
// Field config
|
|
@@ -16,4 +16,5 @@ export declare class TranslateDocumentHandler implements Handler<TranslateDocume
|
|
|
16
16
|
constructor(translationProvider: TranslationProvider, schemaMap: CollectionSchemaMap, provenanceServiceFactory?: ProvenanceServiceFactory);
|
|
17
17
|
handle(payload: Payload, input: TranslateDocumentInput): Promise<TranslateDocumentOutput>;
|
|
18
18
|
private saveTranslatedDocument;
|
|
19
|
+
private publishTargetLocale;
|
|
19
20
|
}
|
|
@@ -2,6 +2,10 @@ import { APIError } from "payload";
|
|
|
2
2
|
import { translateContent } from "../../../core/translation-pipeline";
|
|
3
3
|
import { fetchSourceDocument } from "../../shared/payload/sourceDocument";
|
|
4
4
|
import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext";
|
|
5
|
+
import { resolveTargetLayer } from "./targetLayer";
|
|
6
|
+
/** Loop guard: the auto-translate afterChange hook (#51) skips writes carrying this key. */ const translatorWriteContext = ()=>({
|
|
7
|
+
[AUTO_TRANSLATE_SKIP_CONTEXT_KEY]: true
|
|
8
|
+
});
|
|
5
9
|
/**
|
|
6
10
|
* Translates a single document from source language to target language. Provenance is delegated to
|
|
7
11
|
* {@link ProvenanceService}: this handler only decides *when* to capture the source fingerprint
|
|
@@ -17,71 +21,76 @@ import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateCon
|
|
|
17
21
|
}
|
|
18
22
|
async handle(payload, input) {
|
|
19
23
|
const { collection, collectionId, sourceLng, targetLng, strategy, publishOnTranslation } = input;
|
|
20
|
-
// Get original schema (preserves localized: true on nested fields)
|
|
21
24
|
const schema = this.schemaMap.get(collection);
|
|
22
25
|
if (!schema) throw new APIError(`Collection "${collection}" not found in schemaMap`, 400);
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
26
|
+
const layer = resolveTargetLayer({
|
|
27
|
+
versions: payload.collections[collection].config.versions,
|
|
28
|
+
targetLng
|
|
29
|
+
});
|
|
30
|
+
// `draft: true` is unconditional: on a collection without drafts Payload has no version to
|
|
31
|
+
// substitute, so it returns the only row. The WRITE cannot be so relaxed — the `no-drafts`
|
|
32
|
+
// layer omits `draft` entirely, because that is the argument shape `main` sent.
|
|
33
|
+
const [sourceData, currentTargetVersion] = await Promise.all([
|
|
34
|
+
fetchSourceDocument(payload, collection, collectionId, sourceLng),
|
|
35
|
+
payload.findByID({
|
|
36
|
+
collection,
|
|
37
|
+
id: collectionId,
|
|
38
|
+
locale: targetLng,
|
|
39
|
+
fallbackLocale: false,
|
|
40
|
+
depth: 0,
|
|
41
|
+
draft: true
|
|
42
|
+
})
|
|
43
|
+
]);
|
|
28
44
|
const provenance = this.provenanceServiceFactory?.(payload);
|
|
29
45
|
const sourceFingerprint = provenance?.captureFingerprint(collection, sourceData) ?? null;
|
|
30
|
-
const targetData = await payload.findByID({
|
|
31
|
-
collection,
|
|
32
|
-
id: collectionId,
|
|
33
|
-
locale: targetLng,
|
|
34
|
-
fallbackLocale: false,
|
|
35
|
-
depth: 0
|
|
36
|
-
});
|
|
37
46
|
const translatedData = await translateContent({
|
|
38
47
|
schema,
|
|
39
48
|
sourceData,
|
|
40
|
-
targetData,
|
|
49
|
+
targetData: currentTargetVersion,
|
|
41
50
|
sourceLng,
|
|
42
51
|
targetLng,
|
|
43
52
|
translationProvider: this.translationProvider,
|
|
44
53
|
strategy
|
|
45
54
|
});
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
if (translatedData) {
|
|
56
|
+
await this.saveTranslatedDocument(payload, input, translatedData, layer.write);
|
|
57
|
+
if (provenance && sourceFingerprint !== null) {
|
|
58
|
+
await provenance.record({
|
|
59
|
+
collectionSlug: collection,
|
|
60
|
+
documentId: String(collectionId),
|
|
61
|
+
targetLocale: targetLng,
|
|
62
|
+
sourceLocale: sourceLng
|
|
63
|
+
}, sourceFingerprint);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (publishOnTranslation && layer.kind === "drafts") {
|
|
67
|
+
await this.publishTargetLocale(payload, input, layer.publish);
|
|
58
68
|
}
|
|
59
69
|
return {
|
|
60
70
|
success: true
|
|
61
71
|
};
|
|
62
72
|
}
|
|
63
|
-
async saveTranslatedDocument(payload,
|
|
64
|
-
let isAutosaveEnabled = false;
|
|
65
|
-
const versions = collectionConfig.versions;
|
|
66
|
-
if (versions && versions.drafts) {
|
|
67
|
-
translatedData["_status"] = publishOnTranslation ? "published" : "draft";
|
|
68
|
-
const drafts = versions.drafts;
|
|
69
|
-
if (!publishOnTranslation && drafts.autosave) isAutosaveEnabled = true;
|
|
70
|
-
}
|
|
73
|
+
async saveTranslatedDocument(payload, input, translatedData, write) {
|
|
71
74
|
await payload.update({
|
|
72
|
-
collection: collection,
|
|
73
|
-
id: collectionId,
|
|
75
|
+
collection: input.collection,
|
|
76
|
+
id: input.collectionId,
|
|
74
77
|
data: translatedData,
|
|
75
|
-
|
|
76
|
-
locale: targetLng,
|
|
77
|
-
fallbackLocale: sourceLng,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
...write,
|
|
79
|
+
locale: input.targetLng,
|
|
80
|
+
fallbackLocale: input.sourceLng,
|
|
81
|
+
context: translatorWriteContext()
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async publishTargetLocale(payload, input, publish) {
|
|
85
|
+
await payload.update({
|
|
86
|
+
collection: input.collection,
|
|
87
|
+
id: input.collectionId,
|
|
88
|
+
data: {
|
|
89
|
+
_status: publish.status
|
|
90
|
+
},
|
|
91
|
+
publishSpecificLocale: publish.publishSpecificLocale,
|
|
92
|
+
locale: publish.publishSpecificLocale,
|
|
93
|
+
context: translatorWriteContext()
|
|
85
94
|
});
|
|
86
95
|
}
|
|
87
96
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { CollectionConfig } from "payload";
|
|
2
|
+
export type VersionsSlice = CollectionConfig["versions"];
|
|
3
|
+
/** The arguments that publish one locale, once a collection is known to have a draft layer. */
|
|
4
|
+
export type PublishScope = {
|
|
5
|
+
publishSpecificLocale: string;
|
|
6
|
+
/**
|
|
7
|
+
* Merged into the published write's data. Looks redundant beside `publishSpecificLocale`
|
|
8
|
+
* and is not: without it, any other locale holding a pending draft drags the whole
|
|
9
|
+
* document back to `draft` (#102).
|
|
10
|
+
*/
|
|
11
|
+
status: "published";
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Where a translation is written, and — separately — how the locale is published afterwards.
|
|
15
|
+
*
|
|
16
|
+
* A union, not one shape with optional fields: `publishSpecificLocale` on a collection with
|
|
17
|
+
* versions but no drafts drops every other locale from the live row (Payload 3.84.1, silent).
|
|
18
|
+
* `publish` exists only on the `drafts` variant, so that pair is unbuildable — see
|
|
19
|
+
* `targetLayer.contract.test.ts`.
|
|
20
|
+
*/
|
|
21
|
+
export type TargetLayer = {
|
|
22
|
+
kind: "no-drafts";
|
|
23
|
+
write: {
|
|
24
|
+
autosave: false;
|
|
25
|
+
};
|
|
26
|
+
} | {
|
|
27
|
+
kind: "drafts";
|
|
28
|
+
write: {
|
|
29
|
+
draft: true;
|
|
30
|
+
autosave: boolean;
|
|
31
|
+
};
|
|
32
|
+
publish: PublishScope;
|
|
33
|
+
};
|
|
34
|
+
export declare function resolveTargetLayer(args: {
|
|
35
|
+
versions: VersionsSlice;
|
|
36
|
+
targetLng: string;
|
|
37
|
+
}): TargetLayer;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { hasAutosaveEnabled, hasDraftsEnabled } from "payload/shared";
|
|
2
|
+
export function resolveTargetLayer(args) {
|
|
3
|
+
const { versions, targetLng } = args;
|
|
4
|
+
const config = {
|
|
5
|
+
versions
|
|
6
|
+
};
|
|
7
|
+
if (!hasDraftsEnabled(config)) return {
|
|
8
|
+
kind: "no-drafts",
|
|
9
|
+
write: {
|
|
10
|
+
autosave: false
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
return {
|
|
14
|
+
kind: "drafts",
|
|
15
|
+
write: {
|
|
16
|
+
draft: true,
|
|
17
|
+
autosave: hasAutosaveEnabled(config)
|
|
18
|
+
},
|
|
19
|
+
publish: {
|
|
20
|
+
publishSpecificLocale: targetLng,
|
|
21
|
+
status: "published"
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
//# sourceMappingURL=targetLayer.js.map
|
|
@@ -2,6 +2,7 @@ import { getByPath, ServerResponse } from "../../shared";
|
|
|
2
2
|
import { translateContent } from "../../../core/translation-pipeline";
|
|
3
3
|
import { FieldTranslationInputSchema, MAX_FIELD_VALUE_BYTES } from "./model";
|
|
4
4
|
import { resolveFieldSubtree } from "./resolveFieldSubtree";
|
|
5
|
+
import { fetchSourceDocument } from "../../shared/payload/sourceDocument";
|
|
5
6
|
const byteLength = (value)=>new TextEncoder().encode(JSON.stringify(value) ?? "").length;
|
|
6
7
|
const noop = (value, level, message)=>({
|
|
7
8
|
status: "noop",
|
|
@@ -31,16 +32,9 @@ const noop = (value, level, message)=>({
|
|
|
31
32
|
const { collection_slug, field_path, target_lng, source_lng, doc_id } = parsed.data;
|
|
32
33
|
const fields = this.config.schemaMap.get(collection_slug);
|
|
33
34
|
if (!fields) return ServerResponse.badRequest(`Collection "${collection_slug}" is not available for translation`);
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
const sourceDoc = await req.payload.findByID({
|
|
38
|
-
collection: collection_slug,
|
|
39
|
-
id: doc_id,
|
|
40
|
-
locale: source_lng,
|
|
41
|
-
fallbackLocale: false,
|
|
42
|
-
depth: 0
|
|
43
|
-
});
|
|
35
|
+
// The whole document, not just the field: the resolver needs it to disambiguate `blocks`, whose
|
|
36
|
+
// `blockType` lives in the data.
|
|
37
|
+
const sourceDoc = await fetchSourceDocument(req.payload, collection_slug, doc_id, source_lng);
|
|
44
38
|
const sourceValue = getByPath(sourceDoc, field_path);
|
|
45
39
|
// Guard the *translated* payload (held synchronously through the provider call), not the
|
|
46
40
|
// request body, which now carries no field value.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hasDraftsEnabled } from "payload/shared";
|
|
1
2
|
import { hasSourceContentChanged } from "../../../core/domain/auto-translate";
|
|
2
3
|
import { AUTO_TRANSLATE_CUSTOM_KEY } from "../../../core/domain/auto-translate";
|
|
3
4
|
import { AUTO_TRANSLATE_SKIP_CONTEXT_KEY } from "../../../types/AutoTranslateContext";
|
|
@@ -31,7 +32,7 @@ import { buildAutoTranslateTasks, passesPublishGate } from "./AutoTranslate.poli
|
|
|
31
32
|
return doc;
|
|
32
33
|
}
|
|
33
34
|
if (req.locale !== sourceLocale) return doc;
|
|
34
|
-
const hasDrafts =
|
|
35
|
+
const hasDrafts = hasDraftsEnabled(collection);
|
|
35
36
|
if (!passesPublishGate(doc, hasDrafts)) return doc;
|
|
36
37
|
const schema = schemaMap.get(collection.slug);
|
|
37
38
|
if (schema && !hasSourceContentChanged(previousDoc, doc, schema)) return doc;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toTaskFilter } from "../task-runner/toTaskFilter";
|
|
1
2
|
import { taskFromInput } from "./taskMapping";
|
|
2
3
|
/**
|
|
3
4
|
* Decorate a {@link TaskRunner} so `enqueue` fires the `queued` lifecycle callback for each task.
|
|
@@ -12,7 +13,9 @@ import { taskFromInput } from "./taskMapping";
|
|
|
12
13
|
},
|
|
13
14
|
cancel: (taskIds)=>runner.cancel(taskIds),
|
|
14
15
|
run: (taskId)=>runner.run(taskId),
|
|
15
|
-
|
|
16
|
+
// Normalized rather than forwarded as-is: the wrapper's own signature comes from the overload
|
|
17
|
+
// pair, so it cannot pass the deprecated array form straight through.
|
|
18
|
+
findByCollection: (collectionSlug, filter)=>runner.findByCollection(collectionSlug, toTaskFilter(filter))
|
|
16
19
|
};
|
|
17
20
|
}
|
|
18
21
|
|
|
@@ -24,7 +24,25 @@ export interface TaskRunner {
|
|
|
24
24
|
*/
|
|
25
25
|
run(taskId: string): Promise<RunResult>;
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* @deprecated Pass `{ documentIds }` instead. Removed in the next major.
|
|
28
|
+
* See docs/DEPRECATIONS.md#find-by-collection-document-ids-array
|
|
28
29
|
*/
|
|
29
|
-
findByCollection(collectionSlug: CollectionSlug, documentIds
|
|
30
|
+
findByCollection(collectionSlug: CollectionSlug, documentIds: Array<string | number>): Promise<Task[]>;
|
|
31
|
+
/** Find tasks for a collection, optionally narrowed by a {@link TaskFilter}. */
|
|
32
|
+
findByCollection(collectionSlug: CollectionSlug, filter?: TaskFilter): Promise<Task[]>;
|
|
30
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* How a {@link TaskRunner.findByCollection} call is narrowed. Each field says whether it reaches the
|
|
36
|
+
* database or is applied in memory over everything the database returned.
|
|
37
|
+
*
|
|
38
|
+
* @since 0.12.0
|
|
39
|
+
*/
|
|
40
|
+
export type TaskFilter = {
|
|
41
|
+
/** Keep only tasks for these documents. Applied in memory — see `PayloadJobsTaskRunner.findByCollection`. */
|
|
42
|
+
documentIds?: Array<string | number>;
|
|
43
|
+
/**
|
|
44
|
+
* Drop tasks that have finished. Keeps running and failed ones — everything a re-enqueue can still
|
|
45
|
+
* supersede — so it is wider than the `pending` status.
|
|
46
|
+
*/
|
|
47
|
+
excludeCompleted?: boolean;
|
|
48
|
+
};
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* How a {@link TaskRunner.findByCollection} call is narrowed. Each field says whether it reaches the
|
|
3
|
+
* database or is applied in memory over everything the database returned.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* and execution of translation tasks. All business logic
|
|
6
|
-
* (like cancelling existing tasks before enqueue) is encapsulated
|
|
7
|
-
* within the implementation.
|
|
5
|
+
* @since 0.12.0
|
|
8
6
|
*/ export { };
|
|
9
7
|
|
|
10
8
|
//# sourceMappingURL=TaskRunner.interface.js.map
|
|
@@ -3,4 +3,5 @@ export type { Task, TaskStatus } from "./types";
|
|
|
3
3
|
export { createPayloadJobsRunner } from "./payload-jobs-runner";
|
|
4
4
|
export type { PayloadJobsRunnerOptions } from "./payload-jobs-runner";
|
|
5
5
|
export { createSyncRunner } from "./sync-runner";
|
|
6
|
-
export type { TaskRunner } from "./TaskRunner.interface";
|
|
6
|
+
export type { TaskFilter, TaskRunner } from "./TaskRunner.interface";
|
|
7
|
+
export { toTaskFilter } from "./toTaskFilter";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Payload, CollectionSlug } from "payload";
|
|
2
|
-
import type { TaskRunner } from "../TaskRunner.interface";
|
|
2
|
+
import type { TaskFilter, TaskRunner } from "../TaskRunner.interface";
|
|
3
3
|
import type { Task, TaskInput, RunResult } from "../types";
|
|
4
4
|
import type { PayloadJobsRunnerConfig } from "./types";
|
|
5
5
|
/**
|
|
@@ -15,87 +15,26 @@ export declare class PayloadJobsTaskRunner implements TaskRunner {
|
|
|
15
15
|
cancel(taskIds: string[]): Promise<void>;
|
|
16
16
|
run(taskId: string): Promise<RunResult>;
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
19
|
-
* autorun picker again.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* autorun and is only recoverable via a manual run().
|
|
24
|
-
*
|
|
25
|
-
* A job is stale when it is still `processing: true`, not yet completed, and
|
|
26
|
-
* its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
|
|
27
|
-
* killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
|
|
28
|
-
* in flight on another live instance (fresh `updatedAt`) is left alone.
|
|
29
|
-
* Filters on real `payload-jobs` columns only (no JSON-path traversal), so
|
|
30
|
-
* the drizzle SQLite issue in `findByCollection` does not apply here.
|
|
31
|
-
* @returns the number of jobs reclaimed.
|
|
18
|
+
* Clear stale `processing` locks — still processing, not completed, `updatedAt` older than
|
|
19
|
+
* `staleJobTimeoutMs` — so abandoned jobs are eligible for the autorun picker again. A job that
|
|
20
|
+
* exhausted its retries carries `hasError: true` and stays excluded from autorun even after its
|
|
21
|
+
* lock is cleared; only a manual `run()` recovers it.
|
|
22
|
+
* @returns how many locks were cleared.
|
|
32
23
|
*/
|
|
33
24
|
reclaimStaleJobs(): Promise<number>;
|
|
34
|
-
/**
|
|
35
|
-
* Clear the `processing` lock on every job matching `where`, returning how
|
|
36
|
-
* many were reset. Shared by the per-job reset in `run()` (a stale lock) and
|
|
37
|
-
* the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
|
|
38
|
-
* only the count is needed — no relationships to populate.
|
|
39
|
-
*/
|
|
25
|
+
/** Clears the `processing` lock on every job matching `where`. `depth: 0` — only the count is read. */
|
|
40
26
|
private resetProcessing;
|
|
41
|
-
/**
|
|
42
|
-
* A processing lock is stale once `updatedAt` is older than the configured
|
|
43
|
-
* timeout — the owning run is presumed dead.
|
|
44
|
-
*/
|
|
45
27
|
private isStale;
|
|
46
28
|
/**
|
|
47
|
-
* Find translation jobs for a collection
|
|
48
|
-
*
|
|
49
|
-
* Narrowing is by `taskSlug` only in SQL; the collection slug and document
|
|
50
|
-
* IDs are matched in memory (via the normalized `Task`, which reads both the
|
|
51
|
-
* current flat-text shape and the legacy relationship shape). This is the
|
|
52
|
-
* one path that must transparently span both stored shapes during the
|
|
53
|
-
* ID-agnostic migration — see docs/DEPRECATIONS.md#jobs-input-collection-field.
|
|
54
|
-
*
|
|
55
|
-
* IMPORTANT — why slug/id are matched in memory, not in the SQL WHERE
|
|
56
|
-
* ------------------------------------------------------------------------
|
|
57
|
-
* The "natural" implementation would push `input.collection_id` into the
|
|
58
|
-
* where clause. This DOES NOT work on SQLite (and is unreliable on any
|
|
59
|
-
* adapter) because of two compounding bugs in Payload's drizzle layer.
|
|
60
|
-
*
|
|
61
|
-
* 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
|
|
62
|
-
* drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
|
|
63
|
-
* has no `case 'json'` branch, so the value is left as a raw column and
|
|
64
|
-
* the path segments are passed through to `parseParams.js`, which on
|
|
65
|
-
* SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
|
|
66
|
-
* expressions like `input->>'collection_id'`.
|
|
29
|
+
* Find translation jobs for a collection.
|
|
67
30
|
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* emits queries like `WHERE input->>'collection_id' IN (1)` even though
|
|
73
|
-
* the caller passed `['1']` (an array of strings).
|
|
74
|
-
*
|
|
75
|
-
* On SQLite, `->>` preserves the JSON value's type and `IN (...)` does NOT
|
|
76
|
-
* coerce between TEXT and INTEGER, so a numeric-looking string id never
|
|
77
|
-
* matches once bug #2 strips its quotes. Storing the id as text (this
|
|
78
|
-
* migration) does not fix the SQL path — drizzle re-numbers it anyway — so
|
|
79
|
-
* we keep matching in memory.
|
|
80
|
-
*
|
|
81
|
-
* Why in-memory filtering is acceptable here
|
|
82
|
-
* ------------------------------------------
|
|
83
|
-
* Per-task job sets are small (typically <100 rows; the plugin actively
|
|
84
|
-
* cancels superseded jobs so they don't accumulate), so the JS filtering
|
|
85
|
-
* is effectively free. If/when the upstream drizzle bug is fixed, this can
|
|
86
|
-
* collapse back to a single SQL query.
|
|
87
|
-
*/
|
|
88
|
-
findByCollection(collectionSlug: CollectionSlug, documentIds?: Array<string | number>): Promise<Task[]>;
|
|
89
|
-
/**
|
|
90
|
-
* Group tasks by collection slug
|
|
31
|
+
* Only `taskSlug` and `completedAt` reach the database; slug and document ids are matched in memory
|
|
32
|
+
* because a job's collection reference may sit in either the flat-text fields or the legacy
|
|
33
|
+
* relationship shape (`readCollectionRef`), so a `where` on `input.collection_slug` would silently
|
|
34
|
+
* drop every pre-migration job. `excludeCompleted` is what bounds the read — see issue #108.
|
|
91
35
|
*/
|
|
36
|
+
findByCollection(collectionSlug: CollectionSlug, filter?: Array<string | number> | TaskFilter): Promise<Task[]>;
|
|
92
37
|
private groupByCollection;
|
|
93
|
-
|
|
94
|
-
* Internal cancel implementation
|
|
95
|
-
*/
|
|
96
|
-
private cancelInternal;
|
|
97
|
-
/**
|
|
98
|
-
* Internal method to find jobs with where clause
|
|
99
|
-
*/
|
|
38
|
+
private cancelAndDeleteJobs;
|
|
100
39
|
private findJobsInternal;
|
|
101
40
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toTaskFilter } from "../toTaskFilter";
|
|
1
2
|
import { normalizeJob } from "./normalizeJob";
|
|
2
3
|
// A translation job's supersession identity: same document AND same target locale. IDs are
|
|
3
4
|
// String()-normalized to match the stored (string) form, so a number id compares equal to its
|
|
@@ -18,14 +19,20 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
18
19
|
const byCollection = this.groupByCollection(tasks);
|
|
19
20
|
for (const [collectionSlug, items] of byCollection){
|
|
20
21
|
const documentIds = items.map((t)=>t.collectionId);
|
|
21
|
-
|
|
22
|
+
// Finished jobs must stay out of this set: superseding deletes (`cancelAndDeleteJobs` reaches
|
|
23
|
+
// `payload.delete`), so a completed job for the same (document, locale) would be erased along
|
|
24
|
+
// with the pending one.
|
|
25
|
+
const existing = await this.findByCollection(collectionSlug, {
|
|
26
|
+
documentIds,
|
|
27
|
+
excludeCompleted: true
|
|
28
|
+
});
|
|
22
29
|
// Supersede only jobs for the SAME (document, target locale) being re-enqueued — never a
|
|
23
30
|
// concurrent job for a *different* locale of the same document. Cancelling per-document would
|
|
24
31
|
// kill an in-flight translation of another locale (the concurrent re-translate bug).
|
|
25
32
|
const supersededKeys = new Set(items.map((t)=>documentLocaleKey(t.collectionId, t.targetLng)));
|
|
26
33
|
const toCancel = existing.filter((t)=>supersededKeys.has(documentLocaleKey(t.input.collectionId, t.input.targetLng)));
|
|
27
34
|
if (toCancel.length > 0) {
|
|
28
|
-
await this.
|
|
35
|
+
await this.cancelAndDeleteJobs(toCancel.map((t)=>t.id));
|
|
29
36
|
}
|
|
30
37
|
}
|
|
31
38
|
await Promise.all(tasks.map((task)=>this.payload.jobs.queue({
|
|
@@ -52,7 +59,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
52
59
|
}
|
|
53
60
|
async cancel(taskIds) {
|
|
54
61
|
if (taskIds.length === 0) return;
|
|
55
|
-
await this.
|
|
62
|
+
await this.cancelAndDeleteJobs(taskIds);
|
|
56
63
|
}
|
|
57
64
|
async run(taskId) {
|
|
58
65
|
const tasks = await this.findJobsInternal({
|
|
@@ -76,9 +83,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
76
83
|
};
|
|
77
84
|
}
|
|
78
85
|
if (task.status === "running") {
|
|
79
|
-
//
|
|
80
|
-
// a process killed mid-run) is reclaimable: clear it first so the queue
|
|
81
|
-
// picker below — which only selects `processing: false` — can re-run it.
|
|
86
|
+
// The picker below selects only `processing: false`, so a stale lock must be cleared first.
|
|
82
87
|
if (!this.isStale(task.updatedAt)) {
|
|
83
88
|
return {
|
|
84
89
|
success: false,
|
|
@@ -91,17 +96,10 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
91
96
|
}
|
|
92
97
|
});
|
|
93
98
|
}
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
// NOT `payload.jobs.runByID({ id })`: on the drizzle adapter the id-path
|
|
99
|
-
// (`db.updateJobs({ id })`) writes `processing: true` but returns no rows,
|
|
100
|
-
// so `runJobs` reports `noJobsRemaining` and the handler never runs —
|
|
101
|
-
// leaving the job stuck at `processing: true` forever. The `where`-based
|
|
102
|
-
// picker selects, runs, and finalizes the job correctly (verified against
|
|
103
|
-
// sqlite). The picker also enforces processing:false / no-error / no
|
|
104
|
-
// pending waitUntil, so a failed (max-retries) job is not re-run here.
|
|
99
|
+
// `where` picker, not `payload.jobs.runByID({ id })`: in `runJobs` the guard block
|
|
100
|
+
// (processing:false, hasError not true, waitUntil due) is built only for the non-id branch, so
|
|
101
|
+
// the id path would re-run a job that already exhausted its retries. Checked against payload
|
|
102
|
+
// 3.84.1.
|
|
105
103
|
await this.payload.jobs.run({
|
|
106
104
|
queue: this.config.queueName,
|
|
107
105
|
where: {
|
|
@@ -116,20 +114,11 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
116
114
|
};
|
|
117
115
|
}
|
|
118
116
|
/**
|
|
119
|
-
*
|
|
120
|
-
* autorun picker again.
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* autorun and is only recoverable via a manual run().
|
|
125
|
-
*
|
|
126
|
-
* A job is stale when it is still `processing: true`, not yet completed, and
|
|
127
|
-
* its `updatedAt` is older than `staleJobTimeoutMs` — i.e. a process was
|
|
128
|
-
* killed mid-run (deploy/crash/timeout). Threshold-based, so a job genuinely
|
|
129
|
-
* in flight on another live instance (fresh `updatedAt`) is left alone.
|
|
130
|
-
* Filters on real `payload-jobs` columns only (no JSON-path traversal), so
|
|
131
|
-
* the drizzle SQLite issue in `findByCollection` does not apply here.
|
|
132
|
-
* @returns the number of jobs reclaimed.
|
|
117
|
+
* Clear stale `processing` locks — still processing, not completed, `updatedAt` older than
|
|
118
|
+
* `staleJobTimeoutMs` — so abandoned jobs are eligible for the autorun picker again. A job that
|
|
119
|
+
* exhausted its retries carries `hasError: true` and stays excluded from autorun even after its
|
|
120
|
+
* lock is cleared; only a manual `run()` recovers it.
|
|
121
|
+
* @returns how many locks were cleared.
|
|
133
122
|
*/ async reclaimStaleJobs() {
|
|
134
123
|
const cutoff = new Date(Date.now() - this.config.staleJobTimeoutMs).toISOString();
|
|
135
124
|
return this.resetProcessing({
|
|
@@ -157,12 +146,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
157
146
|
]
|
|
158
147
|
});
|
|
159
148
|
}
|
|
160
|
-
/**
|
|
161
|
-
* Clear the `processing` lock on every job matching `where`, returning how
|
|
162
|
-
* many were reset. Shared by the per-job reset in `run()` (a stale lock) and
|
|
163
|
-
* the bulk boot/recovery reset in `reclaimStaleJobs()`. `depth: 0` because
|
|
164
|
-
* only the count is needed — no relationships to populate.
|
|
165
|
-
*/ async resetProcessing(where) {
|
|
149
|
+
/** Clears the `processing` lock on every job matching `where`. `depth: 0` — only the count is read. */ async resetProcessing(where) {
|
|
166
150
|
const result = await this.payload.update({
|
|
167
151
|
collection: this.config.jobsCollection,
|
|
168
152
|
depth: 0,
|
|
@@ -173,10 +157,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
173
157
|
});
|
|
174
158
|
return result.docs.length;
|
|
175
159
|
}
|
|
176
|
-
|
|
177
|
-
* A processing lock is stale once `updatedAt` is older than the configured
|
|
178
|
-
* timeout — the owning run is presumed dead.
|
|
179
|
-
*/ isStale(updatedAt) {
|
|
160
|
+
isStale(updatedAt) {
|
|
180
161
|
const parsed = Date.parse(updatedAt);
|
|
181
162
|
// Unknown/corrupt timestamp → treat as stale so the job can be recovered
|
|
182
163
|
// rather than permanently refused as already-running.
|
|
@@ -184,60 +165,28 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
184
165
|
return Date.now() - parsed > this.config.staleJobTimeoutMs;
|
|
185
166
|
}
|
|
186
167
|
/**
|
|
187
|
-
* Find translation jobs for a collection
|
|
188
|
-
*
|
|
189
|
-
* Narrowing is by `taskSlug` only in SQL; the collection slug and document
|
|
190
|
-
* IDs are matched in memory (via the normalized `Task`, which reads both the
|
|
191
|
-
* current flat-text shape and the legacy relationship shape). This is the
|
|
192
|
-
* one path that must transparently span both stored shapes during the
|
|
193
|
-
* ID-agnostic migration — see docs/DEPRECATIONS.md#jobs-input-collection-field.
|
|
194
|
-
*
|
|
195
|
-
* IMPORTANT — why slug/id are matched in memory, not in the SQL WHERE
|
|
196
|
-
* ------------------------------------------------------------------------
|
|
197
|
-
* The "natural" implementation would push `input.collection_id` into the
|
|
198
|
-
* where clause. This DOES NOT work on SQLite (and is unreliable on any
|
|
199
|
-
* adapter) because of two compounding bugs in Payload's drizzle layer.
|
|
200
|
-
*
|
|
201
|
-
* 1. The `input` field on `payload-jobs` is declared `type: 'json'`. The
|
|
202
|
-
* drizzle path resolver (`@payloadcms/drizzle/queries/getTableColumnFromPath`)
|
|
203
|
-
* has no `case 'json'` branch, so the value is left as a raw column and
|
|
204
|
-
* the path segments are passed through to `parseParams.js`, which on
|
|
205
|
-
* SQLite builds raw SQL using `convertPathToJSONTraversal` — generating
|
|
206
|
-
* expressions like `input->>'collection_id'`.
|
|
168
|
+
* Find translation jobs for a collection.
|
|
207
169
|
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
*
|
|
221
|
-
* Why in-memory filtering is acceptable here
|
|
222
|
-
* ------------------------------------------
|
|
223
|
-
* Per-task job sets are small (typically <100 rows; the plugin actively
|
|
224
|
-
* cancels superseded jobs so they don't accumulate), so the JS filtering
|
|
225
|
-
* is effectively free. If/when the upstream drizzle bug is fixed, this can
|
|
226
|
-
* collapse back to a single SQL query.
|
|
227
|
-
*/ async findByCollection(collectionSlug, documentIds) {
|
|
228
|
-
const all = await this.findJobsInternal(undefined, {
|
|
170
|
+
* Only `taskSlug` and `completedAt` reach the database; slug and document ids are matched in memory
|
|
171
|
+
* because a job's collection reference may sit in either the flat-text fields or the legacy
|
|
172
|
+
* relationship shape (`readCollectionRef`), so a `where` on `input.collection_slug` would silently
|
|
173
|
+
* drop every pre-migration job. `excludeCompleted` is what bounds the read — see issue #108.
|
|
174
|
+
*/ async findByCollection(collectionSlug, filter) {
|
|
175
|
+
const { documentIds, excludeCompleted } = toTaskFilter(filter);
|
|
176
|
+
const where = excludeCompleted ? {
|
|
177
|
+
completedAt: {
|
|
178
|
+
exists: false
|
|
179
|
+
}
|
|
180
|
+
} : undefined;
|
|
181
|
+
const all = await this.findJobsInternal(where, {
|
|
229
182
|
pagination: false
|
|
230
183
|
});
|
|
231
184
|
const bySlug = all.filter((t)=>t.input.collectionSlug === collectionSlug);
|
|
232
185
|
if (!documentIds?.length) return bySlug;
|
|
233
|
-
// `documentIds` is the public `Array<string | number>` param, so normalize
|
|
234
|
-
// it here; `t.input.collectionId` is already `ID` (string) via normalizeJob.
|
|
235
186
|
const wanted = new Set(documentIds.map(String));
|
|
236
187
|
return bySlug.filter((t)=>wanted.has(t.input.collectionId));
|
|
237
188
|
}
|
|
238
|
-
|
|
239
|
-
* Group tasks by collection slug
|
|
240
|
-
*/ groupByCollection(tasks) {
|
|
189
|
+
groupByCollection(tasks) {
|
|
241
190
|
const map = new Map();
|
|
242
191
|
for (const task of tasks){
|
|
243
192
|
const existing = map.get(task.collectionSlug) ?? [];
|
|
@@ -246,10 +195,12 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
246
195
|
}
|
|
247
196
|
return map;
|
|
248
197
|
}
|
|
249
|
-
|
|
250
|
-
* Internal cancel implementation
|
|
251
|
-
*/ async cancelInternal(taskIds) {
|
|
198
|
+
async cancelAndDeleteJobs(taskIds) {
|
|
252
199
|
if (taskIds.length === 0) return;
|
|
200
|
+
// Both, in this order: `jobs.cancel` only writes `{ error: { cancelled: true }, hasError: true,
|
|
201
|
+
// processing: false }`, which is what signals a running handler to abort. The delete then removes
|
|
202
|
+
// the row — under `deleteJobOnComplete: false` a cancelled job would otherwise sit in the status
|
|
203
|
+
// feed forever.
|
|
253
204
|
await this.payload.jobs.cancel({
|
|
254
205
|
where: {
|
|
255
206
|
id: {
|
|
@@ -267,9 +218,7 @@ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${
|
|
|
267
218
|
}
|
|
268
219
|
});
|
|
269
220
|
}
|
|
270
|
-
|
|
271
|
-
* Internal method to find jobs with where clause
|
|
272
|
-
*/ async findJobsInternal(where, params) {
|
|
221
|
+
async findJobsInternal(where, params) {
|
|
273
222
|
const and = [
|
|
274
223
|
{
|
|
275
224
|
taskSlug: {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Payload, CollectionSlug } from "payload";
|
|
2
|
-
import type { TaskRunner } from "../TaskRunner.interface";
|
|
2
|
+
import type { TaskFilter, TaskRunner } from "../TaskRunner.interface";
|
|
3
3
|
import type { TaskHandler } from "../TaskRunnerProvider.interface";
|
|
4
4
|
import type { Task, TaskInput, RunResult } from "../types";
|
|
5
5
|
import type { LazyMap } from "../../../shared/utils";
|
|
@@ -17,6 +17,6 @@ export declare class SyncTaskRunner implements TaskRunner {
|
|
|
17
17
|
enqueue(inputs: TaskInput[]): Promise<void>;
|
|
18
18
|
cancel(_taskIds: string[]): Promise<void>;
|
|
19
19
|
run(_taskId: string): Promise<RunResult>;
|
|
20
|
-
findByCollection(collectionSlug: CollectionSlug,
|
|
20
|
+
findByCollection(collectionSlug: CollectionSlug, filter?: Array<string | number> | TaskFilter): Promise<Task[]>;
|
|
21
21
|
private getKey;
|
|
22
22
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toTaskFilter } from "../toTaskFilter";
|
|
1
2
|
/**
|
|
2
3
|
* Synchronous TaskRunner implementation.
|
|
3
4
|
*
|
|
@@ -55,12 +56,17 @@
|
|
|
55
56
|
error: "not_found"
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
|
-
async findByCollection(collectionSlug,
|
|
59
|
+
async findByCollection(collectionSlug, filter) {
|
|
60
|
+
const { documentIds, excludeCompleted } = toTaskFilter(filter);
|
|
59
61
|
const results = [];
|
|
60
62
|
const wanted = documentIds ? new Set(documentIds.map(String)) : undefined;
|
|
61
63
|
for (const [, task] of this.tasks){
|
|
62
64
|
if (task.input.collectionSlug !== collectionSlug) continue;
|
|
63
65
|
if (wanted && !wanted.has(task.input.collectionId)) continue;
|
|
66
|
+
// Keyed on `completedAt`, the same field the jobs runner pushes into its where clause. Keying
|
|
67
|
+
// on `status` instead would agree only by accident: `getJobStatus` happens to check
|
|
68
|
+
// `completedAt` before `error`, and reordering it would silently split the two runners.
|
|
69
|
+
if (excludeCompleted && task.completedAt) continue;
|
|
64
70
|
results.push(task);
|
|
65
71
|
}
|
|
66
72
|
return results;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TaskFilter } from "./TaskRunner.interface";
|
|
2
|
+
/**
|
|
3
|
+
* Normalize the two accepted shapes of {@link TaskRunner.findByCollection}'s second argument.
|
|
4
|
+
*
|
|
5
|
+
* Every implementation of {@link TaskRunner} owes this, so it ships alongside the contract rather
|
|
6
|
+
* than being re-derived: the array form is deprecated and will be removed, and a hand-written
|
|
7
|
+
* `Array.isArray` branch in someone else's runner would outlive it.
|
|
8
|
+
*
|
|
9
|
+
* @since 0.12.0
|
|
10
|
+
*/
|
|
11
|
+
export declare function toTaskFilter(filter?: Array<string | number> | TaskFilter): TaskFilter;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize the two accepted shapes of {@link TaskRunner.findByCollection}'s second argument.
|
|
3
|
+
*
|
|
4
|
+
* Every implementation of {@link TaskRunner} owes this, so it ships alongside the contract rather
|
|
5
|
+
* than being re-derived: the array form is deprecated and will be removed, and a hand-written
|
|
6
|
+
* `Array.isArray` branch in someone else's runner would outlive it.
|
|
7
|
+
*
|
|
8
|
+
* @since 0.12.0
|
|
9
|
+
*/ export function toTaskFilter(filter) {
|
|
10
|
+
return Array.isArray(filter) ? {
|
|
11
|
+
documentIds: filter
|
|
12
|
+
} : filter ?? {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
//# sourceMappingURL=toTaskFilter.js.map
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import type { CollectionSlug, Payload } from "payload";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* just translated) and, through {@link ProvenanceService}, by the staleness **read** path
|
|
6
|
-
* (re-fingerprints the live source), so the fingerprint baseline can never drift between the two.
|
|
3
|
+
* The single source read: what "translate from X" resolves to. Both translation write paths and
|
|
4
|
+
* the staleness recompute must go through here, or the fingerprints they compare drift apart.
|
|
7
5
|
*/
|
|
8
6
|
export declare function fetchSourceDocument(payload: Payload, collection: CollectionSlug, id: string, locale: string): Promise<import("payload").JsonObject & import("payload").TypeWithID>;
|
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* just translated) and, through {@link ProvenanceService}, by the staleness **read** path
|
|
5
|
-
* (re-fingerprints the live source), so the fingerprint baseline can never drift between the two.
|
|
2
|
+
* The single source read: what "translate from X" resolves to. Both translation write paths and
|
|
3
|
+
* the staleness recompute must go through here, or the fingerprints they compare drift apart.
|
|
6
4
|
*/ export function fetchSourceDocument(payload, collection, id, locale) {
|
|
7
5
|
return payload.findByID({
|
|
8
6
|
collection,
|
|
9
7
|
id,
|
|
10
8
|
locale,
|
|
11
|
-
depth: 0
|
|
9
|
+
depth: 0,
|
|
10
|
+
// The current version, as the editor sees it. A published-row read is empty whenever the source
|
|
11
|
+
// locale is unpublished — which is exactly what a publish scoped to the target locale leaves
|
|
12
|
+
// behind — and then every fresh translation fingerprints as stale.
|
|
13
|
+
draft: true,
|
|
14
|
+
// Payload's locale fallback resolves an empty source locale to the default locale's text, so
|
|
15
|
+
// "translate from fr" would translate English and fingerprint English as the French source.
|
|
16
|
+
fallbackLocale: false
|
|
12
17
|
});
|
|
13
18
|
}
|
|
14
19
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@focus-reactive/payload-plugin-translator",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"description": "Translation plugin for Payload CMS 3.x. Automatically translate your localized content using any translation provider.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import type { CollectionConfig, SanitizedCollectionConfig } from "payload";
|
|
2
|
-
/**
|
|
3
|
-
* Checks if a collection has drafts enabled.
|
|
4
|
-
* Handles both CollectionConfig and SanitizedCollectionConfig.
|
|
5
|
-
*/
|
|
6
|
-
export declare function collectionHasDrafts(collection: CollectionConfig | SanitizedCollectionConfig): boolean;
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Type guard: Checks if versions config is an object (not boolean).
|
|
3
|
-
*/ function isVersionsObject(versions) {
|
|
4
|
-
return typeof versions === "object" && versions !== null;
|
|
5
|
-
}
|
|
6
|
-
/**
|
|
7
|
-
* Checks if a collection has drafts enabled.
|
|
8
|
-
* Handles both CollectionConfig and SanitizedCollectionConfig.
|
|
9
|
-
*/ export function collectionHasDrafts(collection) {
|
|
10
|
-
const { versions } = collection;
|
|
11
|
-
if (!versions) return false;
|
|
12
|
-
if (!isVersionsObject(versions)) return false;
|
|
13
|
-
return "drafts" in versions && Boolean(versions.drafts);
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
//# sourceMappingURL=collection-guards.js.map
|