@focus-reactive/payload-plugin-translator 0.5.1 → 0.6.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/README.md +46 -8
- package/dist/client/entities/translation/api/mutations/useTranslateField.d.ts +22 -0
- package/dist/client/entities/translation/api/mutations/useTranslateField.js +31 -0
- package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.d.ts +3 -3
- package/dist/client/features/collection-translation-form/ui/CollectionTranslationForm.js +15 -13
- package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.d.ts +2 -2
- package/dist/client/features/collection-translation-popup/ui/CollectionTranslationPopup.js +10 -10
- package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.d.ts +1 -1
- package/dist/client/features/open-document-translation-popup/ui/OpenDocumentTranslationPopup.js +9 -9
- package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.d.ts +2 -2
- package/dist/client/features/translate-document-form/ui/DocumentTranslationForm.js +10 -8
- package/dist/client/shared/lib/assets/icons/SendIcon.d.ts +1 -0
- package/dist/client/shared/lib/assets/icons/SendIcon.js +17 -0
- package/dist/client/shared/ui/Button/Button.d.ts +2 -1
- package/dist/client/shared/ui/Button/Button.js +1 -1
- package/dist/client/shared/ui/Button/styles.module.scss +26 -3
- package/dist/client/widgets/translate-field-control/index.d.ts +1 -0
- package/dist/client/widgets/translate-field-control/index.js +3 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.d.ts +26 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.d.ts +13 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.export.js +14 -0
- package/dist/client/widgets/translate-field-control/ui/TranslateFieldControl.js +204 -0
- package/dist/client/widgets/translate-field-control/ui/styles.module.scss +48 -0
- package/dist/field-actions.d.ts +25 -0
- package/dist/field-actions.js +23 -0
- package/dist/field-config.d.ts +26 -26
- package/dist/field-config.js +14 -37
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/plugin.d.ts +1 -1
- package/dist/plugin.js +3 -1
- package/dist/server/features/translate-field/handler.d.ts +17 -0
- package/dist/server/features/translate-field/handler.js +85 -0
- package/dist/server/features/translate-field/index.d.ts +6 -0
- package/dist/server/features/translate-field/index.js +5 -0
- package/dist/server/features/translate-field/model.d.ts +62 -0
- package/dist/server/features/translate-field/model.js +26 -0
- package/dist/server/features/translate-field/resolveFieldSubtree.d.ts +15 -7
- package/dist/server/features/translate-field/resolveFieldSubtree.js +14 -8
- package/dist/server/features/translate-field/route.d.ts +12 -0
- package/dist/server/features/translate-field/route.js +18 -0
- package/dist/server/modules/translation-levels/PluginConfigBuilder.d.ts +6 -0
- package/dist/server/modules/translation-levels/PluginConfigBuilder.js +4 -0
- package/dist/server/modules/translation-levels/fieldLevel.d.ts +23 -0
- package/dist/server/modules/translation-levels/fieldLevel.js +37 -0
- package/dist/server/modules/translation-levels/index.d.ts +1 -0
- package/dist/server/modules/translation-levels/index.js +1 -0
- package/dist/server/modules/translation-levels/types.d.ts +6 -1
- package/dist/server/modules/translation-levels/types.js +0 -1
- package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.js +10 -4
- package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.d.ts +1 -1
- package/dist/server/modules/translation-pipeline/stages/data-reconciler/DataReconciler.stage.js +1 -1
- package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.d.ts +5 -0
- package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.js +13 -4
- package/dist/server/modules/translation-providers/OpenAITranslation.provider.d.ts +17 -0
- package/dist/server/modules/translation-providers/OpenAITranslation.provider.js +8 -2
- package/dist/server/shared/field-config/types.js +1 -1
- package/dist/server/shared/field-traversal/findFieldByPath.d.ts +25 -10
- package/dist/server/shared/field-traversal/findFieldByPath.js +59 -14
- package/dist/server/shared/field-traversal/index.d.ts +1 -1
- package/dist/server/shared/field-traversal/index.js +1 -1
- package/dist/server/shared/field-traversal/kernel.d.ts +22 -0
- package/dist/server/shared/field-traversal/kernel.js +27 -0
- package/package.json +2 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { CollectionSchemaMap } from "../translate-document";
|
|
3
|
+
import type { TranslationProvider } from "../../modules/translation-providers";
|
|
4
|
+
/**
|
|
5
|
+
* Max serialized size of the value the endpoint will translate — the value read from the
|
|
6
|
+
* source-locale document (the request body no longer carries one; this is from-locale only). The
|
|
7
|
+
* field endpoint is synchronous — it holds the request for the whole translation — so an unbounded
|
|
8
|
+
* payload is a DoS surface. 256 KiB comfortably covers a long `richText` document; still a guard,
|
|
9
|
+
* not yet configurable.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MAX_FIELD_VALUE_BYTES: number;
|
|
12
|
+
/**
|
|
13
|
+
* Request body for `POST {basePath}/field`. Snake_case to match the other translator routes.
|
|
14
|
+
*
|
|
15
|
+
* Single mode — **from locale**: the server reads the saved document (`doc_id`) in `source_lng`,
|
|
16
|
+
* takes its value at `field_path`, and translates it into `target_lng`. Both `source_lng` and
|
|
17
|
+
* `doc_id` are therefore required (a saved document). `doc_id` reuses {@link JobIdSchema} — the
|
|
18
|
+
* canonical id guard, ID-format-agnostic (string UUID/ObjectId or numeric autoincrement),
|
|
19
|
+
* normalized to a string.
|
|
20
|
+
*/
|
|
21
|
+
export declare const FieldTranslationInputSchema: z.ZodObject<{
|
|
22
|
+
collection_slug: z.ZodString;
|
|
23
|
+
field_path: z.ZodString;
|
|
24
|
+
target_lng: z.ZodString;
|
|
25
|
+
source_lng: z.ZodString;
|
|
26
|
+
doc_id: z.ZodEffects<z.ZodUnion<[z.ZodEffects<z.ZodString, string, string>, z.ZodNumber]>, string, string | number>;
|
|
27
|
+
}, "strip", z.ZodTypeAny, {
|
|
28
|
+
collection_slug: string;
|
|
29
|
+
source_lng: string;
|
|
30
|
+
target_lng: string;
|
|
31
|
+
field_path: string;
|
|
32
|
+
doc_id: string;
|
|
33
|
+
}, {
|
|
34
|
+
collection_slug: string;
|
|
35
|
+
source_lng: string;
|
|
36
|
+
target_lng: string;
|
|
37
|
+
field_path: string;
|
|
38
|
+
doc_id: string | number;
|
|
39
|
+
}>;
|
|
40
|
+
export type FieldTranslationInput = z.infer<typeof FieldTranslationInputSchema>;
|
|
41
|
+
/** Handler dependencies — the schema source of truth + the translation backend. */
|
|
42
|
+
export type FieldTranslationConfig = {
|
|
43
|
+
schemaMap: CollectionSchemaMap;
|
|
44
|
+
translationProvider: TranslationProvider;
|
|
45
|
+
};
|
|
46
|
+
export type FieldTranslationNotice = {
|
|
47
|
+
level: "info" | "warning";
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Successful response. Never an error for "couldn't translate": a field with no
|
|
52
|
+
* localized content (or a path our resolver can't handle yet) is a `noop` with a
|
|
53
|
+
* calm notice, not an HTTP error.
|
|
54
|
+
*/
|
|
55
|
+
export type FieldTranslationResult = {
|
|
56
|
+
status: "translated";
|
|
57
|
+
value: unknown;
|
|
58
|
+
} | {
|
|
59
|
+
status: "noop";
|
|
60
|
+
value: unknown;
|
|
61
|
+
notice: FieldTranslationNotice;
|
|
62
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { JobIdSchema } from "../../shared";
|
|
3
|
+
/**
|
|
4
|
+
* Max serialized size of the value the endpoint will translate — the value read from the
|
|
5
|
+
* source-locale document (the request body no longer carries one; this is from-locale only). The
|
|
6
|
+
* field endpoint is synchronous — it holds the request for the whole translation — so an unbounded
|
|
7
|
+
* payload is a DoS surface. 256 KiB comfortably covers a long `richText` document; still a guard,
|
|
8
|
+
* not yet configurable.
|
|
9
|
+
*/ export const MAX_FIELD_VALUE_BYTES = 256 * 1024;
|
|
10
|
+
/**
|
|
11
|
+
* Request body for `POST {basePath}/field`. Snake_case to match the other translator routes.
|
|
12
|
+
*
|
|
13
|
+
* Single mode — **from locale**: the server reads the saved document (`doc_id`) in `source_lng`,
|
|
14
|
+
* takes its value at `field_path`, and translates it into `target_lng`. Both `source_lng` and
|
|
15
|
+
* `doc_id` are therefore required (a saved document). `doc_id` reuses {@link JobIdSchema} — the
|
|
16
|
+
* canonical id guard, ID-format-agnostic (string UUID/ObjectId or numeric autoincrement),
|
|
17
|
+
* normalized to a string.
|
|
18
|
+
*/ export const FieldTranslationInputSchema = z.object({
|
|
19
|
+
collection_slug: z.string().nonempty(),
|
|
20
|
+
field_path: z.string().nonempty(),
|
|
21
|
+
target_lng: z.string().nonempty(),
|
|
22
|
+
source_lng: z.string().nonempty(),
|
|
23
|
+
doc_id: JobIdSchema
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
//# sourceMappingURL=model.js.map
|
|
@@ -6,8 +6,11 @@ import type { Field } from "payload";
|
|
|
6
6
|
* are ready for `translateContent`, and `fieldName` is the key to unwrap the result.
|
|
7
7
|
* - `not-found` — the path resolves to no field at all (a typo) → caller returns 400.
|
|
8
8
|
* - `not-translatable` — resolves to a field that isn't a text-like leaf → caller no-ops.
|
|
9
|
-
* - `inside-blocks` — the path descends through a polymorphic `blocks` field
|
|
10
|
-
*
|
|
9
|
+
* - `inside-blocks` — the path descends through a polymorphic `blocks` field that couldn't be
|
|
10
|
+
* resolved: no `doc` was supplied to read the element's `blockType` from → caller no-ops.
|
|
11
|
+
* - `localized-list-ancestor` — the path descends through a `localized` `blocks`/`array` field,
|
|
12
|
+
* whose per-locale order/content is independent, so a positional path can't be matched across
|
|
13
|
+
* locales → caller no-ops (translate the whole document instead).
|
|
11
14
|
*/
|
|
12
15
|
export type FieldSubtreeResolution = {
|
|
13
16
|
status: "resolved";
|
|
@@ -20,12 +23,17 @@ export type FieldSubtreeResolution = {
|
|
|
20
23
|
status: "not-translatable";
|
|
21
24
|
} | {
|
|
22
25
|
status: "inside-blocks";
|
|
26
|
+
} | {
|
|
27
|
+
status: "localized-list-ancestor";
|
|
23
28
|
};
|
|
24
29
|
/**
|
|
25
30
|
* Map a declared `fieldPath` to the `{ schema, sourceData }` pair `translateContent` expects.
|
|
26
|
-
* Walks the schema
|
|
27
|
-
*
|
|
28
|
-
* containers
|
|
29
|
-
*
|
|
31
|
+
* Walks the schema via the shared {@link findFieldByPath}. Descends transparently through
|
|
32
|
+
* presentational containers (row, collapsible, unnamed tabs) and through named group/array/tab
|
|
33
|
+
* containers; array element indices select the data item but not the schema (shared across items).
|
|
34
|
+
*
|
|
35
|
+
* Pass `doc` (the document the path belongs to) to descend through polymorphic `blocks` fields:
|
|
36
|
+
* the element's `blockType` in `doc` picks the block schema. Without `doc`, a path through a
|
|
37
|
+
* `blocks` field returns `inside-blocks`.
|
|
30
38
|
*/
|
|
31
|
-
export declare function resolveFieldSubtree(rootFields: Field[], fieldPath: string, value: unknown): FieldSubtreeResolution;
|
|
39
|
+
export declare function resolveFieldSubtree(rootFields: Field[], fieldPath: string, value: unknown, doc?: unknown): FieldSubtreeResolution;
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
import { isTranslatableField } from "../../shared";
|
|
2
2
|
import { findFieldByPath } from "../../shared/field-traversal";
|
|
3
|
-
const isIndexSegment = (segment)=>/^\d+$/u.test(segment);
|
|
4
3
|
/**
|
|
5
4
|
* Map a declared `fieldPath` to the `{ schema, sourceData }` pair `translateContent` expects.
|
|
6
|
-
* Walks the schema
|
|
7
|
-
*
|
|
8
|
-
* containers
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
* Walks the schema via the shared {@link findFieldByPath}. Descends transparently through
|
|
6
|
+
* presentational containers (row, collapsible, unnamed tabs) and through named group/array/tab
|
|
7
|
+
* containers; array element indices select the data item but not the schema (shared across items).
|
|
8
|
+
*
|
|
9
|
+
* Pass `doc` (the document the path belongs to) to descend through polymorphic `blocks` fields:
|
|
10
|
+
* the element's `blockType` in `doc` picks the block schema. Without `doc`, a path through a
|
|
11
|
+
* `blocks` field returns `inside-blocks`.
|
|
12
|
+
*/ export function resolveFieldSubtree(rootFields, fieldPath, value, doc) {
|
|
13
|
+
const segments = fieldPath.split(".").map((segment)=>segment.trim()).filter((segment)=>segment.length > 0);
|
|
12
14
|
if (segments.length === 0) return {
|
|
13
15
|
status: "not-found"
|
|
14
16
|
};
|
|
15
|
-
const result = findFieldByPath(rootFields, segments);
|
|
17
|
+
const result = findFieldByPath(rootFields, segments, doc);
|
|
16
18
|
switch(result.status){
|
|
17
19
|
case "leaf":
|
|
18
20
|
return isTranslatableField(result.field) ? {
|
|
@@ -35,6 +37,10 @@ const isIndexSegment = (segment)=>/^\d+$/u.test(segment);
|
|
|
35
37
|
return {
|
|
36
38
|
status: "inside-blocks"
|
|
37
39
|
};
|
|
40
|
+
case "localized-list-ancestor":
|
|
41
|
+
return {
|
|
42
|
+
status: "localized-list-ancestor"
|
|
43
|
+
};
|
|
38
44
|
case "not-found":
|
|
39
45
|
return {
|
|
40
46
|
status: "not-found"
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Endpoint } from "payload";
|
|
2
|
+
import type { AccessGuard } from "../../shared";
|
|
3
|
+
import type { FieldTranslationConfig } from "./model";
|
|
4
|
+
export type CreateFieldRouteArgs = FieldTranslationConfig & {
|
|
5
|
+
access?: AccessGuard;
|
|
6
|
+
basePath?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Creates the synchronous field-translation endpoint: `POST {basePath}/field`.
|
|
10
|
+
* Wired with the same access + error-envelope plumbing as the document routes.
|
|
11
|
+
*/
|
|
12
|
+
export declare function createFieldRoute({ schemaMap, translationProvider, access, basePath }: CreateFieldRouteArgs): Endpoint;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { withAccessCheck, withErrorHandler } from "../../shared";
|
|
2
|
+
import { TranslateFieldHandler } from "./handler";
|
|
3
|
+
/**
|
|
4
|
+
* Creates the synchronous field-translation endpoint: `POST {basePath}/field`.
|
|
5
|
+
* Wired with the same access + error-envelope plumbing as the document routes.
|
|
6
|
+
*/ export function createFieldRoute({ schemaMap, translationProvider, access, basePath = "/translate" }) {
|
|
7
|
+
const handler = new TranslateFieldHandler({
|
|
8
|
+
schemaMap,
|
|
9
|
+
translationProvider
|
|
10
|
+
});
|
|
11
|
+
return {
|
|
12
|
+
path: `${basePath}/field`,
|
|
13
|
+
method: "post",
|
|
14
|
+
handler: withAccessCheck(withErrorHandler(handler.handle.bind(handler)), access)
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
//# sourceMappingURL=route.js.map
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { CollectionConfig, Config, Endpoint } from "payload";
|
|
2
2
|
import type { AccessGuard } from "../../../types/AccessGuard";
|
|
3
3
|
import type { RawPayloadComponentExport } from "../../../client/shared/types/PayloadComponentExport";
|
|
4
|
+
import type { CollectionSchemaMap } from "../../features/translate-document";
|
|
5
|
+
import type { TranslationProvider } from "../translation-providers";
|
|
4
6
|
import type { TaskRunnerFactory } from "../task-runner";
|
|
5
7
|
import type { CollectionAdminSlot, LevelContext } from "./types";
|
|
6
8
|
type ConfigModifier = (config: Config) => Config;
|
|
@@ -9,6 +11,8 @@ export type PluginConfigBuilderDeps = {
|
|
|
9
11
|
basePath: string;
|
|
10
12
|
access?: AccessGuard;
|
|
11
13
|
taskRunnerFactory: TaskRunnerFactory;
|
|
14
|
+
schemaMap: CollectionSchemaMap;
|
|
15
|
+
translationProvider: TranslationProvider;
|
|
12
16
|
};
|
|
13
17
|
/**
|
|
14
18
|
* The single place that mutates the Payload `config`. Levels (through the narrow
|
|
@@ -26,6 +30,8 @@ export declare class PluginConfigBuilder implements LevelContext {
|
|
|
26
30
|
readonly basePath: string;
|
|
27
31
|
readonly access?: AccessGuard;
|
|
28
32
|
readonly taskRunnerFactory: TaskRunnerFactory;
|
|
33
|
+
readonly schemaMap: CollectionSchemaMap;
|
|
34
|
+
readonly translationProvider: TranslationProvider;
|
|
29
35
|
private readonly endpoints;
|
|
30
36
|
private readonly collectionComponents;
|
|
31
37
|
private readonly adminProviders;
|
|
@@ -32,6 +32,8 @@ function attachToSlot(collection, slot, component) {
|
|
|
32
32
|
basePath;
|
|
33
33
|
access;
|
|
34
34
|
taskRunnerFactory;
|
|
35
|
+
schemaMap;
|
|
36
|
+
translationProvider;
|
|
35
37
|
endpoints = [];
|
|
36
38
|
collectionComponents = [];
|
|
37
39
|
adminProviders = [];
|
|
@@ -41,6 +43,8 @@ function attachToSlot(collection, slot, component) {
|
|
|
41
43
|
this.basePath = deps.basePath;
|
|
42
44
|
this.access = deps.access;
|
|
43
45
|
this.taskRunnerFactory = deps.taskRunnerFactory;
|
|
46
|
+
this.schemaMap = deps.schemaMap;
|
|
47
|
+
this.translationProvider = deps.translationProvider;
|
|
44
48
|
}
|
|
45
49
|
addEndpoints(endpoints) {
|
|
46
50
|
this.endpoints.push(...endpoints);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { TranslationLevel } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Field-level translation surface: a synchronous `POST {basePath}/field` endpoint that translates
|
|
4
|
+
* a single declared field **from a chosen source locale** — it reads the field's value from the
|
|
5
|
+
* saved document in that locale and translates it into the active locale. No runner, no queue, no
|
|
6
|
+
* persistence. Unlike `documentLevel` / `collectionLevel`, it contributes no admin component: the
|
|
7
|
+
* per-field control UI is wired separately at field-declaration time via `withFieldTranslation(field)`.
|
|
8
|
+
*
|
|
9
|
+
* Not part of the default `levels`, so it stays strictly opt-in.
|
|
10
|
+
*
|
|
11
|
+
* @returns an opaque {@link TranslationLevel} to list in `translatorPlugin({ levels })`
|
|
12
|
+
* @since 0.6.0
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* translatorPlugin({
|
|
16
|
+
* collections: [Posts],
|
|
17
|
+
* translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
|
|
18
|
+
* runner: createPayloadJobsRunner(),
|
|
19
|
+
* levels: [documentLevel(), fieldLevel()],
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function fieldLevel(): TranslationLevel;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createFieldRoute } from "../../features/translate-field";
|
|
2
|
+
/**
|
|
3
|
+
* Field-level translation surface: a synchronous `POST {basePath}/field` endpoint that translates
|
|
4
|
+
* a single declared field **from a chosen source locale** — it reads the field's value from the
|
|
5
|
+
* saved document in that locale and translates it into the active locale. No runner, no queue, no
|
|
6
|
+
* persistence. Unlike `documentLevel` / `collectionLevel`, it contributes no admin component: the
|
|
7
|
+
* per-field control UI is wired separately at field-declaration time via `withFieldTranslation(field)`.
|
|
8
|
+
*
|
|
9
|
+
* Not part of the default `levels`, so it stays strictly opt-in.
|
|
10
|
+
*
|
|
11
|
+
* @returns an opaque {@link TranslationLevel} to list in `translatorPlugin({ levels })`
|
|
12
|
+
* @since 0.6.0
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* translatorPlugin({
|
|
16
|
+
* collections: [Posts],
|
|
17
|
+
* translationProvider: createOpenAIProvider({ apiKey: process.env.OPENAI_API_KEY }),
|
|
18
|
+
* runner: createPayloadJobsRunner(),
|
|
19
|
+
* levels: [documentLevel(), fieldLevel()],
|
|
20
|
+
* });
|
|
21
|
+
* ```
|
|
22
|
+
*/ export function fieldLevel() {
|
|
23
|
+
return {
|
|
24
|
+
extend (ctx) {
|
|
25
|
+
ctx.addEndpoints([
|
|
26
|
+
createFieldRoute({
|
|
27
|
+
schemaMap: ctx.schemaMap,
|
|
28
|
+
translationProvider: ctx.translationProvider,
|
|
29
|
+
access: ctx.access,
|
|
30
|
+
basePath: ctx.basePath
|
|
31
|
+
})
|
|
32
|
+
]);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
//# sourceMappingURL=fieldLevel.js.map
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { CollectionConfig, Endpoint } from "payload";
|
|
2
2
|
import type { AccessGuard } from "../../../types/AccessGuard";
|
|
3
3
|
import type { RawPayloadComponentExport } from "../../../client/shared/types/PayloadComponentExport";
|
|
4
|
+
import type { CollectionSchemaMap } from "../../features/translate-document";
|
|
5
|
+
import type { TranslationProvider } from "../translation-providers";
|
|
4
6
|
import type { TaskRunnerFactory } from "../task-runner";
|
|
5
7
|
export type CollectionAdminSlot = "beforeDocumentControls" | "beforeListTable";
|
|
6
8
|
/**
|
|
@@ -27,7 +29,6 @@ export interface TranslationLevel {
|
|
|
27
29
|
*
|
|
28
30
|
* A level contributes via these generic primitives; it never mutates the raw
|
|
29
31
|
* Payload config. The plugin deduplicates endpoints by method + path on apply.
|
|
30
|
-
* (fieldLevel — Phase 2 — will also read `schemaMap` + `translationProvider`.)
|
|
31
32
|
* @internal
|
|
32
33
|
*/
|
|
33
34
|
export interface LevelContext {
|
|
@@ -35,6 +36,10 @@ export interface LevelContext {
|
|
|
35
36
|
readonly basePath: string;
|
|
36
37
|
readonly access?: AccessGuard;
|
|
37
38
|
readonly taskRunnerFactory: TaskRunnerFactory;
|
|
39
|
+
/** Deep-cloned localized field schema per managed collection slug. */
|
|
40
|
+
readonly schemaMap: CollectionSchemaMap;
|
|
41
|
+
/** The configured translation backend (used by the synchronous field level). */
|
|
42
|
+
readonly translationProvider: TranslationProvider;
|
|
38
43
|
/** Register endpoints. Deduplicated by method + path when applied. */
|
|
39
44
|
addEndpoints(endpoints: Endpoint[]): void;
|
|
40
45
|
/** Attach an admin component to a slot on every managed collection. */
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A level contributes via these generic primitives; it never mutates the raw
|
|
5
5
|
* Payload config. The plugin deduplicates endpoints by method + path on apply.
|
|
6
|
-
* (fieldLevel — Phase 2 — will also read `schemaMap` + `translationProvider`.)
|
|
7
6
|
* @internal
|
|
8
7
|
*/ export { };
|
|
9
8
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isEmpty, isObject } from "../../../../shared";
|
|
2
|
-
import { resolveBlockFields, walkFields } from "../../../../shared/field-traversal";
|
|
2
|
+
import { matchElementById, resolveBlockFields, walkFields } from "../../../../shared/field-traversal";
|
|
3
3
|
const asObject = (value)=>isObject(value) ? value : {};
|
|
4
4
|
/**
|
|
5
5
|
* Reconcile walker: produces the full document shape from source + target, with target
|
|
@@ -7,6 +7,9 @@ const asObject = (value)=>isObject(value) ? value : {};
|
|
|
7
7
|
* no source value is dropped, `id` is stripped from array/block elements (Postgres rejects it
|
|
8
8
|
* on update), `blockType` is preserved, and non-object array items / unknown blocks pass
|
|
9
9
|
* through unchanged.
|
|
10
|
+
*
|
|
11
|
+
* Array/block elements are paired with their target counterpart by `id`, not by position (see
|
|
12
|
+
* {@link matchElementById}); output order always follows `source`.
|
|
10
13
|
*/ const reconcileWalker = {
|
|
11
14
|
enterObject (field, cursor) {
|
|
12
15
|
const sourceValue = cursor.source[field.name];
|
|
@@ -21,15 +24,17 @@ const asObject = (value)=>isObject(value) ? value : {};
|
|
|
21
24
|
if (!Array.isArray(sourceValue)) return "skip";
|
|
22
25
|
const targetValue = cursor.target[field.name];
|
|
23
26
|
const targetArr = Array.isArray(targetValue) ? targetValue : [];
|
|
27
|
+
const isBlocks = field.type === "blocks";
|
|
24
28
|
const children = [];
|
|
25
29
|
sourceValue.forEach((item, index)=>{
|
|
26
30
|
if (!isObject(item)) return; // non-object element → passthrough (rebuilt in combine)
|
|
27
|
-
const fields =
|
|
31
|
+
const fields = isBlocks ? resolveBlockFields(field, item) : field.fields;
|
|
28
32
|
if (!fields) return; // unknown blockType → passthrough
|
|
33
|
+
// Pair by id, not position: target[index] may be a different element under per-locale ordering.
|
|
29
34
|
children.push({
|
|
30
35
|
cursor: {
|
|
31
36
|
source: item,
|
|
32
|
-
target:
|
|
37
|
+
target: matchElementById(targetArr, item, isBlocks)
|
|
33
38
|
},
|
|
34
39
|
fields,
|
|
35
40
|
key: index
|
|
@@ -45,7 +50,8 @@ const asObject = (value)=>isObject(value) ? value : {};
|
|
|
45
50
|
},
|
|
46
51
|
combine (container, children, cursor) {
|
|
47
52
|
if (container.kind === "list") {
|
|
48
|
-
// Rebuild the full array: reconciled objects
|
|
53
|
+
// Rebuild the full array in source order: reconciled objects keyed by source position, raw
|
|
54
|
+
// source items (non-object / unknown block) otherwise.
|
|
49
55
|
const sourceArr = cursor.source[container.key] ?? [];
|
|
50
56
|
const byIndex = new Map(children.map((child)=>[
|
|
51
57
|
child.key,
|
package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ import type { FieldChunk } from "../../types";
|
|
|
8
8
|
* mutable reference to its parent object (for later write-back). Collect-only — `combine` is a
|
|
9
9
|
* no-op; results accumulate in a closure.
|
|
10
10
|
*
|
|
11
|
+
* Array/block elements pair their `source` by position (`filteredData` is built in source order)
|
|
12
|
+
* but their `target` by `id` (see {@link matchElementById}) — the target locale's elements may be
|
|
13
|
+
* independently ordered, so a positional target would feed `strategy.shouldTranslate` the wrong
|
|
14
|
+
* element's value and skip/re-translate the wrong leaf.
|
|
15
|
+
*
|
|
11
16
|
* IMPORTANT: Expects ORIGINAL collection schemas (before Payload sanitization). Original
|
|
12
17
|
* schemas preserve `localized: true` on nested fields.
|
|
13
18
|
*/
|
package/dist/server/modules/translation-pipeline/stages/field-collector/FieldChunkCollector.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isFieldExcludedFromTranslation, isLocalizedField, isObject, isTranslatableField } from "../../../../shared";
|
|
2
|
-
import { resolveBlockFields, walkFields } from "../../../../shared/field-traversal";
|
|
2
|
+
import { matchElementById, resolveBlockFields, walkFields } from "../../../../shared/field-traversal";
|
|
3
3
|
const asObject = (value)=>isObject(value) ? value : {};
|
|
4
4
|
/**
|
|
5
5
|
* Collects FieldChunks by walking schema + data with the shared {@link walkFields} engine.
|
|
@@ -8,6 +8,11 @@ const asObject = (value)=>isObject(value) ? value : {};
|
|
|
8
8
|
* mutable reference to its parent object (for later write-back). Collect-only — `combine` is a
|
|
9
9
|
* no-op; results accumulate in a closure.
|
|
10
10
|
*
|
|
11
|
+
* Array/block elements pair their `source` by position (`filteredData` is built in source order)
|
|
12
|
+
* but their `target` by `id` (see {@link matchElementById}) — the target locale's elements may be
|
|
13
|
+
* independently ordered, so a positional target would feed `strategy.shouldTranslate` the wrong
|
|
14
|
+
* element's value and skip/re-translate the wrong leaf.
|
|
15
|
+
*
|
|
11
16
|
* IMPORTANT: Expects ORIGINAL collection schemas (before Payload sanitization). Original
|
|
12
17
|
* schemas preserve `localized: true` on nested fields.
|
|
13
18
|
*/ export class FieldChunkCollector {
|
|
@@ -47,16 +52,20 @@ const asObject = (value)=>isObject(value) ? value : {};
|
|
|
47
52
|
const targetValue = cursor.target[field.name];
|
|
48
53
|
const sourceArr = Array.isArray(sourceValue) ? sourceValue : [];
|
|
49
54
|
const targetArr = Array.isArray(targetValue) ? targetValue : [];
|
|
55
|
+
const isBlocks = field.type === "blocks";
|
|
50
56
|
const children = [];
|
|
51
57
|
value.forEach((item, index)=>{
|
|
52
58
|
if (!isObject(item)) return;
|
|
53
|
-
const fields =
|
|
59
|
+
const fields = isBlocks ? resolveBlockFields(field, item) : field.fields;
|
|
54
60
|
if (!fields) return; // unknown blockType → skip element
|
|
61
|
+
// source pairs by index (filteredData shares source order); target by the source
|
|
62
|
+
// element's id, since the target locale may be reordered independently.
|
|
63
|
+
const sourceItem = asObject(sourceArr[index]);
|
|
55
64
|
children.push({
|
|
56
65
|
cursor: {
|
|
57
66
|
data: item,
|
|
58
|
-
source:
|
|
59
|
-
target:
|
|
67
|
+
source: sourceItem,
|
|
68
|
+
target: matchElementById(targetArr, sourceItem, isBlocks),
|
|
60
69
|
path: [
|
|
61
70
|
...cursor.path,
|
|
62
71
|
field.name,
|
|
@@ -76,6 +76,23 @@ export type OpenAIProviderConfig = {
|
|
|
76
76
|
* @default false
|
|
77
77
|
*/
|
|
78
78
|
dryRun?: boolean | DryRunConfig;
|
|
79
|
+
/**
|
|
80
|
+
* Per-request timeout in milliseconds for the OpenAI client. A translation job blocks on this
|
|
81
|
+
* call, so the OpenAI SDK default (10 minutes) is usually too long. Omit to keep the SDK default.
|
|
82
|
+
*
|
|
83
|
+
* @example
|
|
84
|
+
* timeout: 60_000 // 60s
|
|
85
|
+
*
|
|
86
|
+
* @since 0.6.0
|
|
87
|
+
*/
|
|
88
|
+
timeout?: number;
|
|
89
|
+
/**
|
|
90
|
+
* Maximum automatic retries the OpenAI client performs on transient errors (429, 5xx, network).
|
|
91
|
+
* Omit to keep the SDK default (2). Set `0` to disable retries.
|
|
92
|
+
*
|
|
93
|
+
* @since 0.6.0
|
|
94
|
+
*/
|
|
95
|
+
maxRetries?: number;
|
|
79
96
|
};
|
|
80
97
|
/** @deprecated Use `createOpenAIProvider` function instead */
|
|
81
98
|
export declare class OpenAITranslationProvider implements TranslationProvider {
|
|
@@ -4,8 +4,12 @@ import { isObject } from "../../shared";
|
|
|
4
4
|
openAiClient;
|
|
5
5
|
config;
|
|
6
6
|
constructor(config){
|
|
7
|
+
// `timeout`/`maxRetries` are passed through to the OpenAI SDK; `undefined` keeps the SDK
|
|
8
|
+
// defaults (10 min timeout, 2 retries). A blocking translation job rarely wants the full 10 min.
|
|
7
9
|
this.openAiClient = new OpenAI({
|
|
8
|
-
apiKey: config.apiKey
|
|
10
|
+
apiKey: config.apiKey,
|
|
11
|
+
timeout: config.timeout,
|
|
12
|
+
maxRetries: config.maxRetries
|
|
9
13
|
});
|
|
10
14
|
this.config = config;
|
|
11
15
|
}
|
|
@@ -43,7 +47,9 @@ import { isObject } from "../../shared";
|
|
|
43
47
|
type: "json_object"
|
|
44
48
|
}
|
|
45
49
|
});
|
|
46
|
-
|
|
50
|
+
// Guard `choices[0]`: an empty `choices` array (e.g. content-filtered response) would otherwise
|
|
51
|
+
// throw a TypeError instead of the intended graceful `null`.
|
|
52
|
+
const translatedContent = chatCompletion.choices[0]?.message?.content;
|
|
47
53
|
if (!translatedContent) return null;
|
|
48
54
|
try {
|
|
49
55
|
return JSON.parse(translatedContent);
|
|
@@ -4,10 +4,15 @@ import type { LeafField } from "./types";
|
|
|
4
4
|
* Outcome of navigating a field schema by a path of segment names (see {@link findFieldByPath}).
|
|
5
5
|
*
|
|
6
6
|
* - `leaf` — the path lands on a data-affecting leaf field (carries it).
|
|
7
|
-
* - `container` — the path lands on a `group`/`array`/`blocks` field
|
|
8
|
-
* not a leaf).
|
|
9
|
-
* - `inside-blocks` — the path descends THROUGH a `blocks` field
|
|
10
|
-
*
|
|
7
|
+
* - `container` — the path lands on a `group`/`array`/`blocks` field, a single block element, or a
|
|
8
|
+
* named tab (a container, not a leaf).
|
|
9
|
+
* - `inside-blocks` — the path descends THROUGH a `blocks` field that can't be resolved: no
|
|
10
|
+
* document data was supplied (so the element's `blockType` is unknown), the data is missing at
|
|
11
|
+
* that index, or the `blockType` matches no defined block.
|
|
12
|
+
* - `localized-list-ancestor` — the path descends THROUGH a `localized` `blocks`/`array` field.
|
|
13
|
+
* Such fields are stored independently per locale (different order/content), so an element index
|
|
14
|
+
* is not a stable cross-locale identity; a caller translating *by path across locales* should
|
|
15
|
+
* refuse rather than resolve positionally. (See the "cross-locale block identity" design.)
|
|
11
16
|
* - `not-found` — no field matches a segment, or the path continues past a leaf.
|
|
12
17
|
*
|
|
13
18
|
* @public
|
|
@@ -19,6 +24,8 @@ export type FieldPathResult = {
|
|
|
19
24
|
status: "container";
|
|
20
25
|
} | {
|
|
21
26
|
status: "inside-blocks";
|
|
27
|
+
} | {
|
|
28
|
+
status: "localized-list-ancestor";
|
|
22
29
|
} | {
|
|
23
30
|
status: "not-found";
|
|
24
31
|
};
|
|
@@ -26,15 +33,23 @@ export type FieldPathResult = {
|
|
|
26
33
|
* Navigate a field schema by a path of segment NAMES, descending one matching branch at a time
|
|
27
34
|
* with early-exit (targeted navigation, not an exhaustive walk). Presentational containers
|
|
28
35
|
* (`row`/`collapsible`/unnamed `group`) and unnamed tabs are transparent — searched in the same
|
|
29
|
-
* path scope. Built on {@link classifyField} / {@link tabScopes}
|
|
30
|
-
* in one place.
|
|
36
|
+
* path scope. Built on {@link classifyField} / {@link tabScopes} / {@link resolveBlockFields} so
|
|
37
|
+
* the structural dispatch lives in one place.
|
|
31
38
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
39
|
+
* **Array indices and `blocks`.** Array element configs are shared, so a numeric segment after an
|
|
40
|
+
* array name selects the data item but not the schema (the schema continues into `array.fields`).
|
|
41
|
+
* A `blocks` field is polymorphic — which block (and thus which fields) sits at an index lives in
|
|
42
|
+
* the *data*, not the schema. So to descend through `blocks` the caller must pass the document
|
|
43
|
+
* `data` and keep the element index in the path: this function reads `data[name][index].blockType`
|
|
44
|
+
* and resolves the matching block via {@link resolveBlockFields}. Without `data` (or with a
|
|
45
|
+
* non-indexed path), descending through `blocks` returns `inside-blocks`.
|
|
34
46
|
*
|
|
35
47
|
* @param fields - The schema level to search.
|
|
36
|
-
* @param segments - Remaining path segments
|
|
48
|
+
* @param segments - Remaining path segments to match, head-first. Keep array/block element indices
|
|
49
|
+
* in the path when navigating with `data`.
|
|
50
|
+
* @param data - The document (sub)tree aligned with `fields`, used only to disambiguate `blocks`.
|
|
51
|
+
* Omit for schema-only navigation.
|
|
37
52
|
* @returns A {@link FieldPathResult}.
|
|
38
53
|
* @public
|
|
39
54
|
*/
|
|
40
|
-
export declare function findFieldByPath(fields: Field[], segments: string[]): FieldPathResult;
|
|
55
|
+
export declare function findFieldByPath(fields: Field[], segments: string[], data?: unknown): FieldPathResult;
|