@parhelia/localization 0.4.12872 → 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;AA0V1B,wBAAgB,kBAAkB,sBA6qCjC;AAED,eAAO,MAAM,oCAAoC,8BAA8B,CAAC;AAEhF,wBAAgB,8BAA8B,sBA+Y7C"}
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
- ((message.type === "translation-started" ||
465
- message.type === "translation-finished" ||
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
- void loadBatchJobsRef.current?.(batchIdFromPayload);
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 () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parhelia/localization",
3
- "version": "0.4.12872",
3
+ "version": "0.4.12873",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"