feat: machine translation resumes after a page reload
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:
1 parent
7fa88e4025
commit
68b66a1d94
5 files changed
+191
-21
No files matched your search
@@ -216,8 +216,14 @@ translate russian strings:
|
||||
* запуск живёт в провайдере выше страниц (`translator/runner.tsx`): перевод идёт,
|
||||
пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и
|
||||
кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно
|
||||
работает только один перевод (на все игры и языки), пока он идёт, кнопки
|
||||
отдельных строк недоступны; закрытие вкладки браузер подтверждает;
|
||||
работает только один перевод (на все игры и языки; между вкладками это
|
||||
держит Web Lock), пока он идёт, кнопки
|
||||
отдельных строк недоступны; закрытие вкладки браузер подтверждает. На каждой странице запуск
|
||||
записывается в `localStorage` (`pg.translation.run`: что переводится, где
|
||||
остановились, какое задание отправлено в переводчик). После обновления страницы
|
||||
сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт
|
||||
дальше, а карточка и бейджи снова обновляются. Запись старше 30 минут не
|
||||
подхватывается;
|
||||
* автоперевод идёт по списку так, как его видит пользователь: текущая
|
||||
вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз.
|
||||
Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
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;
|
||||
const items = page.items.filter((it) => it.source.trim());
|
||||
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(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]]);
|
||||
});
|
||||
Reference in new issue
Block a user