@parhelia/localization 0.1.10745
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +29 -0
- package/dist/core/src/editor/ui/DragPreview.d.ts +15 -0
- package/dist/core/src/editor/ui/DragPreview.d.ts.map +1 -0
- package/dist/core/src/editor/ui/DragPreview.js +32 -0
- package/dist/core/src/editor/ui/PerfectTree.d.ts +79 -0
- package/dist/core/src/editor/ui/PerfectTree.d.ts.map +1 -0
- package/dist/core/src/editor/ui/PerfectTree.js +857 -0
- package/dist/localization/src/LocalizeItemCommand.d.ts +8 -0
- package/dist/localization/src/LocalizeItemCommand.d.ts.map +1 -0
- package/dist/localization/src/LocalizeItemCommand.js +44 -0
- package/dist/localization/src/LocalizeItemDialog.d.ts +4 -0
- package/dist/localization/src/LocalizeItemDialog.d.ts.map +1 -0
- package/dist/localization/src/LocalizeItemDialog.js +126 -0
- package/dist/localization/src/LocalizeItemUtils.d.ts +17 -0
- package/dist/localization/src/LocalizeItemUtils.d.ts.map +1 -0
- package/dist/localization/src/LocalizeItemUtils.js +93 -0
- package/dist/localization/src/api/discovery.d.ts +36 -0
- package/dist/localization/src/api/discovery.d.ts.map +1 -0
- package/dist/localization/src/api/discovery.js +29 -0
- package/dist/localization/src/constants.d.ts +15 -0
- package/dist/localization/src/constants.d.ts.map +1 -0
- package/dist/localization/src/constants.js +21 -0
- package/dist/localization/src/hooks/useTranslationWizard.d.ts +6 -0
- package/dist/localization/src/hooks/useTranslationWizard.d.ts.map +1 -0
- package/dist/localization/src/hooks/useTranslationWizard.js +78 -0
- package/dist/localization/src/index.d.ts +69 -0
- package/dist/localization/src/index.d.ts.map +1 -0
- package/dist/localization/src/index.js +152 -0
- package/dist/localization/src/services/translationService.d.ts +102 -0
- package/dist/localization/src/services/translationService.d.ts.map +1 -0
- package/dist/localization/src/services/translationService.js +37 -0
- package/dist/localization/src/setup/LocalizationSetupStep.d.ts +3 -0
- package/dist/localization/src/setup/LocalizationSetupStep.d.ts.map +1 -0
- package/dist/localization/src/setup/LocalizationSetupStep.js +108 -0
- package/dist/localization/src/sidebar/TranslationSidebar.d.ts +2 -0
- package/dist/localization/src/sidebar/TranslationSidebar.d.ts.map +1 -0
- package/dist/localization/src/sidebar/TranslationSidebar.js +93 -0
- package/dist/localization/src/steps/MetadataInputStep.d.ts +4 -0
- package/dist/localization/src/steps/MetadataInputStep.d.ts.map +1 -0
- package/dist/localization/src/steps/MetadataInputStep.js +38 -0
- package/dist/localization/src/steps/ServiceLanguageSelectionStep.d.ts +3 -0
- package/dist/localization/src/steps/ServiceLanguageSelectionStep.d.ts.map +1 -0
- package/dist/localization/src/steps/ServiceLanguageSelectionStep.js +91 -0
- package/dist/localization/src/steps/SubitemDiscoveryStep.d.ts +3 -0
- package/dist/localization/src/steps/SubitemDiscoveryStep.d.ts.map +1 -0
- package/dist/localization/src/steps/SubitemDiscoveryStep.js +391 -0
- package/dist/localization/src/steps/index.d.ts +5 -0
- package/dist/localization/src/steps/index.d.ts.map +1 -0
- package/dist/localization/src/steps/index.js +4 -0
- package/dist/localization/src/steps/types.d.ts +68 -0
- package/dist/localization/src/steps/types.d.ts.map +1 -0
- package/dist/localization/src/steps/types.js +1 -0
- package/dist/localization/src/translation-center/BatchTranslationView.d.ts +7 -0
- package/dist/localization/src/translation-center/BatchTranslationView.d.ts.map +1 -0
- package/dist/localization/src/translation-center/BatchTranslationView.js +487 -0
- package/dist/localization/src/translation-center/RecentTranslations.d.ts +2 -0
- package/dist/localization/src/translation-center/RecentTranslations.d.ts.map +1 -0
- package/dist/localization/src/translation-center/RecentTranslations.js +199 -0
- package/dist/localization/src/translation-center/TranslationManagement.d.ts +2 -0
- package/dist/localization/src/translation-center/TranslationManagement.d.ts.map +1 -0
- package/dist/localization/src/translation-center/TranslationManagement.js +25 -0
- package/dist/localization/src/types.d.ts +18 -0
- package/dist/localization/src/types.d.ts.map +1 -0
- package/dist/localization/src/types.js +1 -0
- package/dist/localization/src/utils/createVersions.d.ts +14 -0
- package/dist/localization/src/utils/createVersions.d.ts.map +1 -0
- package/dist/localization/src/utils/createVersions.js +26 -0
- package/package.json +47 -0
- package/styles.css +1 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState, useMemo, useRef, useCallback } from "react";
|
|
3
|
+
import { useDebouncedCallback } from "use-debounce";
|
|
4
|
+
import { useEditContext, SimpleTable, Progress, Button } from "@parhelia/core";
|
|
5
|
+
import { listBatchTranslationJobs, subscribeToBatch, unsubscribeFromBatch, getBatchInfo, getTranslationProviders } from "../services/translationService";
|
|
6
|
+
// Normalize API shape/casing to what the UI expects
|
|
7
|
+
function normalizeBatchInfo(raw) {
|
|
8
|
+
if (!raw)
|
|
9
|
+
return null;
|
|
10
|
+
const pick = (a, b) => (a !== undefined ? a : b);
|
|
11
|
+
const toNum = (v) => (typeof v === "number" ? v : v != null ? parseInt(v, 10) : undefined);
|
|
12
|
+
const info = {
|
|
13
|
+
batchId: pick(raw?.batchId, raw?.BatchId),
|
|
14
|
+
createdAtUtc: pick(raw?.createdAtUtc, raw?.CreatedAtUtc),
|
|
15
|
+
startedAtUtc: pick(raw?.startedAtUtc, raw?.StartedAtUtc),
|
|
16
|
+
completedAtUtc: pick(raw?.completedAtUtc, raw?.CompletedAtUtc),
|
|
17
|
+
initiatedByUser: pick(raw?.initiatedByUser, raw?.InitiatedByUser),
|
|
18
|
+
initiatorSessionId: pick(raw?.initiatorSessionId, raw?.InitiatorSessionId),
|
|
19
|
+
provider: pick(raw?.provider, raw?.Provider),
|
|
20
|
+
status: pick(raw?.status, raw?.Status),
|
|
21
|
+
expectedJobs: toNum(pick(raw?.expectedJobs, raw?.ExpectedJobs)),
|
|
22
|
+
completedJobs: toNum(pick(raw?.completedJobs, raw?.CompletedJobs)),
|
|
23
|
+
errorJobs: toNum(pick(raw?.errorJobs, raw?.ErrorJobs)),
|
|
24
|
+
includeSubitems: pick(raw?.includeSubitems, raw?.IncludeSubitems) ?? null,
|
|
25
|
+
scopeItemId: pick(raw?.scopeItemId, raw?.ScopeItemId),
|
|
26
|
+
scopeItemPath: pick(raw?.scopeItemPath, raw?.ScopeItemPath),
|
|
27
|
+
metadata: pick(raw?.metadata, raw?.Metadata),
|
|
28
|
+
lastUpdatedUtc: pick(raw?.lastUpdatedUtc, raw?.LastUpdatedUtc),
|
|
29
|
+
exists: pick(raw?.exists, raw?.Exists),
|
|
30
|
+
};
|
|
31
|
+
return info.batchId ? info : null;
|
|
32
|
+
}
|
|
33
|
+
export function BatchTranslationView({ batchId, onBack }) {
|
|
34
|
+
const editContext = useEditContext();
|
|
35
|
+
const [batchJobs, setBatchJobs] = useState([]);
|
|
36
|
+
const [batchInfo, setBatchInfo] = useState(null);
|
|
37
|
+
const [providers, setProviders] = useState([]);
|
|
38
|
+
const [itemNameById, setItemNameById] = useState({});
|
|
39
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
40
|
+
const [translationProgress, setTranslationProgress] = useState(new Map());
|
|
41
|
+
const firstProgressSeenRef = useRef(false);
|
|
42
|
+
const lastWsAtRef = useRef(0);
|
|
43
|
+
const batchJobsRef = useRef([]);
|
|
44
|
+
const hasLoadedRef = useRef(false);
|
|
45
|
+
const isSubscribedRef = useRef(false);
|
|
46
|
+
const initGuardRef = useRef(false);
|
|
47
|
+
const lastBatchIdRef = useRef(null);
|
|
48
|
+
// Helper function to get display name from service name
|
|
49
|
+
const getProviderDisplayName = useCallback((serviceName) => {
|
|
50
|
+
if (!serviceName)
|
|
51
|
+
return 'Unknown';
|
|
52
|
+
const provider = providers.find(p => p.name === serviceName);
|
|
53
|
+
return provider?.displayName || serviceName;
|
|
54
|
+
}, [providers]);
|
|
55
|
+
// Keep ref in sync with state
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
batchJobsRef.current = batchJobs;
|
|
58
|
+
}, [batchJobs]);
|
|
59
|
+
const loadProviders = useCallback(async () => {
|
|
60
|
+
try {
|
|
61
|
+
const res = await getTranslationProviders();
|
|
62
|
+
const providerData = (res?.data ?? res ?? []);
|
|
63
|
+
setProviders(providerData);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
console.error("Failed to load translation providers:", error);
|
|
67
|
+
}
|
|
68
|
+
}, []);
|
|
69
|
+
const loadBatchJobs = useCallback(async () => {
|
|
70
|
+
setIsLoading(true);
|
|
71
|
+
try {
|
|
72
|
+
// Use efficient batch-specific endpoint
|
|
73
|
+
const res = await listBatchTranslationJobs(batchId);
|
|
74
|
+
const jobs = (res?.data ?? res ?? []);
|
|
75
|
+
setBatchJobs(jobs);
|
|
76
|
+
// Resolve item names only for new items we haven't seen before
|
|
77
|
+
const ids = Array.from(new Set(jobs.map((j) => j.itemId)));
|
|
78
|
+
const newIds = ids.filter((id) => !itemNameById[id]);
|
|
79
|
+
if (newIds.length && editContext?.itemsRepository) {
|
|
80
|
+
const descriptors = newIds.map((id) => ({ id, language: "en", version: 0 }));
|
|
81
|
+
try {
|
|
82
|
+
const stubs = await editContext.itemsRepository.getItemsStubs(descriptors);
|
|
83
|
+
const map = {};
|
|
84
|
+
stubs?.forEach((s) => {
|
|
85
|
+
if (s)
|
|
86
|
+
map[s.id] = s.name || s.id;
|
|
87
|
+
});
|
|
88
|
+
setItemNameById((prev) => ({ ...prev, ...map }));
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
console.error("Failed to load item names:", error);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
console.error("Failed to load batch jobs:", error);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
setIsLoading(false);
|
|
100
|
+
}
|
|
101
|
+
}, [batchId, editContext, itemNameById]);
|
|
102
|
+
const debouncedRefresh = useDebouncedCallback(() => {
|
|
103
|
+
void loadBatchJobs();
|
|
104
|
+
}, 800);
|
|
105
|
+
// Load initial data and conditionally subscribe to batch updates
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
// Reset guards if batchId changed
|
|
108
|
+
if (lastBatchIdRef.current !== batchId) {
|
|
109
|
+
lastBatchIdRef.current = batchId;
|
|
110
|
+
hasLoadedRef.current = false;
|
|
111
|
+
isSubscribedRef.current = false;
|
|
112
|
+
initGuardRef.current = false;
|
|
113
|
+
}
|
|
114
|
+
let cancelled = false;
|
|
115
|
+
const init = async () => {
|
|
116
|
+
if (initGuardRef.current)
|
|
117
|
+
return; // prevent duplicate init under StrictMode
|
|
118
|
+
initGuardRef.current = true;
|
|
119
|
+
try {
|
|
120
|
+
// Always load batch info first to decide on subscriptions
|
|
121
|
+
try {
|
|
122
|
+
const resInfo = await getBatchInfo(batchId);
|
|
123
|
+
const raw = resInfo?.data ?? resInfo;
|
|
124
|
+
const info = normalizeBatchInfo(raw);
|
|
125
|
+
if (!cancelled && info)
|
|
126
|
+
setBatchInfo(info);
|
|
127
|
+
const isTerminal = info?.status === 'Completed' || info?.status === 'Error';
|
|
128
|
+
// Conditionally subscribe only for non-terminal batches
|
|
129
|
+
if (!isTerminal && editContext?.sessionId) {
|
|
130
|
+
await subscribeToBatch(batchId, editContext.sessionId);
|
|
131
|
+
isSubscribedRef.current = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch { }
|
|
135
|
+
// Load providers for display name mapping
|
|
136
|
+
await loadProviders();
|
|
137
|
+
// Load jobs once per mount/batch change
|
|
138
|
+
if (!hasLoadedRef.current) {
|
|
139
|
+
await loadBatchJobs();
|
|
140
|
+
hasLoadedRef.current = true;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
// no-op
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
init();
|
|
148
|
+
return () => {
|
|
149
|
+
// Cleanup: unsubscribe when component unmounts or batch changes
|
|
150
|
+
if (isSubscribedRef.current && editContext && editContext.sessionId && batchId) {
|
|
151
|
+
unsubscribeFromBatch(batchId, editContext.sessionId).catch(console.error);
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
}, [batchId, editContext?.sessionId]);
|
|
155
|
+
// Listen for translation events - only for this batch
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const removeSocketMessageListener = editContext?.addSocketMessageListener((message) => {
|
|
158
|
+
// Mark last websocket activity time for adaptive polling
|
|
159
|
+
if (message.type === "translation-started" ||
|
|
160
|
+
message.type === "translation-finished" ||
|
|
161
|
+
message.type === "translation-error" ||
|
|
162
|
+
message.type === "translation-progress" ||
|
|
163
|
+
message.type === "batch-completed") {
|
|
164
|
+
lastWsAtRef.current = Date.now();
|
|
165
|
+
}
|
|
166
|
+
if (message.type === "translation-started" ||
|
|
167
|
+
message.type === "translation-finished" ||
|
|
168
|
+
message.type === "translation-error") {
|
|
169
|
+
// Only update if this message is for our batch
|
|
170
|
+
if (message.payload.batchId === batchId) {
|
|
171
|
+
// Update the specific job in our local state instead of refetching all jobs
|
|
172
|
+
setBatchJobs(prevJobs => {
|
|
173
|
+
const jobIndex = prevJobs.findIndex(job => job.itemId === message.payload.itemId &&
|
|
174
|
+
job.targetLanguage === message.payload.language);
|
|
175
|
+
if (jobIndex >= 0) {
|
|
176
|
+
// Update existing job
|
|
177
|
+
const updatedJobs = [...prevJobs];
|
|
178
|
+
const existingJob = updatedJobs[jobIndex];
|
|
179
|
+
if (existingJob) {
|
|
180
|
+
updatedJobs[jobIndex] = {
|
|
181
|
+
...existingJob,
|
|
182
|
+
status: message.payload.status,
|
|
183
|
+
timestamp: message.payload.timestamp || existingJob.timestamp,
|
|
184
|
+
message: message.payload.message || existingJob.message
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return updatedJobs;
|
|
188
|
+
}
|
|
189
|
+
else if (message.type === "translation-started") {
|
|
190
|
+
// Add new job if it doesn't exist (shouldn't happen with batch subscriptions, but safe fallback)
|
|
191
|
+
const newJob = {
|
|
192
|
+
itemId: message.payload.itemId,
|
|
193
|
+
targetLanguage: message.payload.language,
|
|
194
|
+
sourceLanguage: message.payload.sourceLanguage || "",
|
|
195
|
+
status: message.payload.status,
|
|
196
|
+
timestamp: message.payload.timestamp || new Date().toISOString(),
|
|
197
|
+
batchId: batchId,
|
|
198
|
+
message: message.payload.message || ""
|
|
199
|
+
};
|
|
200
|
+
// Resolve item name for new job if we don't have it
|
|
201
|
+
if (!itemNameById[message.payload.itemId]) {
|
|
202
|
+
void resolveItemNames([message.payload.itemId]);
|
|
203
|
+
}
|
|
204
|
+
return [...prevJobs, newJob];
|
|
205
|
+
}
|
|
206
|
+
return prevJobs;
|
|
207
|
+
});
|
|
208
|
+
// Kick reconciliatory refresh the first time we see start/progress
|
|
209
|
+
if (!firstProgressSeenRef.current) {
|
|
210
|
+
firstProgressSeenRef.current = true;
|
|
211
|
+
debouncedRefresh();
|
|
212
|
+
setTimeout(() => debouncedRefresh(), 1500);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (message.type === "translation-progress") {
|
|
217
|
+
// Only track progress for jobs in this batch
|
|
218
|
+
if (message.payload.batchId === batchId) {
|
|
219
|
+
const itemId = (message.payload.itemId ?? "").toString().toLowerCase();
|
|
220
|
+
const language = message.payload.language;
|
|
221
|
+
const progressKey = `${itemId}-${language}`;
|
|
222
|
+
// Ensure a row exists; if not, add a placeholder "In Progress" job
|
|
223
|
+
setBatchJobs(prevJobs => {
|
|
224
|
+
const idx = prevJobs.findIndex(j => j.itemId === itemId && j.targetLanguage === language);
|
|
225
|
+
if (idx >= 0)
|
|
226
|
+
return prevJobs;
|
|
227
|
+
const newJob = {
|
|
228
|
+
itemId,
|
|
229
|
+
targetLanguage: language,
|
|
230
|
+
sourceLanguage: message.payload.sourceLanguage || "",
|
|
231
|
+
status: "In Progress",
|
|
232
|
+
timestamp: new Date().toISOString(),
|
|
233
|
+
batchId: batchId,
|
|
234
|
+
message: message.payload.message || ""
|
|
235
|
+
};
|
|
236
|
+
return [...prevJobs, newJob];
|
|
237
|
+
});
|
|
238
|
+
setTranslationProgress((prev) => {
|
|
239
|
+
const next = new Map(prev);
|
|
240
|
+
next.set(progressKey, {
|
|
241
|
+
progress: message.payload.progress || 0,
|
|
242
|
+
message: message.payload.message || ""
|
|
243
|
+
});
|
|
244
|
+
return next;
|
|
245
|
+
});
|
|
246
|
+
// Kick reconciliatory refresh the first time we see progress
|
|
247
|
+
if (!firstProgressSeenRef.current) {
|
|
248
|
+
firstProgressSeenRef.current = true;
|
|
249
|
+
debouncedRefresh();
|
|
250
|
+
setTimeout(() => debouncedRefresh(), 1500);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Handle batch-completed messages for this specific batch
|
|
255
|
+
if (message.type === "batch-completed" && message.payload.batchId === batchId) {
|
|
256
|
+
// Refresh header info and jobs
|
|
257
|
+
(async () => {
|
|
258
|
+
try {
|
|
259
|
+
const resInfo = await getBatchInfo(batchId);
|
|
260
|
+
const raw = resInfo?.data ?? resInfo;
|
|
261
|
+
const info = normalizeBatchInfo(raw);
|
|
262
|
+
if (info)
|
|
263
|
+
setBatchInfo(info);
|
|
264
|
+
}
|
|
265
|
+
catch { }
|
|
266
|
+
})();
|
|
267
|
+
// Debounce refresh to avoid bursts near completion
|
|
268
|
+
debouncedRefresh();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
return () => {
|
|
272
|
+
if (removeSocketMessageListener) {
|
|
273
|
+
removeSocketMessageListener();
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
}, [editContext, batchId, loadBatchJobs, debouncedRefresh, itemNameById]);
|
|
277
|
+
// Adaptive polling while batch is in progress: poll only if no WS in last 6s
|
|
278
|
+
useEffect(() => {
|
|
279
|
+
const isRunning = batchInfo?.status === 'In Progress';
|
|
280
|
+
if (!isRunning)
|
|
281
|
+
return;
|
|
282
|
+
const id = setInterval(() => {
|
|
283
|
+
if (Date.now() - lastWsAtRef.current > 6000) {
|
|
284
|
+
void loadBatchJobs();
|
|
285
|
+
}
|
|
286
|
+
}, 12000);
|
|
287
|
+
return () => clearInterval(id);
|
|
288
|
+
}, [batchInfo?.status, loadBatchJobs]);
|
|
289
|
+
// Helper function to resolve item names for new jobs
|
|
290
|
+
const resolveItemNames = async (itemIds) => {
|
|
291
|
+
if (itemIds.length && editContext?.itemsRepository) {
|
|
292
|
+
const descriptors = itemIds.map((id) => ({ id, language: "en", version: 0 }));
|
|
293
|
+
try {
|
|
294
|
+
const stubs = await editContext.itemsRepository.getItemsStubs(descriptors);
|
|
295
|
+
const map = {};
|
|
296
|
+
stubs?.forEach((s) => {
|
|
297
|
+
if (s)
|
|
298
|
+
map[s.id] = s.name || s.id;
|
|
299
|
+
});
|
|
300
|
+
setItemNameById((prev) => ({ ...prev, ...map }));
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
console.error("Failed to load item names:", error);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
// Group jobs by item for better organization
|
|
308
|
+
const itemGroups = useMemo(() => {
|
|
309
|
+
const groups = {};
|
|
310
|
+
for (const job of batchJobs) {
|
|
311
|
+
if (!groups[job.itemId]) {
|
|
312
|
+
groups[job.itemId] = [];
|
|
313
|
+
}
|
|
314
|
+
groups[job.itemId].push(job);
|
|
315
|
+
}
|
|
316
|
+
return groups;
|
|
317
|
+
}, [batchJobs]);
|
|
318
|
+
// Sort items by status priority: in-progress first, then errors, then completed
|
|
319
|
+
const sortedItemGroups = useMemo(() => {
|
|
320
|
+
const entries = Object.entries(itemGroups);
|
|
321
|
+
return entries.sort(([, jobsA], [, jobsB]) => {
|
|
322
|
+
// Calculate status priority for each item group
|
|
323
|
+
const getStatusPriority = (jobs) => {
|
|
324
|
+
const hasInProgress = jobs.some(j => j.status === "In Progress");
|
|
325
|
+
const hasError = jobs.some(j => j.status === "Error");
|
|
326
|
+
const allCompleted = jobs.every(j => j.status === "Completed");
|
|
327
|
+
if (hasInProgress)
|
|
328
|
+
return 1; // Highest priority - show first
|
|
329
|
+
if (hasError)
|
|
330
|
+
return 2; // Second priority
|
|
331
|
+
if (allCompleted)
|
|
332
|
+
return 4; // Lowest priority - show last
|
|
333
|
+
return 3; // Pending/other statuses
|
|
334
|
+
};
|
|
335
|
+
const priorityA = getStatusPriority(jobsA);
|
|
336
|
+
const priorityB = getStatusPriority(jobsB);
|
|
337
|
+
return priorityA - priorityB;
|
|
338
|
+
});
|
|
339
|
+
}, [itemGroups]);
|
|
340
|
+
// Calculate overall progress segments including in-progress job progress.
|
|
341
|
+
// Incorporate server-side expected/completed/error counts so pending server jobs are reflected.
|
|
342
|
+
const progressSegments = useMemo(() => {
|
|
343
|
+
const expectedTotal = typeof batchInfo?.expectedJobs === 'number' && batchInfo.expectedJobs > 0
|
|
344
|
+
? batchInfo.expectedJobs
|
|
345
|
+
: batchJobs.length;
|
|
346
|
+
if (expectedTotal === 0) {
|
|
347
|
+
return {
|
|
348
|
+
completed: 0,
|
|
349
|
+
inProgress: 0,
|
|
350
|
+
error: 0,
|
|
351
|
+
pending: 0,
|
|
352
|
+
completedCount: 0,
|
|
353
|
+
inProgressCount: 0,
|
|
354
|
+
errorCount: 0,
|
|
355
|
+
pendingCount: 0,
|
|
356
|
+
total: 0,
|
|
357
|
+
actualProgress: 0
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const localCompleted = batchJobs.filter(job => job.status === 'Completed').length;
|
|
361
|
+
const localErrors = batchJobs.filter(job => job.status === 'Error').length;
|
|
362
|
+
const serverCompleted = typeof batchInfo?.completedJobs === 'number' ? batchInfo.completedJobs : undefined;
|
|
363
|
+
const serverErrors = typeof batchInfo?.errorJobs === 'number' ? batchInfo.errorJobs : undefined;
|
|
364
|
+
// Prefer the larger of local/server so UI can progress smoothly with local updates
|
|
365
|
+
const completedCount = Math.max(0, Math.min(expectedTotal, Math.max(localCompleted, serverCompleted ?? 0)));
|
|
366
|
+
const errorCount = Math.max(0, Math.min(expectedTotal - completedCount, Math.max(localErrors, serverErrors ?? 0)));
|
|
367
|
+
// Known in-progress from client state
|
|
368
|
+
const knownInProgressCount = batchJobs.filter(job => job.status === 'In Progress').length;
|
|
369
|
+
// Cap to not exceed remaining after completed+error
|
|
370
|
+
const inProgressCount = Math.max(0, Math.min(knownInProgressCount, expectedTotal - completedCount - errorCount));
|
|
371
|
+
const pendingCount = Math.max(0, expectedTotal - completedCount - errorCount - inProgressCount);
|
|
372
|
+
const pct = (count) => Math.max(0, Math.min(100, Math.round((count / expectedTotal) * 100)));
|
|
373
|
+
// Actual progress: completed=100 each, in-progress use live progress if available, others 0
|
|
374
|
+
let totalActualProgress = completedCount * 100;
|
|
375
|
+
for (const job of batchJobs) {
|
|
376
|
+
if (job.status === 'In Progress') {
|
|
377
|
+
const progressKey = `${job.itemId}-${job.targetLanguage}`;
|
|
378
|
+
const jobProgress = translationProgress.get(progressKey);
|
|
379
|
+
totalActualProgress += jobProgress?.progress || 0;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
const actualProgress = Math.max(0, Math.min(100, Math.round(totalActualProgress / expectedTotal)));
|
|
383
|
+
return {
|
|
384
|
+
completed: pct(completedCount),
|
|
385
|
+
inProgress: pct(inProgressCount),
|
|
386
|
+
error: pct(errorCount),
|
|
387
|
+
pending: pct(pendingCount),
|
|
388
|
+
completedCount,
|
|
389
|
+
inProgressCount,
|
|
390
|
+
errorCount,
|
|
391
|
+
pendingCount,
|
|
392
|
+
total: expectedTotal,
|
|
393
|
+
actualProgress,
|
|
394
|
+
};
|
|
395
|
+
}, [batchJobs, batchInfo, translationProgress]);
|
|
396
|
+
const overallProgress = progressSegments.actualProgress;
|
|
397
|
+
return (_jsxs("div", { className: "flex h-full flex-col min-h-0", children: [_jsxs("div", { className: "border-b p-4 flex-shrink-0", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-3", children: [onBack && (_jsxs(Button, { size: "sm", variant: "outline", onClick: onBack, children: [_jsx("i", { className: "pi pi-arrow-left" }), "Back"] })), _jsxs("div", { children: [_jsx("h1", { className: "text-xl font-semibold", children: "Translation Batch" }), _jsxs("p", { className: "text-sm text-gray-600 mt-1", children: ["Batch ID: ", _jsx("span", { className: "font-mono text-xs bg-gray-100 px-2 py-0.5 rounded", children: batchId })] }), batchInfo && (_jsxs("div", { className: "mt-2 flex flex-wrap gap-2 items-center text-xs", children: [batchInfo.status && (_jsx("span", { className: `inline-flex items-center rounded-full px-2 py-1 font-medium ${batchInfo.status === 'Completed'
|
|
398
|
+
? 'bg-green-100 text-green-700'
|
|
399
|
+
: batchInfo.status === 'In Progress'
|
|
400
|
+
? 'bg-blue-100 text-blue-700'
|
|
401
|
+
: batchInfo.status === 'Error'
|
|
402
|
+
? 'bg-red-100 text-red-700'
|
|
403
|
+
: 'bg-gray-100 text-gray-700'}`, children: batchInfo.status })), batchInfo.provider && (_jsxs("span", { className: "text-gray-700", children: ["Provider: ", _jsx("span", { className: "font-medium", children: getProviderDisplayName(batchInfo.provider) })] })), batchInfo.initiatedByUser && (_jsxs("span", { className: "text-gray-700", children: ["By: ", _jsx("span", { className: "font-medium", children: batchInfo.initiatedByUser })] })), batchInfo.includeSubitems !== undefined && batchInfo.includeSubitems !== null && (_jsxs("span", { className: "text-gray-700", children: ["Include subitems: ", _jsx("span", { className: "font-medium", children: batchInfo.includeSubitems ? 'Yes' : 'No' })] })), batchInfo.scopeItemPath && (_jsxs("span", { className: "text-gray-700 truncate max-w-[40rem]", children: ["Scope: ", _jsx("span", { className: "font-mono", children: batchInfo.scopeItemPath })] })), (batchInfo.startedAtUtc || batchInfo.completedAtUtc || batchInfo.lastUpdatedUtc) && (_jsxs("span", { className: "text-gray-500", children: [batchInfo.startedAtUtc && (_jsxs(_Fragment, { children: ["Started: ", new Date(batchInfo.startedAtUtc).toLocaleString()] })), batchInfo.completedAtUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? ' • ' : '', "Completed: ", new Date(batchInfo.completedAtUtc).toLocaleString()] })), !batchInfo.completedAtUtc && batchInfo.lastUpdatedUtc && (_jsxs(_Fragment, { children: [batchInfo.startedAtUtc ? ' • ' : '', "Updated: ", new Date(batchInfo.lastUpdatedUtc).toLocaleString()] }))] }))] }))] })] }), _jsxs("div", { className: "flex items-center gap-3", children: [_jsxs("div", { className: "text-sm text-gray-600", children: [`${progressSegments.completedCount}/${progressSegments.total} completed`, progressSegments.errorCount ? `, ${progressSegments.errorCount} errors` : ''] }), _jsxs(Button, { size: "sm", onClick: loadBatchJobs, disabled: isLoading, title: "Manual refresh - normally updates come through websocket subscriptions", children: [_jsx("i", { className: `pi pi-refresh ${isLoading ? 'pi-spin' : ''}` }), "Refresh"] })] })] }), _jsxs("div", { className: "mt-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("span", { className: "text-sm font-medium", children: "Overall Progress" }), _jsxs("span", { className: "text-sm text-gray-600", children: [progressSegments.completedCount, " / ", progressSegments.total, " completed"] })] }), _jsxs("div", { className: "relative h-4 bg-gray-200 rounded-full overflow-hidden", children: [_jsxs("div", { className: "absolute inset-0 flex", children: [progressSegments.completed > 0 && (_jsx("div", { className: "bg-green-500 h-full transition-all duration-300", style: { width: `${progressSegments.completed}%` } })), progressSegments.inProgress > 0 && (_jsx("div", { className: "bg-blue-500 h-full transition-all duration-300", style: { width: `${progressSegments.inProgress}%` } })), progressSegments.error > 0 && (_jsx("div", { className: "bg-red-500 h-full transition-all duration-300", style: { width: `${progressSegments.error}%` } })), progressSegments.pending > 0 && (_jsx("div", { className: "bg-gray-300 h-full transition-all duration-300", style: { width: `${progressSegments.pending}%` } }))] }), _jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: _jsxs("span", { className: "text-xs font-medium text-white drop-shadow-sm", children: [overallProgress, "%"] }) })] }), _jsxs("div", { className: "flex gap-3 mt-2 text-xs", children: [progressSegments.completedCount > 0 && (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("div", { className: "w-3 h-3 bg-green-500 rounded-sm" }), _jsxs("span", { className: "text-gray-700", children: [progressSegments.completedCount, " completed"] })] })), progressSegments.inProgressCount > 0 && (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("div", { className: "w-3 h-3 bg-blue-500 rounded-sm" }), _jsxs("span", { className: "text-gray-700", children: [progressSegments.inProgressCount, " in progress"] })] })), progressSegments.errorCount > 0 && (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("div", { className: "w-3 h-3 bg-red-500 rounded-sm" }), _jsxs("span", { className: "text-gray-700", children: [progressSegments.errorCount, " errors"] })] })), progressSegments.pendingCount > 0 && (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("div", { className: "w-3 h-3 bg-gray-300 rounded-sm" }), _jsxs("span", { className: "text-gray-700", children: [progressSegments.pendingCount, " pending"] })] })), typeof batchInfo?.expectedJobs === 'number' && (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx("div", { className: "w-3 h-3 bg-gray-300 rounded-sm" }), _jsxs("span", { className: "text-gray-700", children: [(batchInfo.expectedJobs || 0) - (batchInfo.completedJobs || 0) - (batchInfo.errorJobs || 0), " pending (server)"] })] }))] })] })] }), _jsx("div", { className: "flex-1 overflow-auto p-4 min-h-0", children: isLoading && batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "flex items-center gap-2 text-gray-500", children: [_jsx("i", { className: "pi pi-spin pi-spinner" }), "Loading batch translations..."] }) })) : batchJobs.length === 0 ? (_jsx("div", { className: "flex items-center justify-center h-32", children: _jsxs("div", { className: "text-center text-gray-500", children: [_jsx("i", { className: "pi pi-language text-2xl block mb-2" }), _jsx("p", { children: "No translations found for this batch" }), _jsx("p", { className: "text-sm", children: "The batch may not exist or has no translation jobs" })] }) })) : (_jsx("div", { className: "space-y-6", children: sortedItemGroups.map(([itemId, jobs]) => {
|
|
404
|
+
const itemName = itemNameById[itemId] || itemId;
|
|
405
|
+
const itemCompleted = jobs.filter(j => j.status === "Completed").length;
|
|
406
|
+
// Calculate item progress including in-progress job progress
|
|
407
|
+
// Performance: item-level calculation is always fast (typically 2-10 jobs per item)
|
|
408
|
+
let totalProgress = 0;
|
|
409
|
+
for (const job of jobs) {
|
|
410
|
+
if (job.status === "Completed") {
|
|
411
|
+
totalProgress += 100; // Full credit for completed jobs
|
|
412
|
+
}
|
|
413
|
+
else if (job.status === "In Progress") {
|
|
414
|
+
// Get progress for this specific job
|
|
415
|
+
const progressKey = `${job.itemId}-${job.targetLanguage}`;
|
|
416
|
+
const jobProgress = translationProgress.get(progressKey);
|
|
417
|
+
totalProgress += jobProgress?.progress || 0; // Use actual progress or 0
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
totalProgress += 0; // Pending/Error jobs contribute 0
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
const itemProgress = Math.round(totalProgress / jobs.length);
|
|
424
|
+
const itemInProgress = jobs.some(j => j.status === "In Progress");
|
|
425
|
+
const itemError = jobs.some(j => j.status === "Error");
|
|
426
|
+
return (_jsxs("div", { className: "border rounded-lg p-4", children: [_jsxs("div", { className: "mb-4", children: [_jsxs("div", { className: "flex items-center justify-between mb-2", children: [_jsx("h3", { className: "font-medium text-lg", children: itemName }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsxs("span", { className: "text-sm text-gray-600", children: [itemCompleted, " / ", jobs.length, " languages"] }), itemInProgress && (_jsx("span", { className: "text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full", children: "In Progress" })), itemError && (_jsx("span", { className: "text-xs bg-red-100 text-red-700 px-2 py-1 rounded-full", children: "Error" }))] })] }), _jsx("div", { className: "relative", children: _jsx(Progress, { value: itemProgress, className: "h-3 mb-2", indicatorClassName: itemError ? "bg-red-500" :
|
|
427
|
+
itemProgress === 100 ? "bg-green-500" :
|
|
428
|
+
"bg-blue-500", showValue: true }) }), _jsxs("div", { className: "text-xs text-gray-500 font-mono", children: ["Item ID: ", itemId] })] }), _jsx(SimpleTable, { columns: [
|
|
429
|
+
{
|
|
430
|
+
header: "Language",
|
|
431
|
+
className: "w-16", // Fixed width for language column
|
|
432
|
+
body: (job) => (_jsx("div", { className: "font-medium", children: job.targetLanguage }))
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
header: "Status",
|
|
436
|
+
className: "w-32", // Fixed width for status column
|
|
437
|
+
body: (job) => {
|
|
438
|
+
const progressKey = `${job.itemId}-${job.targetLanguage}`;
|
|
439
|
+
const progress = translationProgress.get(progressKey);
|
|
440
|
+
return (_jsxs("div", { children: [_jsx("span", { className: `inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${job.status === "Completed"
|
|
441
|
+
? "bg-green-100 text-green-700"
|
|
442
|
+
: job.status === "In Progress"
|
|
443
|
+
? "bg-blue-100 text-blue-700"
|
|
444
|
+
: job.status === "Error"
|
|
445
|
+
? "bg-red-100 text-red-700"
|
|
446
|
+
: "bg-gray-100 text-gray-700"}`, children: job.status }), job.status === "In Progress" && progress && Math.round(progress.progress || 0) === 0 && (_jsx("div", { className: "mt-1 text-xs text-gray-600", children: "Version created" })), progress && job.status === "In Progress" && (_jsxs("div", { className: "mt-1", children: [_jsxs("div", { className: "flex items-center justify-between mb-1", children: [_jsx("span", { className: "text-xs text-gray-500 truncate", children: progress.message }), _jsxs("span", { className: "text-xs font-medium text-gray-700 ml-1", children: [Math.round(progress.progress), "%"] })] }), _jsx(Progress, { value: progress.progress, className: "h-2", indicatorClassName: "bg-blue-500", showValue: false })] }))] }));
|
|
447
|
+
}
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
header: "Source",
|
|
451
|
+
className: "w-16", // Fixed width for source column
|
|
452
|
+
body: (job) => (_jsx("div", { className: "text-sm", children: job.sourceLanguage || "—" }))
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
header: "Message",
|
|
456
|
+
className: "w-64", // Fixed width with proper wrapping for message column
|
|
457
|
+
body: (job) => (_jsx("div", { className: "text-sm break-words whitespace-normal leading-relaxed", children: job.message || "—" }))
|
|
458
|
+
},
|
|
459
|
+
{
|
|
460
|
+
header: "Updated",
|
|
461
|
+
className: "w-24", // Fixed width for updated column
|
|
462
|
+
body: (job) => {
|
|
463
|
+
const date = new Date(job.timestamp);
|
|
464
|
+
return (_jsx("div", { className: "text-sm whitespace-nowrap", children: date.toLocaleTimeString() }));
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
], items: jobs.sort((a, b) => {
|
|
468
|
+
// Sort by status first (in-progress first, then errors, then completed)
|
|
469
|
+
const getJobPriority = (job) => {
|
|
470
|
+
if (job.status === "In Progress")
|
|
471
|
+
return 1;
|
|
472
|
+
if (job.status === "Error")
|
|
473
|
+
return 2;
|
|
474
|
+
if (job.status === "Completed")
|
|
475
|
+
return 4;
|
|
476
|
+
return 3; // Pending/other statuses
|
|
477
|
+
};
|
|
478
|
+
const priorityA = getJobPriority(a);
|
|
479
|
+
const priorityB = getJobPriority(b);
|
|
480
|
+
// If same priority, sort by language alphabetically
|
|
481
|
+
if (priorityA === priorityB) {
|
|
482
|
+
return a.targetLanguage.localeCompare(b.targetLanguage);
|
|
483
|
+
}
|
|
484
|
+
return priorityA - priorityB;
|
|
485
|
+
}) })] }, itemId));
|
|
486
|
+
}) })) })] }));
|
|
487
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"RecentTranslations.d.ts","sourceRoot":"","sources":["../../../../src/translation-center/RecentTranslations.tsx"],"names":[],"mappings":"AA8BA,wBAAgB,kBAAkB,4CAgYjC"}
|