From 5f6bd4508ea8c17f18f22d94b17ef5cc8dd8632a Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:11:27 +0300 Subject: [PATCH] feat: per-line translate buttons, auto-translate follows the visible list and fills rows live Shared translator session, game description/genres as optional context, live header progress. Co-Authored-By: Claude Sonnet 5.5 --- docs/LOCALIZATION.md | 13 +- frontend/src/pages/TranslationEditor.tsx | 72 ++++++- frontend/src/translator/MachineTranslate.tsx | 215 ++++++------------- frontend/src/translator/TranslatorSetup.tsx | 4 +- frontend/src/translator/run.ts | 7 +- frontend/src/translator/session.ts | 162 ++++++++++++++ 6 files changed, 316 insertions(+), 157 deletions(-) create mode 100644 frontend/src/translator/session.ts diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index 686d3b5..f17ed2a 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -213,8 +213,17 @@ translate russian strings: `PGames: <игра> (<исходный> → <язык>)`: контекст из описания игры, глоссарий из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и обновляется перед каждым запуском; -* строки берутся страницами по 40 с фильтром «не переведено» (и «устарело» по - желанию), страница уходит одним заданием, готовое сразу сохраняется. Остановка +* автоперевод идёт по списку так, как его видит пользователь: текущая + вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз. + Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40; + страница уходит одним заданием, готовое сразу сохраняется, появляется в + открытых строках и обновляет прогресс в шапке; +* у каждой строки есть кнопка «Перевести» (пока переводчик подключён): она + переводит одну строку с тем же контекстом и глоссарием и помечает её + «Машинный». Редактор ищет переводчик сам, только если он уже работал в этом + браузере, чтобы Chrome не спрашивал про локальную сеть без причины; +* флажок «Передавать описание и жанры игры как контекст» (по умолчанию включён) + добавляет описание и теги игры в контекст проекта; название игры уходит всегда; Остановка отменяет задание в переводчике; повторный запуск продолжает с оставшегося. Непереведённые из-за ошибки строки (например, модель повредила плейсхолдер) пропускаются и видны в счётчике ошибок; diff --git a/frontend/src/pages/TranslationEditor.tsx b/frontend/src/pages/TranslationEditor.tsx index 6448ae9..ed16a97 100644 --- a/frontend/src/pages/TranslationEditor.tsx +++ b/frontend/src/pages/TranslationEditor.tsx @@ -4,7 +4,8 @@ import { api, uploadRaw, type Version } from "../api"; import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits"; import { Icon } from "../ui/Icon"; import { useUi } from "../ui/Ui"; -import { MachineTranslate } from "../translator/MachineTranslate"; +import { MachineTranslate, type View } from "../translator/MachineTranslate"; +import { useTranslatorSession, type Session } from "../translator/session"; import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts"; import { FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE, @@ -23,6 +24,23 @@ export function TranslationEditor() { const [machine, setMachine] = useState(false); const lang = params.get("lang") ?? ""; const tab = (params.get("tab") as Tab) ?? "lines"; + const session = useTranslatorSession(versionId, version, lang, ov); + // what the list shows now, so the automatic run goes through it in the same order + const [view, setView] = useState({ kind: "lines", module: "", q: "" }); + // lines translated by the automatic run, shown in the rows without reloading the list + const fills = useRef>>({}); + const [fillTick, setFillTick] = useState(0); + useEffect(() => { + fills.current = {}; + }, [lang]); + const onFill = useCallback((kind: string, results: { key: string; text: string }[]) => { + const map = (fills.current[kind] ??= {}); + for (const r of results) map[r.key] = r.text; + setFillTick((t) => t + 1); + }, []); + const onStats = useCallback((s: Partial) => { + setOv((o) => o && { ...o, languages: o.languages.map((l) => l.code === lang ? { ...l, ...s } : l) }); + }, [lang]); const setParam = (key: string, value: string) => { const next = new URLSearchParams(params); @@ -196,9 +214,8 @@ export function TranslationEditor() { {machine && ov.editable && stats && ( - setMachine(false)} - onChanged={() => void loadOverview().then(() => setParam("r", String(Date.now())))} /> + setMachine(false)} /> )} {!ov.languages.length ? ( @@ -214,15 +231,16 @@ export function TranslationEditor() { ))} setOv((o) => o && { ...o, languages: o.languages.map((l) => l.code === lang ? { ...l, ...s } : l) })} /> + ov={ov} onStats={onStats} session={session} fills={fills.current} fillTick={fillTick} onView={setView} /> ) : null} ); } -function EntriesView({ versionId, lang, tab, ov, onStats }: { +function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTick, onView }: { versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial) => void; + session: Session; fills: Record>; fillTick: number; onView: (v: View) => void; }) { const ui = useUi(); const [state, setState] = useState("todo"); @@ -283,6 +301,22 @@ function EntriesView({ versionId, lang, tab, ov, onStats }: { } }, [versionId, lang, tab, onStats, ui]); + useEffect(() => { + onView({ kind: tab, module, q }); + }, [onView, tab, module, q]); + + // show what the automatic run has translated, but never over a line the user has just edited + useEffect(() => { + const done = fills[tab]; + if (!done) return; + setItems((list) => list?.map((it) => { + const t = done[it.key]; + return t !== undefined && (it.state === "untranslated" || it.state === "outdated") && it.translation !== t + ? { ...it, translation: t, state: "machine" as LineState } : it; + }) ?? null); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fillTick]); + // don't lose edits when leaving the page useEffect(() => () => void flush(), [flush]); @@ -340,7 +374,7 @@ function EntriesView({ versionId, lang, tab, ov, onStats }: {
{items.map((it, i) => ( update(it.key, t, s)} onBlur={() => void flush()} onNext={() => { void flush(); @@ -360,13 +394,29 @@ function EntriesView({ versionId, lang, tab, ov, onStats }: { ); } -function Row({ item, versionId, lang, editable, canApprove, terms, onChange, onBlur, onNext }: { +function Row({ item, versionId, lang, editable, canApprove, terms, machine, onChange, onBlur, onNext }: { item: Item; versionId: string; lang: string; editable: boolean; canApprove: boolean; terms: GlossaryTerm[]; + /** the connected translator: when set, the line can be translated by itself */ + machine: Session | null; onChange: (t: string, state?: LineState) => void; onBlur: () => void; onNext: () => void; }) { + const ui = useUi(); const [context, setContext] = useState(false); + const [busy, setBusy] = useState(false); const ref = useRef(null); + const translateHere = async () => { + if (!machine) return; + setBusy(true); + try { + onChange(await machine.translateOne(item.source), "machine"); + } catch (e) { + ui.toast((e as Error).message, "error"); + } finally { + setBusy(false); + } + }; + useEffect(() => { const el = ref.current; if (!el) return; @@ -418,6 +468,12 @@ function Row({ item, versionId, lang, editable, canApprove, terms, onChange, onB }} /> {editable && (
+ {machine && ( + + )} {!item.translation && (

Перевод выполняет программа на вашем компьютере, сайт только получает готовые строки с пометкой - «Машинный». Как установить и настроить + «Машинный». Как установить и настроить. Отдельную строку можно перевести + кнопкой «Перевести» рядом с ней.

- {link === "checking" &&

Ищу переводчик на {client.base}…

} - {link !== "checking" && link.state === "offline" && ( + {(link === "idle" || link === "checking") &&

Ищу переводчик на {client.base}…

} + {typeof link === "object" && link.state === "offline" && ( Переводчик не отвечает на {client.base}. Запустите его (инструкция) и нажмите «Проверить снова». )} - {link !== "checking" && link.state === "blocked" && ( + {typeof link === "object" && link.state === "blocked" && ( Переводчик запущен, но браузер не пускает этот сайт к нему. Добавьте {location.origin} в - allowed_origins в config.toml и перезапустите переводчик (подробнее). + allowed_origins в config.toml (в начале файла, выше первой секции) и перезапустите переводчик + (подробнее). )} - {link !== "checking" && link.state === "old" && ( + {typeof link === "object" && link.state === "old" && ( Версия переводчика слишком старая: обновите репозиторий local-translator и перезапустите его. )} - {link !== "checking" && link.state !== "ok" && ( -
+ {typeof link === "object" && link.state !== "ok" && ( +
)} {health?.startup_errors[prefs.provider] && {health.startup_errors[prefs.provider]}} @@ -225,17 +145,17 @@ export function MachineTranslate({ versionId, version, lang, ov, onClose, onChan @@ -249,28 +169,35 @@ export function MachineTranslate({ versionId, version, lang, ov, onClose, onChan