@focus-reactive/payload-plugin-translator 0.8.1 → 0.8.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.
@@ -9,7 +9,7 @@ type Options = {
9
9
  };
10
10
  export declare function useDocumentTranslation({ collection, id }: Props, options?: Options): {
11
11
  invalidate: () => void;
12
- data: NoInfer<DocumentTranslation>;
12
+ data: NoInfer<DocumentTranslation[]>;
13
13
  error: Error;
14
14
  isError: true;
15
15
  isPending: false;
@@ -32,12 +32,12 @@ export declare function useDocumentTranslation({ collection, id }: Props, option
32
32
  isRefetching: boolean;
33
33
  isStale: boolean;
34
34
  isEnabled: boolean;
35
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation>, Error>>;
35
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation[]>, Error>>;
36
36
  fetchStatus: import("@tanstack/react-query").FetchStatus;
37
- promise: Promise<NoInfer<DocumentTranslation>>;
37
+ promise: Promise<NoInfer<DocumentTranslation[]>>;
38
38
  } | {
39
39
  invalidate: () => void;
40
- data: NoInfer<DocumentTranslation>;
40
+ data: NoInfer<DocumentTranslation[]>;
41
41
  error: null;
42
42
  isError: false;
43
43
  isPending: false;
@@ -60,9 +60,9 @@ export declare function useDocumentTranslation({ collection, id }: Props, option
60
60
  isRefetching: boolean;
61
61
  isStale: boolean;
62
62
  isEnabled: boolean;
63
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation>, Error>>;
63
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation[]>, Error>>;
64
64
  fetchStatus: import("@tanstack/react-query").FetchStatus;
65
- promise: Promise<NoInfer<DocumentTranslation>>;
65
+ promise: Promise<NoInfer<DocumentTranslation[]>>;
66
66
  } | {
67
67
  invalidate: () => void;
68
68
  data: undefined;
@@ -88,9 +88,9 @@ export declare function useDocumentTranslation({ collection, id }: Props, option
88
88
  isRefetching: boolean;
89
89
  isStale: boolean;
90
90
  isEnabled: boolean;
91
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation>, Error>>;
91
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation[]>, Error>>;
92
92
  fetchStatus: import("@tanstack/react-query").FetchStatus;
93
- promise: Promise<NoInfer<DocumentTranslation>>;
93
+ promise: Promise<NoInfer<DocumentTranslation[]>>;
94
94
  } | {
95
95
  invalidate: () => void;
96
96
  data: undefined;
@@ -116,9 +116,9 @@ export declare function useDocumentTranslation({ collection, id }: Props, option
116
116
  isRefetching: boolean;
117
117
  isStale: boolean;
118
118
  isEnabled: boolean;
119
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation>, Error>>;
119
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation[]>, Error>>;
120
120
  fetchStatus: import("@tanstack/react-query").FetchStatus;
121
- promise: Promise<NoInfer<DocumentTranslation>>;
121
+ promise: Promise<NoInfer<DocumentTranslation[]>>;
122
122
  } | {
123
123
  invalidate: () => void;
124
124
  data: undefined;
@@ -144,12 +144,12 @@ export declare function useDocumentTranslation({ collection, id }: Props, option
144
144
  isRefetching: boolean;
145
145
  isStale: boolean;
146
146
  isEnabled: boolean;
147
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation>, Error>>;
147
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation[]>, Error>>;
148
148
  fetchStatus: import("@tanstack/react-query").FetchStatus;
149
- promise: Promise<NoInfer<DocumentTranslation>>;
149
+ promise: Promise<NoInfer<DocumentTranslation[]>>;
150
150
  } | {
151
151
  invalidate: () => void;
152
- data: NoInfer<DocumentTranslation>;
152
+ data: NoInfer<DocumentTranslation[]>;
153
153
  isError: false;
154
154
  error: null;
155
155
  isPending: false;
@@ -172,8 +172,8 @@ export declare function useDocumentTranslation({ collection, id }: Props, option
172
172
  isRefetching: boolean;
173
173
  isStale: boolean;
174
174
  isEnabled: boolean;
175
- refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation>, Error>>;
175
+ refetch: (options?: import("@tanstack/react-query").RefetchOptions) => Promise<import("@tanstack/react-query").QueryObserverResult<NoInfer<DocumentTranslation[]>, Error>>;
176
176
  fetchStatus: import("@tanstack/react-query").FetchStatus;
177
- promise: Promise<NoInfer<DocumentTranslation>>;
177
+ promise: Promise<NoInfer<DocumentTranslation[]>>;
178
178
  };
179
179
  export {};
@@ -26,12 +26,16 @@ export function useDocumentTranslation({ collection, id }, options) {
26
26
  method: "get",
27
27
  signal
28
28
  });
29
- return response.data;
29
+ // Normalize to an array defensively: a stale admin tab from before the object→array response
30
+ // change (deploy skew) would otherwise feed a non-array here and break the polling callback.
31
+ return Array.isArray(response.data) ? response.data : [];
30
32
  });
31
33
  },
32
34
  enabled: options?.enabled,
35
+ // Keep polling while any locale's job is still in flight; stop once every job is terminal.
33
36
  refetchInterval: (query)=>{
34
- return query.state.data?.status === "failed" || query.state.data?.status === "completed" ? false : POLLING_INTERVAL_MILLISECONDS;
37
+ const active = (query.state.data ?? []).some((job)=>job.status === "pending" || job.status === "running");
38
+ return active ? POLLING_INTERVAL_MILLISECONDS : false;
35
39
  }
36
40
  });
37
41
  const invalidate = useCallback(()=>{
@@ -49,23 +53,35 @@ export function useDocumentTranslation({ collection, id }, options) {
49
53
  // When a queued (async) translation transitions to "completed" in-session, the provenance
50
54
  // record's sourceFingerprint has just been updated server-side — invalidate staleness so the
51
55
  // "Out of date" notice clears without waiting for the next focus/remount refetch (#50).
52
- // Seeded lazily (undefined) and gated on a *defined, non-completed completed* transition, so a
53
- // cold mount of an already-completed document does NOT fire a spurious extra refetch.
54
- const previousStatusRef = useRef(undefined);
56
+ // Tracked as a *set* of completed target locales (the feed is now per-locale): fire whenever a
57
+ // locale newly reaches "completed". The snapshot is tagged with the document key and reset when
58
+ // the document changes, so a cold mount of an already-completed document — or navigating to a
59
+ // different document within the same component instance — does NOT fire a spurious extra refetch.
60
+ const docKey = `${collection}:${id}`;
61
+ const previousCompletedRef = useRef(undefined);
55
62
  useEffect(()=>{
56
- const previousStatus = previousStatusRef.current;
57
- const currentStatus = query.data?.status;
58
- if (currentStatus === "completed" && previousStatus !== undefined && previousStatus !== "completed") {
63
+ const jobs = query.data;
64
+ if (!jobs) return;
65
+ const completed = new Set(jobs.filter((job)=>job.status === "completed").map((job)=>job.input.target_lng));
66
+ const previous = previousCompletedRef.current;
67
+ const sameDoc = previous?.docKey === docKey;
68
+ if (sameDoc && [
69
+ ...completed
70
+ ].some((locale)=>!previous.locales.has(locale))) {
59
71
  queryClient.invalidateQueries({
60
72
  queryKey: [
61
73
  DOCUMENT_STALENESS_QUERY_KEY
62
74
  ]
63
75
  });
64
76
  }
65
- previousStatusRef.current = currentStatus;
77
+ previousCompletedRef.current = {
78
+ docKey,
79
+ locales: completed
80
+ };
66
81
  }, [
67
- query.data?.status,
68
- queryClient
82
+ query.data,
83
+ queryClient,
84
+ docKey
69
85
  ]);
70
86
  return {
71
87
  ...query,
@@ -19,7 +19,7 @@ export declare const TranslationsApi: {
19
19
  };
20
20
  export type { DocumentTranslation, DocumentTranslationCompleted, DocumentTranslationFailed, DocumentTranslationPending, DocumentTranslationRunning, CollectionTranslationStatus, CollectionTranslationStatusItem, GroupedCollectionTranslationStatus, DocumentStaleness, StalenessLocale, } from "./model/types";
21
21
  export { DocumentTranslationStatus } from "./model/enums";
22
- export { derivePanelStatus, deriveCollectionPanelStatus, describePanelStatus, } from "./model/panelStatus";
22
+ export { derivePanelStatus, deriveCollectionPanelStatus, deriveDocumentRunStatus, describePanelStatus, } from "./model/panelStatus";
23
23
  export type { PanelStatus, MarkerTone } from "./model/panelStatus";
24
24
  export { PanelStatusMarker } from "./ui/PanelStatusMarker";
25
25
  export { ActionButton } from "./ui/ActionButton";
@@ -18,7 +18,7 @@ export const TranslationsApi = {
18
18
  useDismissStaleness
19
19
  };
20
20
  export { DocumentTranslationStatus } from "./model/enums";
21
- export { derivePanelStatus, deriveCollectionPanelStatus, describePanelStatus } from "./model/panelStatus";
21
+ export { derivePanelStatus, deriveCollectionPanelStatus, deriveDocumentRunStatus, describePanelStatus } from "./model/panelStatus";
22
22
  export { PanelStatusMarker } from "./ui/PanelStatusMarker";
23
23
  export { ActionButton } from "./ui/ActionButton";
24
24
  export { buildTranslationStatusRows } from "./model/statusRows";
@@ -1,6 +1,6 @@
1
1
  import { DocumentTranslationStatus } from "./enums";
2
2
  import type { StatusDotColor } from "./statusRows";
3
- import type { GroupedCollectionTranslationStatus } from "./types";
3
+ import type { DocumentTranslation, GroupedCollectionTranslationStatus } from "./types";
4
4
  /**
5
5
  * The single aggregate signal shown on a translate trigger (document or collection). Detail lives in
6
6
  * the popup; the panel only surfaces the most urgent state at a glance (see the panel-UI design doc).
@@ -37,8 +37,14 @@ export declare function describePanelStatus(status: PanelStatus | undefined, fal
37
37
  tone?: MarkerTone;
38
38
  title: string;
39
39
  };
40
+ /**
41
+ * Collapse the per-locale job feed into the single most-urgent run status for the trigger marker.
42
+ * Returns `undefined` when there are no jobs. The panel shows one aggregate signal; the popup's list
43
+ * carries the full per-locale detail.
44
+ */
45
+ export declare function deriveDocumentRunStatus(runs: DocumentTranslation[] | undefined): DocumentTranslationStatus | undefined;
40
46
  type PanelStatusInput = {
41
- /** Status of the latest translation job, if any. */
47
+ /** Status of the most-urgent translation job across locales, if any (see `deriveDocumentRunStatus`). */
42
48
  runStatus?: DocumentTranslationStatus | null;
43
49
  /** Target locales whose translation is out of date (from staleness detection). */
44
50
  staleLocales: string[];
@@ -50,6 +50,22 @@ import { DocumentTranslationStatus } from "./enums";
50
50
  };
51
51
  }
52
52
  }
53
+ // The most-urgent job status wins the single aggregate marker, in the order `derivePanelStatus`
54
+ // itself ranks them: a failure to surface, then in-flight work, then a queued job, then completed.
55
+ const RUN_STATUS_PRIORITY = [
56
+ DocumentTranslationStatus.FAILED,
57
+ DocumentTranslationStatus.RUNNING,
58
+ DocumentTranslationStatus.PENDING,
59
+ DocumentTranslationStatus.COMPLETED
60
+ ];
61
+ /**
62
+ * Collapse the per-locale job feed into the single most-urgent run status for the trigger marker.
63
+ * Returns `undefined` when there are no jobs. The panel shows one aggregate signal; the popup's list
64
+ * carries the full per-locale detail.
65
+ */ export function deriveDocumentRunStatus(runs) {
66
+ const present = new Set(runs?.map((run)=>run.status));
67
+ return RUN_STATUS_PRIORITY.find((status)=>present.has(status));
68
+ }
53
69
  /**
54
70
  * Collapse the latest run status + per-locale staleness into one panel marker, by priority:
55
71
  * `failed → running → stale → fresh → none`. A more urgent transient state (a failed or in-flight
@@ -29,14 +29,17 @@ export declare const STATE_DOT: Record<TranslationRowState, {
29
29
  animated?: boolean;
30
30
  }>;
31
31
  /**
32
- * Build the unified status rows from per-locale staleness + the single latest job.
32
+ * Build the unified status rows from per-locale staleness + the latest job per target locale.
33
33
  *
34
- * Rows come from `staleness.locales` (one per translated target: `stale` or `translated`). The latest
35
- * job is overlaid onto its target locale: a **transient** job state (failed/running/pending) wins over
36
- * the durable signal for that locale, and a job for a target with no provenance row yet adds a row.
37
- * Sorted `failed running pending stale translated`, newest first within a group.
34
+ * Rows come from `staleness.locales` (one per translated target: `stale` or `translated`). Each job in
35
+ * `runs` (one per target locale — see `useDocumentTranslation`) is overlaid onto its target locale: a
36
+ * **transient** job state (failed/running/pending) wins over the durable signal for that locale, and a
37
+ * job for a target with no provenance row yet adds a row. Overlaying *every* job (not just one) is what
38
+ * lets several concurrent re-translations each show their own live state instead of the last one
39
+ * appearing to overwrite the rest. Sorted by the fixed source→target locale pair (not by state), so a
40
+ * row keeps its place when its state changes — re-translating a locale never reorders the list.
38
41
  */
39
42
  export declare function buildTranslationStatusRows(input: {
40
43
  staleness?: DocumentStaleness | null;
41
- run?: DocumentTranslation;
44
+ runs?: DocumentTranslation[];
42
45
  }): TranslationStatusRow[];
@@ -20,32 +20,30 @@
20
20
  color: "green"
21
21
  }
22
22
  };
23
- // Most urgent first. Transient job states outrank the durable stale/translated signal.
24
- const ORDER = {
25
- failed: 0,
26
- running: 1,
27
- pending: 2,
28
- stale: 3,
29
- translated: 4
30
- };
31
23
  const TRANSIENT = new Set([
32
24
  "failed",
33
25
  "running",
34
26
  "pending"
35
27
  ]);
28
+ // A row's permanent identity for ordering: the source→target locale pair (unique per row, never
29
+ // changes when the row's state does).
30
+ const localePairKey = (row)=>`${row.sourceLocale}:${row.targetLocale}`;
36
31
  // Maps the closed 4-member job-status set to a row state. "completed" → "translated"; the transient
37
32
  // three pass through. If a new DocumentTranslationStatus is ever added, extend this explicitly —
38
33
  // otherwise it would silently render as "translated".
39
34
  const toRowState = (jobStatus)=>jobStatus === "failed" || jobStatus === "running" || jobStatus === "pending" ? jobStatus : "translated";
40
35
  /**
41
- * Build the unified status rows from per-locale staleness + the single latest job.
36
+ * Build the unified status rows from per-locale staleness + the latest job per target locale.
42
37
  *
43
- * Rows come from `staleness.locales` (one per translated target: `stale` or `translated`). The latest
44
- * job is overlaid onto its target locale: a **transient** job state (failed/running/pending) wins over
45
- * the durable signal for that locale, and a job for a target with no provenance row yet adds a row.
46
- * Sorted `failed running pending stale translated`, newest first within a group.
38
+ * Rows come from `staleness.locales` (one per translated target: `stale` or `translated`). Each job in
39
+ * `runs` (one per target locale — see `useDocumentTranslation`) is overlaid onto its target locale: a
40
+ * **transient** job state (failed/running/pending) wins over the durable signal for that locale, and a
41
+ * job for a target with no provenance row yet adds a row. Overlaying *every* job (not just one) is what
42
+ * lets several concurrent re-translations each show their own live state instead of the last one
43
+ * appearing to overwrite the rest. Sorted by the fixed source→target locale pair (not by state), so a
44
+ * row keeps its place when its state changes — re-translating a locale never reorders the list.
47
45
  */ export function buildTranslationStatusRows(input) {
48
- const { staleness, run } = input;
46
+ const { staleness, runs } = input;
49
47
  const byTarget = new Map();
50
48
  for (const locale of staleness?.locales ?? []){
51
49
  byTarget.set(locale.target_lng, {
@@ -55,7 +53,7 @@ const toRowState = (jobStatus)=>jobStatus === "failed" || jobStatus === "running
55
53
  at: locale.translated_at
56
54
  });
57
55
  }
58
- if (run) {
56
+ for (const run of runs ?? []){
59
57
  const state = toRowState(run.status);
60
58
  const target = run.input.target_lng;
61
59
  const existing = byTarget.get(target);
@@ -71,9 +69,13 @@ const toRowState = (jobStatus)=>jobStatus === "failed" || jobStatus === "running
71
69
  });
72
70
  }
73
71
  }
72
+ // Stable order by the source→target locale pair — the one thing about a row that never changes when
73
+ // its state does. Sorting by state/time instead would make a row jump (e.g. to the top) the moment
74
+ // you re-translate it; the locale pair keeps every row in a fixed place, and progress is conveyed by
75
+ // the badge/dot, not the position. The pair is unique (one row per target), so no tie-break needed.
74
76
  return [
75
77
  ...byTarget.values()
76
- ].sort((a, b)=>ORDER[a.state] - ORDER[b.state] || (b.at ?? "").localeCompare(a.at ?? ""));
78
+ ].sort((a, b)=>localePairKey(a).localeCompare(localePairKey(b)));
77
79
  }
78
80
 
79
81
  //# sourceMappingURL=statusRows.js.map
@@ -35,7 +35,12 @@ export type DocumentTranslationCompleted = {
35
35
  completed_at: string;
36
36
  input: InputData;
37
37
  };
38
- export type DocumentTranslation = DocumentTranslationCompleted | DocumentTranslationRunning | DocumentTranslationFailed | DocumentTranslationPending | null;
38
+ /**
39
+ * One translation job for a single target locale. The document status feed is an array of these —
40
+ * the latest job per target locale (see `useDocumentTranslation`), because re-translate queues an
41
+ * independent job per locale.
42
+ */
43
+ export type DocumentTranslation = DocumentTranslationCompleted | DocumentTranslationRunning | DocumentTranslationFailed | DocumentTranslationPending;
39
44
  export type CollectionTranslationStatusItem = {
40
45
  id: string;
41
46
  status: DocumentTranslationStatus;
@@ -2,7 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { toast, useLocale } from "@payloadcms/ui";
4
4
  import { useEffect, useMemo } from "react";
5
- import { buildTranslationStatusRows, derivePanelStatus, TranslationsApi, TranslationStatusList } from "../../../entities/translation";
5
+ import { buildTranslationStatusRows, deriveDocumentRunStatus, derivePanelStatus, TranslationsApi, TranslationStatusList } from "../../../entities/translation";
6
6
  import { OpenDocumentTranslationPopup } from "../../../features/open-document-translation-popup";
7
7
  import { DocumentTranslationForm, FORM_FIELDS } from "../../../features/translate-document-form";
8
8
  import { DocumentTranslationFormModel } from "../../../features/translate-document-form/index.client";
@@ -27,15 +27,15 @@ const TranslateDocument = ({ hasDrafts })=>{
27
27
  ]);
28
28
  // The panel trigger shows one aggregate marker; the popup shows the full per-locale list.
29
29
  const panelStatus = useMemo(()=>derivePanelStatus({
30
- runStatus: data?.status,
30
+ runStatus: deriveDocumentRunStatus(data),
31
31
  staleLocales
32
32
  }), [
33
- data?.status,
33
+ data,
34
34
  staleLocales
35
35
  ]);
36
36
  const statusRows = useMemo(()=>buildTranslationStatusRows({
37
37
  staleness,
38
- run: data
38
+ runs: data
39
39
  }), [
40
40
  staleness,
41
41
  data
@@ -1,6 +1,6 @@
1
1
  import { ServerResponse } from "../../shared";
2
2
  import { isCollectionAvailable } from "../_lib/collection-utils";
3
- import { GetDocumentStatusInputSchema, taskToJobStatusOutput } from "./model";
3
+ import { GetDocumentStatusInputSchema, latestTaskPerTargetLocale, taskToJobStatusOutput } from "./model";
4
4
  /**
5
5
  * Gets the translation status for a specific document
6
6
  */ export class GetDocumentStatusHandler {
@@ -20,7 +20,9 @@ import { GetDocumentStatusInputSchema, taskToJobStatusOutput } from "./model";
20
20
  const tasks = await runner.findByCollection(collectionSlug, [
21
21
  collection_id
22
22
  ]);
23
- return ServerResponse.success(tasks[0] ? taskToJobStatusOutput(tasks[0]) : null);
23
+ // One job per target locale (latest), not a single job for the whole document — re-translate
24
+ // queues an independent job per locale, and the status panel renders a row per locale.
25
+ return ServerResponse.success(latestTaskPerTargetLocale(tasks).map(taskToJobStatusOutput));
24
26
  }
25
27
  }
26
28
 
@@ -56,6 +56,16 @@ export type JobStatusOutput = {
56
56
  export type GetDocumentStatusConfig = {
57
57
  availableCollections: Set<CollectionSlug>;
58
58
  };
59
+ /**
60
+ * Reduce a document's jobs to the latest one per target locale.
61
+ *
62
+ * `findByCollection` returns every job for the document across all target locales (plus any superseded
63
+ * jobs not yet cancelled). The status panel shows one row per target locale, so we keep — per
64
+ * `targetLng` — the most recently created job (tie-break: most recently updated). Without this the
65
+ * caller sees a single arbitrary job and every other in-flight locale looks idle, which is the
66
+ * concurrent re-translate bug (a second re-translate appearing to overwrite the first's status).
67
+ */
68
+ export declare function latestTaskPerTargetLocale(tasks: Task[]): Task[];
59
69
  /**
60
70
  * Transforms a Task to API output format (snake_case for client compatibility)
61
71
  */
@@ -10,6 +10,27 @@ import { JobIdSchema, toClientErrorMessage } from "../../shared";
10
10
  collection_id: JobIdSchema,
11
11
  collection_slug: z.string().nonempty()
12
12
  });
13
+ // A job is "newer" by creation time, tie-broken by last update — both ISO-8601, so a lexicographic
14
+ // string compare is a correct chronological compare (no Date parsing needed).
15
+ const isNewerTask = (candidate, current)=>candidate.createdAt !== current.createdAt ? candidate.createdAt > current.createdAt : candidate.updatedAt > current.updatedAt;
16
+ /**
17
+ * Reduce a document's jobs to the latest one per target locale.
18
+ *
19
+ * `findByCollection` returns every job for the document across all target locales (plus any superseded
20
+ * jobs not yet cancelled). The status panel shows one row per target locale, so we keep — per
21
+ * `targetLng` — the most recently created job (tie-break: most recently updated). Without this the
22
+ * caller sees a single arbitrary job and every other in-flight locale looks idle, which is the
23
+ * concurrent re-translate bug (a second re-translate appearing to overwrite the first's status).
24
+ */ export function latestTaskPerTargetLocale(tasks) {
25
+ const byLocale = new Map();
26
+ for (const task of tasks){
27
+ const current = byLocale.get(task.input.targetLng);
28
+ if (!current || isNewerTask(task, current)) byLocale.set(task.input.targetLng, task);
29
+ }
30
+ return [
31
+ ...byLocale.values()
32
+ ];
33
+ }
13
34
  /**
14
35
  * Transforms a Task to API output format (snake_case for client compatibility)
15
36
  */ export function taskToJobStatusOutput(task) {
@@ -1,4 +1,8 @@
1
1
  import { normalizeJob } from "./normalizeJob";
2
+ // A translation job's supersession identity: same document AND same target locale. IDs are
3
+ // String()-normalized to match the stored (string) form, so a number id compares equal to its
4
+ // persisted job.
5
+ const documentLocaleKey = (collectionId, targetLng)=>`${String(collectionId)}:${targetLng}`;
2
6
  /**
3
7
  * TaskRunner implementation using Payload Jobs.
4
8
  *
@@ -15,8 +19,13 @@ import { normalizeJob } from "./normalizeJob";
15
19
  for (const [collectionSlug, items] of byCollection){
16
20
  const documentIds = items.map((t)=>t.collectionId);
17
21
  const existing = await this.findByCollection(collectionSlug, documentIds);
18
- if (existing.length > 0) {
19
- await this.cancelInternal(existing.map((t)=>t.id));
22
+ // Supersede only jobs for the SAME (document, target locale) being re-enqueued — never a
23
+ // concurrent job for a *different* locale of the same document. Cancelling per-document would
24
+ // kill an in-flight translation of another locale (the concurrent re-translate bug).
25
+ const supersededKeys = new Set(items.map((t)=>documentLocaleKey(t.collectionId, t.targetLng)));
26
+ const toCancel = existing.filter((t)=>supersededKeys.has(documentLocaleKey(t.input.collectionId, t.input.targetLng)));
27
+ if (toCancel.length > 0) {
28
+ await this.cancelInternal(toCancel.map((t)=>t.id));
20
29
  }
21
30
  }
22
31
  await Promise.all(tasks.map((task)=>this.payload.jobs.queue({
@@ -14,7 +14,7 @@
14
14
  }
15
15
  async enqueue(inputs) {
16
16
  for (const input of inputs){
17
- const key = this.getKey(input.collectionSlug, input.collectionId);
17
+ const key = this.getKey(input.collectionSlug, input.collectionId, input.targetLng);
18
18
  const now = new Date().toISOString();
19
19
  const task = {
20
20
  id: crypto.randomUUID(),
@@ -65,8 +65,10 @@
65
65
  }
66
66
  return results;
67
67
  }
68
- getKey(collectionSlug, collectionId) {
69
- return `${collectionSlug}:${collectionId}`;
68
+ // Keyed by (document, target locale) so translating a second locale of the same document does not
69
+ // evict the first — findByCollection must be able to return one task per locale.
70
+ getKey(collectionSlug, collectionId, targetLng) {
71
+ return `${collectionSlug}:${collectionId}:${targetLng}`;
70
72
  }
71
73
  }
72
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@focus-reactive/payload-plugin-translator",
3
- "version": "0.8.1",
3
+ "version": "0.8.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",