From 68b66a1d94839fa4f860c43ebde3fdcef02c9423 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:39:37 +0300 Subject: [PATCH] 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 --- docs/LOCALIZATION.md | 10 +- frontend/src/translator/MachineTranslate.tsx | 4 +- frontend/src/translator/run.ts | 61 ++++++++-- frontend/src/translator/runner.tsx | 119 +++++++++++++++++-- frontend/tests/translator-run.test.mjs | 36 ++++++ 5 files changed, 200 insertions(+), 30 deletions(-) diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index 9ba873a..e8fb494 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -216,8 +216,14 @@ translate russian strings: * запуск живёт в провайдере выше страниц (`translator/runner.tsx`): перевод идёт, пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно - работает только один перевод (на все игры и языки), пока он идёт, кнопки - отдельных строк недоступны; закрытие вкладки браузер подтверждает; + работает только один перевод (на все игры и языки; между вкладками это + держит Web Lock), пока он идёт, кнопки + отдельных строк недоступны; закрытие вкладки браузер подтверждает. На каждой странице запуск + записывается в `localStorage` (`pg.translation.run`: что переводится, где + остановились, какое задание отправлено в переводчик). После обновления страницы + сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт + дальше, а карточка и бейджи снова обновляются. Запись старше 30 минут не + подхватывается; * автоперевод идёт по списку так, как его видит пользователь: текущая вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз. Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40; diff --git a/frontend/src/translator/MachineTranslate.tsx b/frontend/src/translator/MachineTranslate.tsx index a875591..630bc60 100644 --- a/frontend/src/translator/MachineTranslate.tsx +++ b/frontend/src/translator/MachineTranslate.tsx @@ -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 { diff --git a/frontend/src/translator/run.ts b/frontend/src/translator/run.ts index fde2c6c..ca30703 100644 --- a/frontend/src/translator/run.ts +++ b/frontend/src/translator/run.ts @@ -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 { - 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 { 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; } diff --git a/frontend/src/translator/runner.tsx b/frontend/src/translator/runner.tsx index 2579361..610af42 100644 --- a/frontend/src/translator/runner.tsx +++ b/frontend/src/translator/runner.tsx @@ -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; stop: () => void; dismiss: () => void; subscribe: (listener: (event: FillEvent) => void) => () => void; @@ -66,6 +87,39 @@ interface RunApi { const RunContext = createContext(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): Promise { + 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(null); const control = useRef(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 => { 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 | 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(); diff --git a/frontend/tests/translator-run.test.mjs b/frontend/tests/translator-run.test.mjs index 43e130d..7ddd552 100644 --- a/frontend/tests/translator-run.test.mjs +++ b/frontend/tests/translator-run.test.mjs @@ -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]]); +});