@parhelia/localization 0.4.12871 → 0.4.12873
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.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"TranslationBatches.d.ts","sourceRoot":"","sources":["../../src/translation-center/TranslationBatches.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"TranslationBatches.d.ts","sourceRoot":"","sources":["../../src/translation-center/TranslationBatches.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,MAAM,OAAO,CAAC;AAyb1B,wBAAgB,kBAAkB,sBA8uCjC;AAED,eAAO,MAAM,oCAAoC,8BAA8B,CAAC;AAEhF,wBAAgB,8BAA8B,sBA+Y7C"}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState, useMemo, useSyncExternalStore, } from "react";
|
|
3
3
|
import React from "react";
|
|
4
|
-
import { Button, Badge, Select, Input, UserPicker, Popover, PopoverContent, PopoverTrigger, Tooltip, TooltipContent, TooltipTrigger, ContentTree, ContentInspectorPopover, PageHeader, fetchItemStubs, searchUsers as searchBackendUsers, useEditContext, getLanguages, SimpleIconButton, WorkspaceHomeView, capitalizeFirstLetter, formatDateTime, } from "@parhelia/core";
|
|
4
|
+
import { Button, Badge, Select, Input, UserPicker, Popover, PopoverContent, PopoverTrigger, Tooltip, TooltipContent, TooltipTrigger, ContentTree, ContentInspectorPopover, PageHeader, fetchItemStubs, searchUsers as searchBackendUsers, useEditContext, getLanguages, SimpleIconButton, WorkspaceHomeView, capitalizeFirstLetter, createCoalescedReload, formatDateTime, } from "@parhelia/core";
|
|
5
5
|
import { listBatches, getTranslationProviders, getTranslationJobLastPayload, listBatchTranslationJobs, retryBatchTranslation, abortJob, abortBatch, subscribeToBatch, unsubscribeFromBatch, } from "../services/translationService";
|
|
6
6
|
import { TRANSLATION_BATCH_STARTED_EVENT, } from "../translationEvents";
|
|
7
|
-
import { useDebouncedCallback } from "use-debounce";
|
|
7
|
+
import { useDebouncedCallback, useThrottledCallback } from "use-debounce";
|
|
8
8
|
import { X, ChevronDown, Languages, Loader2, Globe, FolderTree, ExternalLink, Check, AlertCircle, CircleStop, Hourglass, Copy, Sparkles, RotateCcw, Plus, Bug, } from "lucide-react";
|
|
9
9
|
import { toast } from "sonner";
|
|
10
10
|
import { openLocalizeItemDialog } from "../LocalizeItemCommand";
|
|
@@ -142,6 +142,77 @@ function normalizeGuid(value) {
|
|
|
142
142
|
function normalizeLanguageCode(value) {
|
|
143
143
|
return (value ?? "").toString().trim().toLowerCase();
|
|
144
144
|
}
|
|
145
|
+
const JOB_STATUS_BY_EVENT = {
|
|
146
|
+
"translation-started": "In Progress",
|
|
147
|
+
"translation-finished": "Completed",
|
|
148
|
+
"translation-error": "Error",
|
|
149
|
+
"translation-aborted": "Aborted",
|
|
150
|
+
};
|
|
151
|
+
function pickPayloadValue(payload, ...keys) {
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
const value = payload[key];
|
|
154
|
+
if (value !== undefined && value !== null)
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Applies a per-job socket event to a loaded job list. The server sends the
|
|
161
|
+
* same event twice to the initiating session (directly and through the batch
|
|
162
|
+
* subscription) and in either property casing, so this is idempotent and
|
|
163
|
+
* reads both spellings.
|
|
164
|
+
*
|
|
165
|
+
* Returns `undefined` when the event's job is not in the list (the caller
|
|
166
|
+
* then needs a reload), the same array when nothing changed, and a new array
|
|
167
|
+
* with the patched record otherwise.
|
|
168
|
+
*/
|
|
169
|
+
function applyJobStatusEvent(jobs, eventType, rawPayload) {
|
|
170
|
+
const payload = (rawPayload ?? {});
|
|
171
|
+
const status = String(pickPayloadValue(payload, "status", "Status") ??
|
|
172
|
+
JOB_STATUS_BY_EVENT[eventType] ??
|
|
173
|
+
"");
|
|
174
|
+
if (!status)
|
|
175
|
+
return undefined;
|
|
176
|
+
const translationId = normalizeGuid(pickPayloadValue(payload, "id", "Id"));
|
|
177
|
+
const itemId = normalizeGuid(pickPayloadValue(payload, "itemId", "ItemId"));
|
|
178
|
+
const language = String(pickPayloadValue(payload, "language", "Language") ?? "");
|
|
179
|
+
let index = -1;
|
|
180
|
+
if (translationId) {
|
|
181
|
+
index = jobs.findIndex((job) => normalizeGuid(job.id) === translationId);
|
|
182
|
+
}
|
|
183
|
+
if (index < 0 && itemId && language) {
|
|
184
|
+
const candidates = jobs
|
|
185
|
+
.map((job, i) => ({ job, i }))
|
|
186
|
+
.filter(({ job }) => normalizeGuid(job.itemId) === itemId &&
|
|
187
|
+
job.targetLanguage === language);
|
|
188
|
+
// Retries add records for the same item and language; a running one is
|
|
189
|
+
// the event's subject, otherwise the newest record.
|
|
190
|
+
const active = candidates.find(({ job }) => !isTerminalBatchStatus(job.status));
|
|
191
|
+
index = (active ?? candidates[candidates.length - 1])?.i ?? -1;
|
|
192
|
+
}
|
|
193
|
+
const job = index >= 0 ? jobs[index] : undefined;
|
|
194
|
+
if (!job)
|
|
195
|
+
return undefined;
|
|
196
|
+
const next = { ...job, status };
|
|
197
|
+
const message = pickPayloadValue(payload, "message", "Message");
|
|
198
|
+
if (typeof message === "string" && message)
|
|
199
|
+
next.message = message;
|
|
200
|
+
const timestamp = pickPayloadValue(payload, "timestamp", "Timestamp");
|
|
201
|
+
if (typeof timestamp === "string" && timestamp)
|
|
202
|
+
next.timestamp = timestamp;
|
|
203
|
+
const totalCost = pickPayloadValue(payload, "totalCost", "TotalCost");
|
|
204
|
+
if (typeof totalCost === "number")
|
|
205
|
+
next.totalCost = totalCost;
|
|
206
|
+
const unchanged = next.status === job.status &&
|
|
207
|
+
next.message === job.message &&
|
|
208
|
+
next.timestamp === job.timestamp &&
|
|
209
|
+
next.totalCost === job.totalCost;
|
|
210
|
+
if (unchanged)
|
|
211
|
+
return jobs;
|
|
212
|
+
const result = jobs.slice();
|
|
213
|
+
result[index] = next;
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
145
216
|
function dateRangeToFromUtc(range) {
|
|
146
217
|
const now = new Date();
|
|
147
218
|
const cutoff = new Date(now);
|
|
@@ -175,6 +246,10 @@ function dateRangeToFromUtc(range) {
|
|
|
175
246
|
}
|
|
176
247
|
return cutoff.toISOString();
|
|
177
248
|
}
|
|
249
|
+
/** Quiet window that collapses a burst of job events into one job-list read. */
|
|
250
|
+
const BATCH_JOBS_RELOAD_WINDOW_MS = 400;
|
|
251
|
+
/** Longest a running batch's row counters lag behind its job events. */
|
|
252
|
+
const BATCH_LIST_RELOAD_THROTTLE_MS = 3000;
|
|
178
253
|
const EMPTY_STATE_SLOGANS = [
|
|
179
254
|
"It's quiet here. In every language.",
|
|
180
255
|
"Zero translations. Nada. Nichts. Rien.",
|
|
@@ -337,6 +412,10 @@ export function TranslationBatches() {
|
|
|
337
412
|
}
|
|
338
413
|
}, []);
|
|
339
414
|
const debouncedReload = useDebouncedCallback(() => loadRecentBatches(0, false, effectiveFilters), 800);
|
|
415
|
+
// Per-job events only move a row's counters; one list read every few
|
|
416
|
+
// seconds keeps the rings honest without a read per job. Batch completion
|
|
417
|
+
// still goes through the quicker debounced reload above.
|
|
418
|
+
const throttledReload = useThrottledCallback(() => loadRecentBatches(0, false, effectiveFilters), BATCH_LIST_RELOAD_THROTTLE_MS);
|
|
340
419
|
const loadMore = () => {
|
|
341
420
|
if (!isLoadingMore && hasMore) {
|
|
342
421
|
loadRecentBatches(currentOffset, true, effectiveFilters);
|
|
@@ -419,11 +498,28 @@ export function TranslationBatches() {
|
|
|
419
498
|
useEffect(() => {
|
|
420
499
|
debouncedReloadRef.current = debouncedReload;
|
|
421
500
|
}, [debouncedReload]);
|
|
501
|
+
const throttledReloadRef = useRef(throttledReload);
|
|
502
|
+
useEffect(() => {
|
|
503
|
+
throttledReloadRef.current = throttledReload;
|
|
504
|
+
}, [throttledReload]);
|
|
505
|
+
// Mirror of the loaded job lists for the socket listener, which must know
|
|
506
|
+
// synchronously whether an event's job is loaded before deciding between an
|
|
507
|
+
// in-place patch and a full reload.
|
|
508
|
+
const batchJobsRef = useRef(batchJobs);
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
batchJobsRef.current = batchJobs;
|
|
511
|
+
}, [batchJobs]);
|
|
422
512
|
const expandedBatchIdRef = useRef(expandedBatchId);
|
|
423
513
|
useEffect(() => {
|
|
424
514
|
expandedBatchIdRef.current = expandedBatchId;
|
|
425
515
|
}, [expandedBatchId]);
|
|
426
516
|
const loadBatchJobsRef = useRef(null);
|
|
517
|
+
// Job events arrive in bursts: one per job start and finish, and the
|
|
518
|
+
// initiator's session receives each twice (sent directly and again through
|
|
519
|
+
// the batch subscription). Every one used to trigger its own full job-list
|
|
520
|
+
// read. Collapse them into one read per batch per window, never two at once.
|
|
521
|
+
const batchJobsReload = useMemo(() => createCoalescedReload((batchId) => loadBatchJobsRef.current?.(batchId) ?? Promise.resolve([]), BATCH_JOBS_RELOAD_WINDOW_MS), []);
|
|
522
|
+
useEffect(() => () => batchJobsReload.dispose(), [batchJobsReload]);
|
|
427
523
|
// Listen for batch-related events to refresh the list
|
|
428
524
|
useEffect(() => {
|
|
429
525
|
const removeSocketMessageListener = editContext?.addSocketMessageListener((message) => {
|
|
@@ -460,19 +556,48 @@ export function TranslationBatches() {
|
|
|
460
556
|
}
|
|
461
557
|
return;
|
|
462
558
|
}
|
|
463
|
-
if (message.type === "batch-completed"
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
message.type === "translation-error" ||
|
|
467
|
-
message.type === "translation-aborted") &&
|
|
468
|
-
batchIdFromPayload)) {
|
|
559
|
+
if (message.type === "batch-completed") {
|
|
560
|
+
// Final state: the row's status and counters, and the authoritative
|
|
561
|
+
// job records (attempts, costs, messages) for the open batch.
|
|
469
562
|
debouncedReloadRef.current();
|
|
470
|
-
// If the impacted batch is currently expanded, refresh its per-job
|
|
471
|
-
// list too — otherwise the language chips keep their stale spinner.
|
|
472
563
|
if (batchIdFromPayload &&
|
|
473
564
|
expandedBatchIdRef.current === batchIdFromPayload) {
|
|
474
|
-
|
|
565
|
+
batchJobsReload.request(batchIdFromPayload);
|
|
475
566
|
}
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if ((message.type === "translation-started" ||
|
|
570
|
+
message.type === "translation-finished" ||
|
|
571
|
+
message.type === "translation-error" ||
|
|
572
|
+
message.type === "translation-aborted") &&
|
|
573
|
+
batchIdFromPayload) {
|
|
574
|
+
throttledReloadRef.current();
|
|
575
|
+
if (expandedBatchIdRef.current !== batchIdFromPayload)
|
|
576
|
+
return;
|
|
577
|
+
// The event names the job and its new status, so the open batch's
|
|
578
|
+
// chips are patched in place. A full job-list read (hundreds of KB
|
|
579
|
+
// for a large batch) is only needed when the job is not loaded,
|
|
580
|
+
// e.g. a retry that added records, or a list still being fetched.
|
|
581
|
+
const loadedJobs = batchJobsRef.current[batchIdFromPayload];
|
|
582
|
+
const isLoaded = !!loadedJobs &&
|
|
583
|
+
applyJobStatusEvent(loadedJobs, message.type, message.payload) !==
|
|
584
|
+
undefined;
|
|
585
|
+
if (!isLoaded) {
|
|
586
|
+
batchJobsReload.request(batchIdFromPayload);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
// Patch against React's own previous state: several events can
|
|
590
|
+
// arrive before the ref mirror above catches up.
|
|
591
|
+
setBatchJobs((prev) => {
|
|
592
|
+
const current = prev[batchIdFromPayload];
|
|
593
|
+
if (!current)
|
|
594
|
+
return prev;
|
|
595
|
+
const next = applyJobStatusEvent(current, message.type, message.payload) ??
|
|
596
|
+
current;
|
|
597
|
+
return next === current
|
|
598
|
+
? prev
|
|
599
|
+
: { ...prev, [batchIdFromPayload]: next };
|
|
600
|
+
});
|
|
476
601
|
}
|
|
477
602
|
});
|
|
478
603
|
return () => {
|