Total shown right away, lines saved per poll, retry reasons in the card; layout fix of the options row. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
303 lines
13 KiB
TypeScript
303 lines
13 KiB
TypeScript
// The one machine translation run of this browser. It lives above the pages, so it goes on while
|
|
// the user moves around the site, and its card sits in the same floating window as the uploads.
|
|
// The run is written down at every page, so reloading the site picks it up where it stood.
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { api } from "../api";
|
|
import type { LangStats } from "../pages/translation-types";
|
|
import { ErrorNote } from "../ui/Bits";
|
|
import { Icon } from "../ui/Icon";
|
|
import { TranslatorClient } from "./client";
|
|
import { runTranslation, type Checkpoint, type Progress, type Source } from "./run";
|
|
|
|
const PAGE = 40;
|
|
const SAVED_KEY = "pg.translation.run";
|
|
const LOCK = "pg.translation.run";
|
|
/** a run last heard of longer ago than this is not picked up again */
|
|
const FRESH_MS = 30 * 60 * 1000;
|
|
|
|
export type RunState = "untranslated" | "outdated";
|
|
|
|
interface RunInfo {
|
|
projectId: string;
|
|
versionId: string;
|
|
lang: string;
|
|
/** shown in the floating card: the game and version being translated */
|
|
title: string;
|
|
kind: "lines" | "strings";
|
|
/** the list the run walks, as the editor shows it */
|
|
module: string;
|
|
q: string;
|
|
states: RunState[];
|
|
batch: number;
|
|
}
|
|
|
|
export interface RunSpec extends RunInfo {
|
|
client: TranslatorClient;
|
|
}
|
|
|
|
/** What is kept in the browser to continue after a reload. */
|
|
interface SavedRun {
|
|
info: RunInfo;
|
|
/** address of the translator */
|
|
base: string;
|
|
/** which of `states` was being walked, and the totals of the ones before it */
|
|
phase: number;
|
|
before: { total: number; done: number; failed: number };
|
|
checkpoint: Checkpoint | null;
|
|
at: number;
|
|
}
|
|
|
|
export interface TranslationTask {
|
|
id: number;
|
|
versionId: string;
|
|
lang: string;
|
|
title: string;
|
|
kind: "lines" | "strings";
|
|
status: "running" | "stopping" | "done" | "stopped" | "failed";
|
|
progress: Progress;
|
|
/** lines of the page being translated that are not saved yet */
|
|
keys: string[];
|
|
/** the first line of that page: moves when the run moves on */
|
|
cursor: string;
|
|
error: string;
|
|
}
|
|
|
|
/** What the open editor needs to show a page of results at once. */
|
|
export interface FillEvent {
|
|
versionId: string;
|
|
lang: string;
|
|
kind: "lines" | "strings";
|
|
results: { key: string; text: string }[];
|
|
stats: Partial<LangStats>;
|
|
}
|
|
|
|
interface RunApi {
|
|
task: TranslationTask | null;
|
|
/** a run is going: no other can start */
|
|
busy: boolean;
|
|
/** `false` when another run (here or in another tab) already holds the translator */
|
|
start: (spec: RunSpec) => Promise<boolean>;
|
|
stop: () => void;
|
|
dismiss: () => void;
|
|
subscribe: (listener: (event: FillEvent) => void) => () => void;
|
|
}
|
|
|
|
const RunContext = createContext<RunApi | null>(null);
|
|
const EMPTY: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
|
|
|
|
function loadSaved(): SavedRun | null {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem(SAVED_KEY) ?? "null") as SavedRun | null;
|
|
return saved && saved.info && Date.now() - saved.at < FRESH_MS ? saved : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function store(saved: SavedRun | null) {
|
|
try {
|
|
if (saved) localStorage.setItem(SAVED_KEY, JSON.stringify(saved));
|
|
else localStorage.removeItem(SAVED_KEY);
|
|
} catch {
|
|
/* the run just cannot be picked up after a reload */
|
|
}
|
|
}
|
|
|
|
/** Runs `work` only if no other tab runs a translation; the lock is released when the work ends or the tab goes. */
|
|
function exclusive(work: () => Promise<void>): Promise<boolean> {
|
|
if (!navigator.locks) {
|
|
void work();
|
|
return Promise.resolve(true);
|
|
}
|
|
return new Promise((resolve) => {
|
|
void navigator.locks.request(LOCK, { ifAvailable: true }, async (lock) => {
|
|
if (!lock) return resolve(false);
|
|
resolve(true);
|
|
await work();
|
|
});
|
|
});
|
|
}
|
|
|
|
export function TranslationRunProvider({ children }: { children: ReactNode }) {
|
|
const [task, setTask] = useState<TranslationTask | null>(null);
|
|
const control = useRef<AbortController | null>(null);
|
|
const listeners = useRef(new Set<(event: FillEvent) => void>());
|
|
const sequence = useRef(0);
|
|
const busyRef = useRef(false);
|
|
|
|
const patch = useCallback((id: number, change: Partial<TranslationTask> | ((t: TranslationTask) => Partial<TranslationTask>)) => {
|
|
setTask((t) => (t && t.id === id ? { ...t, ...(typeof change === "function" ? change(t) : change) } : t));
|
|
}, []);
|
|
|
|
const launch = useCallback(async (spec: RunSpec, saved?: SavedRun): Promise<boolean> => {
|
|
if (busyRef.current) return false;
|
|
busyRef.current = true;
|
|
const id = ++sequence.current;
|
|
const ctl = new AbortController();
|
|
control.current = ctl;
|
|
const before = saved?.before ?? { total: 0, done: 0, failed: 0 };
|
|
const resumed = saved?.checkpoint;
|
|
setTask({ id, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind, status: "running",
|
|
progress: { ...EMPTY, total: before.total + (resumed?.total ?? 0), done: before.done + (resumed?.done ?? 0),
|
|
failed: before.failed + (resumed?.failed ?? 0) },
|
|
keys: [], cursor: "", error: "" });
|
|
|
|
const info: RunInfo = {
|
|
projectId: spec.projectId, versionId: spec.versionId, lang: spec.lang, title: spec.title, kind: spec.kind,
|
|
module: spec.module, q: spec.q, states: spec.states, batch: spec.batch,
|
|
};
|
|
const url = (state: RunState, offset: number, limit: number) =>
|
|
`/api/versions/${spec.versionId}/i18n/${spec.lang}/entries?${new URLSearchParams({
|
|
kind: spec.kind, state, module: spec.module, q: spec.q, offset: String(offset), limit: String(limit) })}`;
|
|
|
|
const work = async () => {
|
|
// written only once this tab owns the translator, so it never overwrites another tab's record
|
|
store({ info, base: spec.client.base, phase: saved?.phase ?? 0, before, checkpoint: resumed ?? null, at: Date.now() });
|
|
const base = { ...before };
|
|
let last: Progress = EMPTY;
|
|
try {
|
|
for (let phase = saved?.phase ?? 0; phase < spec.states.length; phase++) {
|
|
const state = spec.states[phase];
|
|
const again = phase === saved?.phase ? saved.checkpoint : null;
|
|
const outcome = await runTranslation({
|
|
client: spec.client, projectId: spec.projectId, batchSize: spec.batch, pageSize: PAGE, signal: ctl.signal,
|
|
initial: again ? { total: again.total, done: again.done, failed: again.failed, skip: again.skip } : undefined,
|
|
resume: again?.jobId ? { jobId: again.jobId, keys: again.keys } : undefined,
|
|
fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(url(state, skip, limit)),
|
|
onCheckpoint: (checkpoint) => store({ info, base: spec.client.base, phase, before: { ...base }, checkpoint, at: Date.now() }),
|
|
onPage: (items) => patch(id, { keys: items.map((it) => it.key), cursor: items[0]?.key ?? "" }),
|
|
save: async (results) => {
|
|
const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }]));
|
|
const stats = await api.put<Partial<LangStats>>(`/api/versions/${spec.versionId}/i18n/${spec.lang}`,
|
|
spec.kind === "strings" ? { strings: changes } : { lines: changes });
|
|
const done = new Set(results.map((r) => r.key));
|
|
patch(id, (t) => ({ keys: t.keys.filter((k) => !done.has(k)) }));
|
|
for (const listener of listeners.current) {
|
|
listener({ versionId: spec.versionId, lang: spec.lang, kind: spec.kind, results, stats });
|
|
}
|
|
},
|
|
onProgress: (p) => {
|
|
last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed };
|
|
patch(id, { progress: last });
|
|
},
|
|
});
|
|
Object.assign(base, { total: last.total, done: last.done, failed: last.failed });
|
|
if (outcome === "stopped") break;
|
|
}
|
|
patch(id, { status: ctl.signal.aborted ? "stopped" : "done", keys: [] });
|
|
} catch (e) {
|
|
patch(id, { status: "failed", error: (e as Error).message, keys: [] });
|
|
} finally {
|
|
store(null);
|
|
busyRef.current = false;
|
|
control.current = null;
|
|
}
|
|
};
|
|
|
|
const got = await exclusive(work);
|
|
if (!got) {
|
|
// another tab is translating
|
|
busyRef.current = false;
|
|
control.current = null;
|
|
setTask((t) => (t && t.id === id ? null : t));
|
|
}
|
|
return got;
|
|
}, [patch]);
|
|
|
|
const start = useCallback((spec: RunSpec) => launch(spec), [launch]);
|
|
|
|
// A reload ends the page's loop, but the translator goes on with its page: continue from the saved point.
|
|
useEffect(() => {
|
|
const saved = loadSaved();
|
|
if (!saved) return;
|
|
const resume = () => launch({ ...saved.info, client: new TranslatorClient(saved.base) }, saved);
|
|
let retry: ReturnType<typeof setTimeout> | undefined;
|
|
// the page that was reloaded may still be letting go of its lock: look once more shortly
|
|
void resume().then((got) => {
|
|
if (!got && !busyRef.current) retry = setTimeout(() => void resume(), 2000);
|
|
});
|
|
return () => clearTimeout(retry);
|
|
}, [launch]);
|
|
|
|
const stop = useCallback(() => {
|
|
control.current?.abort();
|
|
setTask((t) => (t && t.status === "running" ? { ...t, status: "stopping" } : t));
|
|
}, []);
|
|
|
|
const dismiss = useCallback(() => setTask((t) => (t && (t.status === "running" || t.status === "stopping") ? t : null)), []);
|
|
|
|
const subscribe = useCallback((listener: (event: FillEvent) => void) => {
|
|
listeners.current.add(listener);
|
|
return () => void listeners.current.delete(listener);
|
|
}, []);
|
|
|
|
const busy = task?.status === "running" || task?.status === "stopping";
|
|
|
|
// A reload is survivable now, but it still pauses the run: say so
|
|
useEffect(() => {
|
|
if (!busy) return;
|
|
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
|
window.addEventListener("beforeunload", warn);
|
|
return () => window.removeEventListener("beforeunload", warn);
|
|
}, [busy]);
|
|
|
|
const value = useMemo(() => ({ task, busy: !!busy, start, stop, dismiss, subscribe }), [task, busy, start, stop, dismiss, subscribe]);
|
|
return <RunContext.Provider value={value}>{children}</RunContext.Provider>;
|
|
}
|
|
|
|
export function useTranslationRun(): RunApi {
|
|
const ctx = useContext(RunContext);
|
|
if (!ctx) throw new Error("TranslationRunProvider is missing");
|
|
return ctx;
|
|
}
|
|
|
|
export function statusText(task: TranslationTask): string {
|
|
const p = task.progress;
|
|
const count = `Переведено ${p.done} из ${p.total}${p.failed ? ` · ошибок ${p.failed}` : ""}`;
|
|
switch (task.status) {
|
|
case "running": return p.total ? count : "Готовлю перевод…";
|
|
case "stopping": return "Останавливаю…";
|
|
case "stopped": return `Остановлено. ${count}`;
|
|
case "done": return `Готово. ${count}`;
|
|
default: return "Перевод прервался";
|
|
}
|
|
}
|
|
|
|
/** The card of the run in the floating window. */
|
|
export function TranslationProgress() {
|
|
const { task, stop, dismiss } = useTranslationRun();
|
|
if (!task) return null;
|
|
const { progress: p } = task;
|
|
const pct = p.total ? Math.min(100, Math.round(((p.done + p.failed) / p.total) * 100)) : 0;
|
|
const active = task.status === "running" || task.status === "stopping";
|
|
const text = statusText(task);
|
|
return (
|
|
<section className="upload-progress card" aria-label="Машинный перевод">
|
|
<h2>Машинный перевод</h2>
|
|
<div className="upload-task">
|
|
<div className="upload-task-heading">
|
|
<b>{task.title}</b>
|
|
{!active && <button className="btn btn-ghost btn-icon" onClick={dismiss} aria-label="Убрать"><Icon name="x" size={16} /></button>}
|
|
</div>
|
|
<div className="muted small">{task.lang}{p.model ? ` · модель: ${p.model}` : ""}</div>
|
|
{task.status !== "failed" && (
|
|
<div className="progress" role="progressbar" aria-label="Машинный перевод" aria-valuemin={0} aria-valuemax={100}
|
|
aria-valuenow={p.total ? pct : undefined} aria-valuetext={text}>
|
|
<div style={{ width: `${pct}%`, opacity: p.total ? 1 : 0.4 }} />
|
|
</div>
|
|
)}
|
|
<p className="small" role="status">{text}</p>
|
|
{task.error && <ErrorNote>{task.error}</ErrorNote>}
|
|
{p.lastError && <p className="muted small">{p.failed > 0 ? "Последняя ошибка" : "Модель повторяет попытку"}: {p.lastError}</p>}
|
|
<div className="actions">
|
|
{active && <button className="btn btn-sm" disabled={task.status === "stopping"} onClick={stop}><Icon name="x" size={16} />Остановить</button>}
|
|
<Link to={`/versions/${task.versionId}/translate?lang=${encodeURIComponent(task.lang)}${task.kind === "strings" ? "&tab=strings" : ""}`}
|
|
className="small">Открыть редактор</Link>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|