feat: machine translation resumes after a page reload
CI / frontend (push) Failing after 14s
CI / backend (push) Failing after 49s

The run is recorded at every page and picked up from the translator's finished job; one run across tabs via Web Locks.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 20:39:37 +03:00
1 parent 7fa88e4025
commit 68b66a1d94
5 files changed
+200 -30

No files matched your search

+8 -2
View File
@@ -216,8 +216,14 @@ translate russian strings:
* запуск живёт в провайдере выше страниц (`translator/runner.tsx`): перевод идёт,
пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и
кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно
работает только один перевод (на все игры и языки), пока он идёт, кнопки
отдельных строк недоступны; закрытие вкладки браузер подтверждает;
работает только один перевод (на все игры и языки; между вкладками это
держит Web Lock), пока он идёт, кнопки
отдельных строк недоступны; закрытие вкладки браузер подтверждает. На каждой странице запуск
записывается в `localStorage` (`pg.translation.run`: что переводится, где
остановились, какое задание отправлено в переводчик). После обновления страницы
сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт
дальше, а карточка и бейджи снова обновляются. Запись старше 30 минут не
подхватывается;
* автоперевод идёт по списку так, как его видит пользователь: текущая
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
+2 -2
View File
@@ -65,11 +65,11 @@ export function MachineTranslate({ session, versionId, lang, title, view, follow
setPreparing(true);
try {
const projectId = await session.project();
const started = run.start({
const started = await run.start({
client, projectId, versionId, lang, title, kind: view.kind, module: view.module, q: view.q,
states, batch: prefs.batch,
});
if (!started) setError("Другой перевод уже идёт: дождитесь его или остановите.");
if (!started) setError("Другой перевод уже идёт (возможно, в другой вкладке): дождитесь его или остановите.");
} catch (e) {
setError((e as Error).message);
} finally {
+48 -13
View File
@@ -23,6 +23,21 @@ export interface RunOptions {
onPage?: (items: Source[]) => void;
onProgress: (p: Progress) => void;
pollMs?: number;
/** where an interrupted run stood: the counters and how many lines to skip */
initial?: { total: number; done: number; failed: number; skip: number };
/** the job that was being translated when the run was interrupted, and the lines it carries */
resume?: { jobId: string; keys: string[] };
/** a page was handed to the translator (`jobId`) or fully handled (`null`): enough to continue from here */
onCheckpoint?: (cp: Checkpoint) => void;
}
export interface Checkpoint {
jobId: string | null;
keys: string[];
skip: number;
total: number;
done: number;
failed: number;
}
export interface Progress {
@@ -70,23 +85,42 @@ export async function waitForJob(client: Pick<TranslatorClient, "job" | "cancel"
/** Throws when the translator becomes unreachable or a save fails. */
export async function runTranslation(opts: RunOptions): Promise<Outcome> {
const progress: Progress = { total: 0, done: 0, failed: 0, lastError: "", model: null };
const start = opts.initial;
const progress: Progress = { total: start?.total ?? 0, done: start?.done ?? 0, failed: start?.failed ?? 0, lastError: "", model: null };
const emit = () => opts.onProgress({ ...progress });
let skip = 0;
let skip = start?.skip ?? 0;
let resume = opts.resume;
const checkpoint = (jobId: string | null, keys: string[]) =>
opts.onCheckpoint?.({ jobId, keys, skip, total: progress.total, done: progress.done, failed: progress.failed });
while (!opts.signal.aborted) {
const page = await opts.fetchPage(skip, opts.pageSize);
if (opts.signal.aborted || !page.items.length) break;
// `page.total` still counts the lines that failed earlier, so it is the whole remaining work
progress.total = progress.done + page.total;
const items = page.items.filter((it) => it.source.trim());
// lines that stay untranslated keep matching the filter, so the next page starts after them
skip += page.items.length - items.length;
if (!items.length) continue;
let items: Source[];
let job: Job;
if (resume) {
// the page was already sent before the page was reloaded: pick its result up
const taken = resume;
resume = undefined;
const known = await opts.client.job(taken.jobId, opts.signal).catch(() => null);
// the translator has forgotten it: the lines are still untranslated and come with the next page
if (!known) continue;
job = known;
items = taken.keys.map((key, i) => ({ key, source: job.items[i]?.source ?? "" }));
opts.onPage?.(items);
} else {
const page = await opts.fetchPage(skip, opts.pageSize);
if (opts.signal.aborted || !page.items.length) break;
// `page.total` still counts the lines that failed earlier, so it is the whole remaining work
progress.total = progress.done + page.total;
items = page.items.filter((it) => it.source.trim());
// lines that stay untranslated keep matching the filter, so the next page starts after them
skip += page.items.length - items.length;
if (!items.length) continue;
opts.onPage?.(items);
let job = await opts.client.createJob(
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
opts.onPage?.(items);
job = await opts.client.createJob(
opts.projectId, items.map((it, i) => ({ id: String(i), text: it.source })), opts.batchSize);
checkpoint(job.id, items.map((it) => it.key));
}
job = await waitForJob(opts.client, job, opts.signal, opts.pollMs);
progress.model = job.model ?? progress.model;
@@ -101,6 +135,7 @@ export async function runTranslation(opts: RunOptions): Promise<Outcome> {
if (job.status !== "cancelled") progress.failed += items.length - ready.length;
skip += items.length - ready.length;
emit();
checkpoint(null, []);
if (job.status === "failed") throw new Error(job.error || "Переводчик не смог выполнить задание");
if (job.status === "cancelled") break;
}
+106 -13
View File
@@ -1,5 +1,6 @@
// 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";
@@ -7,15 +8,18 @@ import { api } from "../api";
import type { LangStats } from "../pages/translation-types";
import { ErrorNote } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import type { TranslatorClient } from "./client";
import { runTranslation, type Progress, type Source } from "./run";
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";
export interface RunSpec {
client: TranslatorClient;
interface RunInfo {
projectId: string;
versionId: string;
lang: string;
@@ -29,6 +33,22 @@ export interface RunSpec {
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;
@@ -57,7 +77,8 @@ interface RunApi {
task: TranslationTask | null;
/** a run is going: no other can start */
busy: boolean;
start: (spec: RunSpec) => 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;
@@ -66,6 +87,39 @@ interface RunApi {
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);
@@ -77,27 +131,42 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
setTask((t) => (t && t.id === id ? { ...t, ...(typeof change === "function" ? change(t) : change) } : t));
}, []);
const start = useCallback((spec: RunSpec) => {
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, keys: [], cursor: "", error: "" });
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) })}`;
void (async () => {
const base = { total: 0, done: 0, failed: 0 };
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 (const state of spec.states) {
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" }]));
@@ -121,13 +190,37 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
} catch (e) {
patch(id, { status: "failed", error: (e as Error).message, keys: [] });
} finally {
store(null);
busyRef.current = false;
control.current = null;
}
})();
return true;
};
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));
@@ -142,7 +235,7 @@ export function TranslationRunProvider({ children }: { children: ReactNode }) {
const busy = task?.status === "running" || task?.status === "stopping";
// closing the tab would end the run: ask first
// A reload is survivable now, but it still pauses the run: say so
useEffect(() => {
if (!busy) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
+36
View File
@@ -138,3 +138,39 @@ test("addresses are completed and cleaned", () => {
assert.equal(normalizeUrl(" 127.0.0.1:8787/ "), "http://127.0.0.1:8787");
assert.equal(normalizeUrl("https://t.example//"), "https://t.example");
});
test("a run that was interrupted picks up the page the translator finished meanwhile", async () => {
const server = fakeServer(["a", "b", "c", "d", "e"]);
const translator = fakeTranslator();
// the page "a","b" was sent before the reload; the translator completed it by itself
const sent = await translator.createJob("p", [{ id: "0", text: "a" }, { id: "1", text: "b" }]);
const checkpoints = [];
const outcome = await run(translator, server, {
initial: { total: 5, done: 0, failed: 0, skip: 0 },
resume: { jobId: sent.id, keys: ["k0", "k1"] },
onCheckpoint: (c) => checkpoints.push(c),
});
assert.equal(outcome, "finished");
assert.deepEqual(Object.keys(server.saved), ["k0", "k1", "k2", "k3", "k4"]);
// the first page was not sent twice: the resumed job plus two new pages
assert.equal(translator.created.length, 3);
const last = checkpoints.at(-1);
assert.deepEqual([last.jobId, last.done, last.failed], [null, 5, 0]);
});
test("a job the translator no longer knows is skipped and its lines come back with the list", async () => {
const server = fakeServer(["a", "b", "c"]);
const translator = { ...fakeTranslator(), async job() { throw new Error("404"); } };
// `job` is also used while waiting, but the resumed job fails at once, so nothing waits
const created = [];
const client = { ...translator, async createJob(...args) { const j = await fakeTranslator().createJob(...args); created.push(j); return j; } };
const outcome = await run(client, server, { pageSize: 3, resume: { jobId: "gone", keys: ["k0"] } });
assert.equal(outcome, "finished");
assert.deepEqual(Object.keys(server.saved), ["k0", "k1", "k2"]);
});
test("checkpoints say which page is out and when it is handled", async () => {
const marks = [];
await run(fakeTranslator(), fakeServer(["a", "b", "c"]), { pageSize: 2, onCheckpoint: (c) => marks.push([c.jobId ? "sent" : "done", c.keys.length, c.skip]) });
assert.deepEqual(marks, [["sent", 2, 0], ["done", 0, 0], ["sent", 1, 0], ["done", 0, 0]]);
});