diff --git a/backend/app/routers/i18n.py b/backend/app/routers/i18n.py index 01d1fbc..ed410af 100644 --- a/backend/app/routers/i18n.py +++ b/backend/app/routers/i18n.py @@ -18,7 +18,6 @@ router = APIRouter(prefix="/api/versions/{version_id}/i18n", tags=["i18n"]) Player = Annotated[User, Depends(require("games.play"))] Translator = Annotated[User, Depends(require("games.play", "translations.edit"))] -TODO_STATES = {"untranslated", "outdated", "fuzzy", "machine"} LANG_RE = r"^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$" FONT_EXTS = (".ttf", ".otf", ".woff", ".woff2") MAX_FONT_BYTES = 30 * 1024 * 1024 @@ -84,56 +83,12 @@ async def overview(version_id: int, user: Player, db: DB) -> dict[str, Any]: @router.get("/{lang}/entries") async def entries(version_id: int, lang: str, user: Player, db: DB, kind: str = "lines", - state: str = "all", module: str = "", q: str = "", offset: int = 0, - limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]: + state: str = "all", module: str = "", q: str = "", offset: int = Query(0, ge=0), + limit: int = Query(50, ge=1, le=200), locate: str = "") -> dict[str, Any]: v = await ready_version(db, user, version_id) - needle = q.strip().lower() - - def matches(st: str, *texts: str) -> bool: - if state == "todo" and st not in TODO_STATES: - return False - if state not in ("all", "todo") and st != state: - return False - return not needle or any(needle in t.lower() for t in texts if t) - - def build() -> dict[str, Any]: - idx = tr.text_index(v.manifest) - lines, strings, _ = tr.locale_tables(v.manifest, lang) - files = v.manifest["files"] - items: list[dict[str, Any]] = [] - total = 0 - if kind == "strings": - for src, usage in idx.strings.items(): - e = strings.get(src) or {} - st = e.get("state", "translated") if e.get("t") else "untranslated" - if not matches(st, src, e.get("t", "")): - continue - total += 1 - if offset < total <= offset + limit: - items.append({"key": src, "usage": usage, "source": src, - "translation": e.get("t", ""), "state": st}) - else: - for info in idx.lines: - if module and info.module != module: - continue - e = lines.get(info.tid) - st = tr.line_state(e, info) - if not matches(st, info.source, (e or {}).get("t", ""), info.who): - continue - total += 1 - if offset < total <= offset + limit: - scene = files.get(info.scene) if info.scene else None - items.append({ - "key": info.tid, "nodeId": info.node_id, "module": info.module, - "label": info.label, "who": info.who, "source": info.source, - "resource": info.resource, - "prev": info.prev, "next": info.next, - "scene": f"/blobs/{scene['hash']}" if scene else None, - "translation": (e or {}).get("t", ""), "state": st, - }) - return {"total": total, "items": items} - - return await run_in_threadpool(build) + return await run_in_threadpool( + lambda: tr.entries_page(v.manifest, lang, kind=kind, state=state, module=module, q=q, + offset=offset, limit=limit, locate=locate)) @router.get("/{lang}/meta") diff --git a/backend/app/translation.py b/backend/app/translation.py index 9267cce..45efb44 100644 --- a/backend/app/translation.py +++ b/backend/app/translation.py @@ -470,3 +470,65 @@ def parse_csv(text: str) -> tuple[dict[str, Any], dict[str, Any]]: elif row.get("kind") == "line": lines[row["key"]] = value return lines, strings + + +TODO_STATES = {"untranslated", "outdated", "fuzzy", "machine"} + + +def entries_page(manifest: dict[str, Any], lang: str, *, kind: str = "lines", state: str = "all", + module: str = "", q: str = "", offset: int = 0, limit: int = 50, + locate: str = "") -> dict[str, Any]: + """One page of the list the editor shows, in the order the editor shows it. + + ``locate`` is the key of a line: ``located`` is its position in this same filtered list + (``None`` when the line is not in it), so the editor can open the page that holds it. + """ + needle = q.strip().lower() + + def matches(st: str, *texts: str) -> bool: + if state == "todo" and st not in TODO_STATES: + return False + if state not in ("all", "todo") and st != state: + return False + return not needle or any(needle in t.lower() for t in texts if t) + + idx = text_index(manifest) + lines, strings, _ = locale_tables(manifest, lang) + files = manifest["files"] + items: list[dict[str, Any]] = [] + total = 0 + located: int | None = None + if kind == "strings": + for src, usage in idx.strings.items(): + e = strings.get(src) or {} + st = e.get("state", "translated") if e.get("t") else "untranslated" + if not matches(st, src, e.get("t", "")): + continue + total += 1 + if locate and src == locate: + located = total - 1 + if offset < total <= offset + limit: + items.append({"key": src, "usage": usage, "source": src, + "translation": e.get("t", ""), "state": st}) + else: + for info in idx.lines: + if module and info.module != module: + continue + e = lines.get(info.tid) + st = line_state(e, info) + if not matches(st, info.source, (e or {}).get("t", ""), info.who): + continue + total += 1 + if locate and info.tid == locate: + located = total - 1 + if offset < total <= offset + limit: + scene = files.get(info.scene) if info.scene else None + items.append({ + "key": info.tid, "nodeId": info.node_id, "module": info.module, + "label": info.label, "who": info.who, "source": info.source, + "resource": info.resource, + "prev": info.prev, "next": info.next, + "scene": f"/blobs/{scene['hash']}" if scene else None, + "translation": (e or {}).get("t", ""), "state": st, + }) + return {"total": total, "items": items, "located": located} diff --git a/backend/tests/test_translation.py b/backend/tests/test_translation.py index 59e34a0..51b7f97 100644 --- a/backend/tests/test_translation.py +++ b/backend/tests/test_translation.py @@ -80,3 +80,19 @@ def test_po_and_csv_roundtrip(manifest): st = tr.stats(m, "de") assert st["translated"] == 1 and st["stringsTranslated"] == 1 assert json.dumps(st) + + +def test_entries_page_locates_a_line_in_the_filtered_list(manifest): + idx = tr.text_index(manifest) + first, second = idx.lines[0], idx.lines[1] + page = tr.entries_page(manifest, "ru", state="untranslated", limit=1, locate=second.tid) + assert page["located"] == 1 and page["total"] == len(idx.lines) and len(page["items"]) == 1 + + m = tr.apply_changes(manifest, "ru", lines={first.tid: {"t": "Привет", "state": "machine"}}) + # a translated line leaves the "untranslated" list, so the next one moves up + assert tr.entries_page(m, "ru", state="untranslated", locate=second.tid)["located"] == 0 + assert tr.entries_page(m, "ru", state="untranslated", locate=first.tid)["located"] is None + # ...but stays in "todo" because machine translations still await a review + assert tr.entries_page(m, "ru", state="todo", locate=first.tid)["located"] == 0 + assert tr.entries_page(m, "ru", locate="")["located"] is None + assert tr.entries_page(m, "ru", kind="strings", locate="Эйлин")["located"] is not None diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index f17ed2a..9ba873a 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -213,11 +213,22 @@ translate russian strings: `PGames: <игра> (<исходный> → <язык>)`: контекст из описания игры, глоссарий из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и обновляется перед каждым запуском; +* запуск живёт в провайдере выше страниц (`translator/runner.tsx`): перевод идёт, + пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и + кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно + работает только один перевод (на все игры и языки), пока он идёт, кнопки + отдельных строк недоступны; закрытие вкладки браузер подтверждает; * автоперевод идёт по списку так, как его видит пользователь: текущая вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз. Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40; страница уходит одним заданием, готовое сразу сохраняется, появляется в открытых строках и обновляет прогресс в шапке; +* список разбит на страницы (20/40/100/200 строк, номера страниц, переход по + номеру); сервер в `GET …/entries` по параметру `locate=<ключ>` возвращает + `located`, позицию строки в том же отфильтрованном списке. По нему режим + «Следить за переводом» (включён по умолчанию, выключается кнопкой над списком, + флажком в панели или ручным листанием) открывает страницу, на которой модель + работает сейчас, и подсвечивает строки текущей пачки; * у каждой строки есть кнопка «Перевести» (пока переводчик подключён): она переводит одну строку с тем же контекстом и глоссарием и помечает её «Машинный». Редактор ищет переводчик сам, только если он уже работал в этом diff --git a/frontend/public/flags/LICENSE.txt b/frontend/public/flags/LICENSE.txt new file mode 100644 index 0000000..84e028a --- /dev/null +++ b/frontend/public/flags/LICENSE.txt @@ -0,0 +1,22 @@ +(The MIT License) + +Copyright (c) 2020 @catamphetamine + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/frontend/public/flags/al.svg b/frontend/public/flags/al.svg new file mode 100644 index 0000000..d7989d9 --- /dev/null +++ b/frontend/public/flags/al.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/am.svg b/frontend/public/flags/am.svg new file mode 100644 index 0000000..d49087d --- /dev/null +++ b/frontend/public/flags/am.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/az.svg b/frontend/public/flags/az.svg new file mode 100644 index 0000000..486d3b8 --- /dev/null +++ b/frontend/public/flags/az.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ba.svg b/frontend/public/flags/ba.svg new file mode 100644 index 0000000..7ad7d94 --- /dev/null +++ b/frontend/public/flags/ba.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/bd.svg b/frontend/public/flags/bd.svg new file mode 100644 index 0000000..e3c7267 --- /dev/null +++ b/frontend/public/flags/bd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/bg.svg b/frontend/public/flags/bg.svg new file mode 100644 index 0000000..7ebee38 --- /dev/null +++ b/frontend/public/flags/bg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/br.svg b/frontend/public/flags/br.svg new file mode 100644 index 0000000..f4dbb02 --- /dev/null +++ b/frontend/public/flags/br.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/by.svg b/frontend/public/flags/by.svg new file mode 100644 index 0000000..86a423a --- /dev/null +++ b/frontend/public/flags/by.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/cn.svg b/frontend/public/flags/cn.svg new file mode 100644 index 0000000..64629b2 --- /dev/null +++ b/frontend/public/flags/cn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/cz.svg b/frontend/public/flags/cz.svg new file mode 100644 index 0000000..c6ae614 --- /dev/null +++ b/frontend/public/flags/cz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/de.svg b/frontend/public/flags/de.svg new file mode 100644 index 0000000..295d13e --- /dev/null +++ b/frontend/public/flags/de.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/dk.svg b/frontend/public/flags/dk.svg new file mode 100644 index 0000000..1fecf56 --- /dev/null +++ b/frontend/public/flags/dk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ee.svg b/frontend/public/flags/ee.svg new file mode 100644 index 0000000..28a9e16 --- /dev/null +++ b/frontend/public/flags/ee.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/es.svg b/frontend/public/flags/es.svg new file mode 100644 index 0000000..e143324 --- /dev/null +++ b/frontend/public/flags/es.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/fi.svg b/frontend/public/flags/fi.svg new file mode 100644 index 0000000..285363e --- /dev/null +++ b/frontend/public/flags/fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/fr.svg b/frontend/public/flags/fr.svg new file mode 100644 index 0000000..ec311d7 --- /dev/null +++ b/frontend/public/flags/fr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/gb.svg b/frontend/public/flags/gb.svg new file mode 100644 index 0000000..1f18f18 --- /dev/null +++ b/frontend/public/flags/gb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ge.svg b/frontend/public/flags/ge.svg new file mode 100644 index 0000000..d4afde9 --- /dev/null +++ b/frontend/public/flags/ge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/gr.svg b/frontend/public/flags/gr.svg new file mode 100644 index 0000000..0067170 --- /dev/null +++ b/frontend/public/flags/gr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/hr.svg b/frontend/public/flags/hr.svg new file mode 100644 index 0000000..c61e8c8 --- /dev/null +++ b/frontend/public/flags/hr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/hu.svg b/frontend/public/flags/hu.svg new file mode 100644 index 0000000..8d92a84 --- /dev/null +++ b/frontend/public/flags/hu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/id.svg b/frontend/public/flags/id.svg new file mode 100644 index 0000000..2684a3d --- /dev/null +++ b/frontend/public/flags/id.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ie.svg b/frontend/public/flags/ie.svg new file mode 100644 index 0000000..f3f99d9 --- /dev/null +++ b/frontend/public/flags/ie.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/il.svg b/frontend/public/flags/il.svg new file mode 100644 index 0000000..da41218 --- /dev/null +++ b/frontend/public/flags/il.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/in.svg b/frontend/public/flags/in.svg new file mode 100644 index 0000000..9f6e566 --- /dev/null +++ b/frontend/public/flags/in.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ir.svg b/frontend/public/flags/ir.svg new file mode 100644 index 0000000..5f4ba03 --- /dev/null +++ b/frontend/public/flags/ir.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/is.svg b/frontend/public/flags/is.svg new file mode 100644 index 0000000..43cf1d4 --- /dev/null +++ b/frontend/public/flags/is.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/it.svg b/frontend/public/flags/it.svg new file mode 100644 index 0000000..2eb1289 --- /dev/null +++ b/frontend/public/flags/it.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/jp.svg b/frontend/public/flags/jp.svg new file mode 100644 index 0000000..8714514 --- /dev/null +++ b/frontend/public/flags/jp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ke.svg b/frontend/public/flags/ke.svg new file mode 100644 index 0000000..ff76773 --- /dev/null +++ b/frontend/public/flags/ke.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/kr.svg b/frontend/public/flags/kr.svg new file mode 100644 index 0000000..e86b888 --- /dev/null +++ b/frontend/public/flags/kr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/kz.svg b/frontend/public/flags/kz.svg new file mode 100644 index 0000000..bb020c9 --- /dev/null +++ b/frontend/public/flags/kz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/lt.svg b/frontend/public/flags/lt.svg new file mode 100644 index 0000000..47343ca --- /dev/null +++ b/frontend/public/flags/lt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/lu.svg b/frontend/public/flags/lu.svg new file mode 100644 index 0000000..8aa2421 --- /dev/null +++ b/frontend/public/flags/lu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/lv.svg b/frontend/public/flags/lv.svg new file mode 100644 index 0000000..a420dbf --- /dev/null +++ b/frontend/public/flags/lv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/mk.svg b/frontend/public/flags/mk.svg new file mode 100644 index 0000000..d44b445 --- /dev/null +++ b/frontend/public/flags/mk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/mn.svg b/frontend/public/flags/mn.svg new file mode 100644 index 0000000..7cf9781 --- /dev/null +++ b/frontend/public/flags/mn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/mt.svg b/frontend/public/flags/mt.svg new file mode 100644 index 0000000..e144b0c --- /dev/null +++ b/frontend/public/flags/mt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/mx.svg b/frontend/public/flags/mx.svg new file mode 100644 index 0000000..bbdf84d --- /dev/null +++ b/frontend/public/flags/mx.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/my.svg b/frontend/public/flags/my.svg new file mode 100644 index 0000000..38cc877 --- /dev/null +++ b/frontend/public/flags/my.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/nl.svg b/frontend/public/flags/nl.svg new file mode 100644 index 0000000..3c762c3 --- /dev/null +++ b/frontend/public/flags/nl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/no.svg b/frontend/public/flags/no.svg new file mode 100644 index 0000000..e12299e --- /dev/null +++ b/frontend/public/flags/no.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ph.svg b/frontend/public/flags/ph.svg new file mode 100644 index 0000000..0ca2e3f --- /dev/null +++ b/frontend/public/flags/ph.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/pk.svg b/frontend/public/flags/pk.svg new file mode 100644 index 0000000..96d75be --- /dev/null +++ b/frontend/public/flags/pk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/pl.svg b/frontend/public/flags/pl.svg new file mode 100644 index 0000000..e98271f --- /dev/null +++ b/frontend/public/flags/pl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/pt.svg b/frontend/public/flags/pt.svg new file mode 100644 index 0000000..51d7c80 --- /dev/null +++ b/frontend/public/flags/pt.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ro.svg b/frontend/public/flags/ro.svg new file mode 100644 index 0000000..2be793b --- /dev/null +++ b/frontend/public/flags/ro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/rs.svg b/frontend/public/flags/rs.svg new file mode 100644 index 0000000..7410acd --- /dev/null +++ b/frontend/public/flags/rs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ru.svg b/frontend/public/flags/ru.svg new file mode 100644 index 0000000..5e78ce8 --- /dev/null +++ b/frontend/public/flags/ru.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/sa.svg b/frontend/public/flags/sa.svg new file mode 100644 index 0000000..9632754 --- /dev/null +++ b/frontend/public/flags/sa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/se.svg b/frontend/public/flags/se.svg new file mode 100644 index 0000000..c4efc4b --- /dev/null +++ b/frontend/public/flags/se.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/si.svg b/frontend/public/flags/si.svg new file mode 100644 index 0000000..67d17df --- /dev/null +++ b/frontend/public/flags/si.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/sk.svg b/frontend/public/flags/sk.svg new file mode 100644 index 0000000..300f814 --- /dev/null +++ b/frontend/public/flags/sk.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/th.svg b/frontend/public/flags/th.svg new file mode 100644 index 0000000..01b837c --- /dev/null +++ b/frontend/public/flags/th.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/tr.svg b/frontend/public/flags/tr.svg new file mode 100644 index 0000000..1e08e23 --- /dev/null +++ b/frontend/public/flags/tr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/tw.svg b/frontend/public/flags/tw.svg new file mode 100644 index 0000000..c3660f1 --- /dev/null +++ b/frontend/public/flags/tw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/ua.svg b/frontend/public/flags/ua.svg new file mode 100644 index 0000000..8eaa507 --- /dev/null +++ b/frontend/public/flags/ua.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/us.svg b/frontend/public/flags/us.svg new file mode 100644 index 0000000..dc427e7 --- /dev/null +++ b/frontend/public/flags/us.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/uz.svg b/frontend/public/flags/uz.svg new file mode 100644 index 0000000..b83eacb --- /dev/null +++ b/frontend/public/flags/uz.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/vn.svg b/frontend/public/flags/vn.svg new file mode 100644 index 0000000..d52233d --- /dev/null +++ b/frontend/public/flags/vn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/flags/za.svg b/frontend/public/flags/za.svg new file mode 100644 index 0000000..1b294c9 --- /dev/null +++ b/frontend/public/flags/za.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6f73c67..93d732b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ import { Icon } from "./ui/Icon"; import { useSpatialNav } from "./ui/nav"; import { THEME_ICON, THEME_LABEL } from "./ui/ThemeButton"; import { UploadProvider } from "./uploads"; +import { TranslationRunProvider } from "./translator/runner"; function RequirePerm({ perm, children }: { perm: string; children: JSX.Element }) { const { can } = useAuth(); @@ -94,6 +95,7 @@ export function App() { if (me.must_change_password || me.must_change_username) return ; return ( + } /> @@ -140,5 +142,6 @@ export function App() { /> + ); } diff --git a/frontend/src/pages/TranslationEditor.tsx b/frontend/src/pages/TranslationEditor.tsx index ed16a97..879c872 100644 --- a/frontend/src/pages/TranslationEditor.tsx +++ b/frontend/src/pages/TranslationEditor.tsx @@ -3,8 +3,12 @@ import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom" import { api, uploadRaw, type Version } from "../api"; import { EmptyState, ErrorNote, PageHeader, Spinner } from "../ui/Bits"; import { Icon } from "../ui/Icon"; +import { LanguagePicker } from "../ui/LanguagePicker"; +import { Pager } from "../ui/Pager"; +import type { LangOption } from "../ui/language-catalog"; import { useUi } from "../ui/Ui"; import { MachineTranslate, type View } from "../translator/MachineTranslate"; +import { useTranslationRun } from "../translator/runner"; import { useTranslatorSession, type Session } from "../translator/session"; import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts"; import { @@ -13,6 +17,28 @@ import { } from "./translation-types"; import "./translate.css"; +/** The page the automatic run is on: highlighted in the list, and followed when asked. */ +interface Working { + kind: string; + /** lines of the page that are still being translated */ + keys: string[]; + /** the first line of the page; changes when the run moves on */ + cursor: string; + active: boolean; +} +const NOT_WORKING: Working = { kind: "", keys: [], cursor: "", active: false }; +const SIZES = [20, 40, 100, 200]; +const SIZE_KEY = "pg.translation.pageSize"; + +function savedSize(): number { + try { + const n = Number(localStorage.getItem(SIZE_KEY)); + return SIZES.includes(n) ? n : PAGE; + } catch { + return PAGE; + } +} + export function TranslationEditor() { const { versionId } = useParams(); const [params, setParams] = useSearchParams(); @@ -22,6 +48,7 @@ export function TranslationEditor() { const [ov, setOv] = useState(null); const [error, setError] = useState(""); const [machine, setMachine] = useState(false); + const [picking, setPicking] = useState(false); const lang = params.get("lang") ?? ""; const tab = (params.get("tab") as Tab) ?? "lines"; const session = useTranslatorSession(versionId, version, lang, ov); @@ -33,6 +60,15 @@ export function TranslationEditor() { useEffect(() => { fills.current = {}; }, [lang]); + // the lines the automatic run is translating right now; `cursor` is the first of the page + // the run itself lives above the pages (see runner.tsx), so it goes on when this page is left + const run = useTranslationRun(); + const [follow, setFollow] = useState(true); + const working = useMemo(() => { + const t = run.task; + if (!t || t.versionId !== versionId || t.lang !== lang || !run.busy) return NOT_WORKING; + return { kind: t.kind, keys: t.keys, cursor: t.cursor, active: true }; + }, [run.task, run.busy, versionId, 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; @@ -42,6 +78,13 @@ export function TranslationEditor() { setOv((o) => o && { ...o, languages: o.languages.map((l) => l.code === lang ? { ...l, ...s } : l) }); }, [lang]); + // pages the run has saved show up in the open rows at once + useEffect(() => run.subscribe((ev) => { + if (ev.versionId !== versionId || ev.lang !== lang) return; + onFill(ev.kind, ev.results); + onStats(ev.stats); + }), [run, versionId, lang, onFill, onStats]); + const setParam = (key: string, value: string) => { const next = new URLSearchParams(params); if (value) next.set(key, value); @@ -70,30 +113,30 @@ export function TranslationEditor() { const stats = ov?.languages.find((l) => l.code === lang); - const addLanguage = async () => { - const code = await ui.prompt({ - title: "Новый язык", - text: "Код языка в формате BCP-47: ru, en, de, ja, pt-BR, zh-Hans…", - label: "Код языка", confirmLabel: "Добавить", - }); - if (!code) return; - const clean = code.trim(); - if (clean === ov?.sourceLang) { - ui.toast("Это исходный язык игры", "error"); - return; - } - try { - await api.put(`/api/versions/${versionId}/i18n/${encodeURIComponent(clean)}`, { - name: ov?.knownLanguages[clean] ?? null, - }); - await loadOverview(); - setParam("lang", clean); - ui.toast("Язык добавлен", "ok"); - } catch (e) { - ui.toast((e as Error).message, "error"); - } + const addLanguage = (option: LangOption) => { + setPicking(false); + void (async () => { + try { + await api.put(`/api/versions/${versionId}/i18n/${encodeURIComponent(option.code)}`, { + name: ov?.knownLanguages[option.code] ?? option.native, + }); + await loadOverview(); + setParam("lang", option.code); + ui.toast("Язык добавлен", "ok"); + } catch (e) { + ui.toast((e as Error).message, "error"); + } + })(); }; + // languages that cannot be added again, and why + const taken = useMemo(() => { + const out: Record = {}; + for (const l of ov?.languages ?? []) out[l.code] = "уже добавлен"; + if (ov?.sourceLang) out[ov.sourceLang] = "язык игры"; + return out; + }, [ov]); + const publish = async () => { if (!version) return; const ok = await ui.confirm({ @@ -147,6 +190,7 @@ export function TranslationEditor() { const draft = version.visibility === "draft"; return (
+ {picking && setPicking(false)} />} К игре ))} {ov.editable && ( - + )}
{stats && ( @@ -214,14 +258,14 @@ export function TranslationEditor() { {machine && ov.editable && stats && ( - setMachine(false)} /> + setMachine(false)} /> )} {!ov.languages.length ? ( void addLanguage()}>Добавить язык : undefined} /> + action={ov.editable ? : undefined} /> ) : stats ? ( <>
@@ -231,16 +275,18 @@ export function TranslationEditor() { ))}
+ ov={ov} onStats={onStats} session={session} fills={fills.current} fillTick={fillTick} onView={setView} + working={working} runBusy={run.busy} follow={follow} onFollow={setFollow} /> ) : null} ); } -function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTick, onView }: { +function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTick, onView, working, runBusy, follow, onFollow }: { versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial) => void; session: Session; fills: Record>; fillTick: number; onView: (v: View) => void; + working: Working; runBusy: boolean; follow: boolean; onFollow: (on: boolean) => void; }) { const ui = useUi(); const [state, setState] = useState("todo"); @@ -249,13 +295,21 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi const [q, setQ] = useState(""); const [items, setItems] = useState(null); const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [size, setSize] = useState(savedSize); + const [loading, setLoading] = useState(false); + const [reload, setReload] = useState(0); + const top = useRef(null); const [glossary, setGlossary] = useState([]); const [saving, setSaving] = useState<"idle" | "saving" | "saved" | "error">("idle"); const pending = useRef>({}); const timer = useRef(); useEffect(() => { - const t = setTimeout(() => setQ(query), 300); + const t = setTimeout(() => { + setQ(query); + setPage(0); + }, 300); return () => clearTimeout(t); }, [query]); @@ -264,24 +318,34 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi .then((m) => setGlossary(m.glossary ?? [])).catch(() => {}); }, [versionId, lang]); - const fetchPage = useCallback(async (offset: number) => { - const p = new URLSearchParams({ kind: tab, state, module, q, offset: String(offset), limit: String(PAGE) }); - return api.get<{ total: number; items: Item[] }>(`/api/versions/${versionId}/i18n/${lang}/entries?${p}`); + const listUrl = useCallback((offset: number, limit: number, locate = "") => { + const p = new URLSearchParams({ kind: tab, state, module, q, offset: String(offset), limit: String(limit) }); + if (locate) p.set("locate", locate); + return `/api/versions/${versionId}/i18n/${lang}/entries?${p}`; }, [versionId, lang, tab, state, module, q]); useEffect(() => { if (tab === "glossary") return; let cancelled = false; - setItems(null); - fetchPage(0).then((r) => { + setLoading(true); + api.get<{ total: number; items: Item[] }>(listUrl(page * size, size)).then((r) => { if (cancelled) return; + // the list got shorter while we were on a late page (lines were translated): step back + if (!r.items.length && page > 0 && r.total > 0) { + setPage(Math.ceil(r.total / size) - 1); + return; + } setItems(r.items); setTotal(r.total); - }).catch((e) => ui.toast(e.message, "error")); + setLoading(false); + }).catch((e) => { + if (!cancelled) setLoading(false); + ui.toast(e.message, "error"); + }); return () => { cancelled = true; }; - }, [fetchPage, tab, ui]); + }, [listUrl, page, size, reload, tab, ui]); const flush = useCallback(async () => { window.clearTimeout(timer.current); @@ -320,6 +384,46 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi // don't lose edits when leaving the page useEffect(() => () => void flush(), [flush]); + const pages = Math.max(1, Math.ceil(total / size)); + const goPage = (next: number) => { + onFollow(false); + void flush().then(() => { + setPage(next); + top.current?.scrollIntoView({ block: "start", behavior: "smooth" }); + }); + }; + const filtered = (change: () => void) => { + setPage(0); + change(); + }; + + // follow the automatic run: open the page that holds the line it has just started on + const cursor = working.kind === tab ? working.cursor : ""; + useEffect(() => { + if (!follow || !cursor) return; + let stale = false; + void (async () => { + await flush(); + const r = await api.get<{ located: number | null }>(listUrl(0, 1, cursor)); + if (stale || r.located === null) return; + const target = Math.floor(r.located / size); + if (target !== page) setPage(target); + // lines that were translated have left this list: it has to be read again + else if (state === "untranslated" || state === "outdated") setReload((t) => t + 1); + })().catch(() => {}); + return () => { + stale = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [follow, cursor]); + + const lit = useMemo(() => new Set(working.kind === tab ? working.keys : []), [working, tab]); + useEffect(() => { + if (!follow || !cursor || loading) return; + document.querySelector(".tr-row.working")?.scrollIntoView({ block: "center", behavior: "smooth" }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cursor, follow, page, loading]); + const update = (key: string, t: string, nextState?: LineState) => { setItems((list) => list?.map((it) => it.key === key ? { ...it, translation: t, @@ -331,11 +435,6 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi timer.current = window.setTimeout(() => void flush(), 900); }; - const more = async () => { - const r = await fetchPage(items?.length ?? 0); - setItems((list) => [...(list ?? []), ...r.items]); - }; - const terms = useMemo(() => glossary.filter((g) => g.src.trim()), [glossary]); if (tab === "glossary") { @@ -351,7 +450,7 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi onChange={(e) => setQuery(e.target.value)} aria-label="Поиск" /> {tab === "lines" && ov.modules.length > 1 && ( - filtered(() => setModule(e.target.value))} aria-label="Файл скрипта"> {ov.modules.map((m) => )} @@ -359,10 +458,16 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi {saving === "saving" ? "Сохранение…" : saving === "saved" ? "Сохранено" : saving === "error" ? "Ошибка сохранения" : ""} + {working.active && working.kind === tab && ( + + )}
{FILTERS.map(([v, label]) => ( - + ))}
@@ -370,11 +475,28 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi ) : ( <> -
Найдено: {total}
-
+
+ + Найдено: {total} · показаны {page * size + 1}–{Math.min(total, page * size + items.length)} + + + +
+
{items.map((it, i) => ( update(it.key, t, s)} onBlur={() => void flush()} onNext={() => { void flush(); @@ -383,19 +505,19 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi }} /> ))}
- {items.length < total && ( -
- -
- )} +
)}
); } -function Row({ item, versionId, lang, editable, canApprove, terms, machine, onChange, onBlur, onNext }: { +function Row({ item, versionId, lang, editable, canApprove, terms, machine, blocked, working, onChange, onBlur, onNext }: { item: Item; versionId: string; lang: string; editable: boolean; canApprove: boolean; terms: GlossaryTerm[]; + /** the automatic run is translating this line now */ + working: boolean; + /** an automatic run is going: one line would only queue behind it */ + blocked: boolean; /** 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; @@ -425,7 +547,7 @@ function Row({ item, versionId, lang, editable, canApprove, terms, machine, onCh }, [item.translation]); return ( -
+
{item.who ? {item.who} : item.usage ? {USAGE[item.usage] ?? item.usage} : рассказчик} @@ -469,8 +591,8 @@ function Row({ item, versionId, lang, editable, canApprove, terms, machine, onCh {editable && (
{machine && ( - )} diff --git a/frontend/src/pages/translate.css b/frontend/src/pages/translate.css index 55504e4..9017572 100644 --- a/frontend/src/pages/translate.css +++ b/frontend/src/pages/translate.css @@ -77,3 +77,53 @@ .tr-context { flex-direction: column; } .tr-context img { width: 100%; } } + +/* Language picker */ +.lp-modal { width: min(32rem, 100%); display: flex; flex-direction: column; gap: var(--space-3); } +.lp-modal h2 { margin: 0; } +.lp-list { + list-style: none; + margin: 0; + padding: 0.25rem; + max-height: min(22rem, 50vh); + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius-s); + background: var(--surface-2); +} +.lp-option { display: flex; align-items: center; gap: var(--space-3); padding: 0.45rem 0.6rem; border-radius: 6px; cursor: pointer; } +.lp-option.on { background: var(--accent-soft); } +.lp-option.taken { opacity: 0.5; cursor: not-allowed; } +.lp-names { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.lp-flag { width: 24px; height: 16px; object-fit: cover; border-radius: 2px; box-shadow: 0 0 0 1px var(--border); flex-shrink: 0; } +.lp-flag-none { display: inline-grid; place-items: center; box-shadow: none; color: var(--muted); } +.lp-empty { padding: var(--space-3); } + +/* Paging and following the automatic run */ +.tr-listbar { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; margin-bottom: var(--space-3); } +.tr-listbar .tr-size { display: inline-flex; align-items: center; gap: 0.4rem; } +.tr-listbar select { height: 2.2rem; } +.tr-list.loading { opacity: 0.55; transition: opacity 0.15s; pointer-events: none; } +.tr-pager-bottom { display: flex; justify-content: center; margin-top: var(--space-5); } +.pager { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; justify-content: center; } +.pager-pages { display: flex; align-items: center; gap: 0.25rem; } +.pager-page { + min-width: 2.2rem; + height: 2.2rem; + padding: 0 0.5rem; + border: 1px solid transparent; + border-radius: var(--radius-s); + background: transparent; + color: var(--text-2); + font: inherit; + cursor: pointer; +} +.pager-page:hover:not(:disabled) { background: var(--surface-2); color: var(--text); } +.pager-page.on { background: var(--accent-soft); border-color: var(--accent); color: var(--text); font-weight: 600; } +.pager-gap { padding: 0 0.2rem; color: var(--muted); } +.pager-step { min-width: 2.2rem; justify-content: center; } +.pager-jump { display: inline-flex; align-items: center; gap: 0.4rem; margin-left: var(--space-2); } +.pager-jump input { width: 4.5rem; height: 2.2rem; text-align: center; } +.tr-row.working { border-left-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); animation: tr-working 1.6s ease-in-out infinite; } +@keyframes tr-working { 50% { box-shadow: 0 0 0 3px var(--accent-soft); } } +@media (prefers-reduced-motion: reduce) { .tr-row.working { animation: none; } } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 96ddb21..53eb3ae 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -678,6 +678,7 @@ input[type="file"] { padding: 0.4rem; } .upload-task-heading b { overflow-wrap: anywhere; min-width: 0; } .upload-floating { position: fixed; right: var(--space-4); bottom: var(--space-4); z-index: 50; width: min(360px, calc(100vw - 2rem)); padding: var(--space-4); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-2); max-height: calc(100dvh - var(--nav-h) - 2rem); overflow-y: auto; } .upload-floating .upload-progress { padding: 0; border: 0; box-shadow: none; } +.upload-floating > * + * { margin-top: var(--space-3); } @media (max-width: 700px) { .upload-floating { bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom) + 1rem); max-height: calc(100dvh - var(--nav-h) - var(--tabbar-h) - env(safe-area-inset-bottom) - 2rem); } } diff --git a/frontend/src/translator/MachineTranslate.tsx b/frontend/src/translator/MachineTranslate.tsx index f47739b..a875591 100644 --- a/frontend/src/translator/MachineTranslate.tsx +++ b/frontend/src/translator/MachineTranslate.tsx @@ -1,15 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } 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 { runTranslation, type Progress, type Source } from "./run"; +import { statusText, useTranslationRun, type RunState } from "./runner"; import type { Session } from "./session"; import "./translator.css"; -const PAGE = 40; - /** What the list below shows right now: the run walks it in the same order. */ export interface View { kind: "lines" | "strings" | "glossary"; @@ -17,23 +14,26 @@ export interface View { q: string; } -type State = "untranslated" | "outdated"; - /** Machine translation of the list on screen through the translator running on this computer. */ -export function MachineTranslate({ session, versionId, lang, view, onFill, onStats, onClose }: { - session: Session; versionId: string; lang: string; view: View; - /** a page of translations has just been saved: show them in the rows */ - onFill: (kind: "lines" | "strings", results: { key: string; text: string }[]) => void; - onStats: (stats: Partial) => void; +export function MachineTranslate({ session, versionId, lang, title, view, follow, onFollow, onClose }: { + session: Session; versionId: string; lang: string; + /** the game and version, for the card in the floating window */ + title: string; + view: View; + /** the list jumps to the page being translated */ + follow: boolean; + onFollow: (on: boolean) => void; onClose: () => void; }) { const { client, link, ready, prefs, setPref } = session; + const run = useTranslationRun(); + const task = run.task; + const mine = !!task && task.versionId === versionId && task.lang === lang; + const running = run.busy && mine; + const elsewhere = run.busy && !mine; const [todo, setTodo] = useState(null); - const [running, setRunning] = useState(false); - const [progress, setProgress] = useState(null); + const [preparing, setPreparing] = useState(false); const [error, setError] = useState(""); - const [summary, setSummary] = useState(""); - const abort = useRef(null); useEffect(() => { if (link === "idle") void session.connect(); @@ -41,84 +41,58 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta }, []); const listable = view.kind !== "glossary"; - const states = useMemo(() => (prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]), [prefs.outdated]); + const states = useMemo(() => (prefs.outdated ? ["untranslated", "outdated"] : ["untranslated"]), [prefs.outdated]); - const entriesUrl = useCallback((state: State, offset: number, limit: number) => + const countUrl = useCallback((state: RunState) => `/api/versions/${versionId}/i18n/${lang}/entries?${new URLSearchParams({ - kind: view.kind, state, module: view.module, q: view.q, offset: String(offset), limit: String(limit) })}`, + kind: view.kind, state, module: view.module, q: view.q, offset: "0", limit: "1" })}`, [versionId, lang, view.kind, view.module, view.q]); // how much there is to translate with the current list and options useEffect(() => { - if (running || !listable) return; + if (run.busy || !listable) return; let stale = false; - Promise.all(states.map((s) => api.get<{ total: number }>(entriesUrl(s, 0, 1)))) + Promise.all(states.map((s) => api.get<{ total: number }>(countUrl(s)))) .then((r) => !stale && setTodo(r.reduce((n, x) => n + x.total, 0))).catch(() => !stale && setTodo(null)); return () => { stale = true; }; - }, [states, entriesUrl, running, listable, summary]); - - useEffect(() => () => abort.current?.abort(), []); + }, [states, countUrl, run.busy, listable, task?.status]); const start = async () => { if (view.kind === "glossary") return; - const kind = view.kind; setError(""); - setSummary(""); - setProgress(null); - const ctl = new AbortController(); - abort.current = ctl; - setRunning(true); - const base = { total: 0, done: 0, failed: 0 }; - let last: Progress = { ...base, lastError: "", model: null }; + setPreparing(true); try { const projectId = await session.project(); - for (const state of states) { - const outcome = await runTranslation({ - client, projectId, batchSize: prefs.batch, pageSize: PAGE, signal: ctl.signal, - fetchPage: (skip, limit) => api.get<{ total: number; items: Source[] }>(entriesUrl(state, skip, limit)), - save: async (results) => { - const changes = Object.fromEntries(results.map((r) => [r.key, { t: r.text, state: "machine" }])); - const stats = await api.put>(`/api/versions/${versionId}/i18n/${lang}`, - kind === "strings" ? { strings: changes } : { lines: changes }); - onFill(kind, results); - onStats(stats); - }, - onProgress: (p) => { - last = { ...p, total: base.total + p.total, done: base.done + p.done, failed: base.failed + p.failed }; - setProgress(last); - }, - }); - Object.assign(base, { total: last.total, done: last.done, failed: last.failed }); - if (outcome === "stopped") break; - } - setSummary(ctl.signal.aborted ? `Остановлено. Переведено строк: ${last.done}.` - : `Готово. Переведено строк: ${last.done}${last.failed ? `, не удалось: ${last.failed}` : ""}.`); + const started = run.start({ + client, projectId, versionId, lang, title, kind: view.kind, module: view.module, q: view.q, + states, batch: prefs.batch, + }); + if (!started) setError("Другой перевод уже идёт: дождитесь его или остановите."); } catch (e) { setError((e as Error).message); } finally { - setRunning(false); - abort.current = null; + setPreparing(false); } }; - const stop = () => abort.current?.abort(); - const health = link !== "idle" && link !== "checking" && link.state === "ok" ? link.health : null; - const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0; const scope = view.kind === "strings" ? "имена, меню и интерфейс" : "реплики"; + const progress = mine && task ? task.progress : null; + const percent = progress && progress.total ? Math.min(100, ((progress.done + progress.failed) / progress.total) * 100) : 0; + const locked = running || elsewhere; return (

Машинный перевод

- +

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

{(link === "idle" || link === "checking") &&

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

} @@ -139,18 +113,27 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta )} {health?.startup_errors[prefs.provider] && {health.startup_errors[prefs.provider]}} + {elsewhere && task && ( +
+ + Сейчас идёт другой перевод: «{task.title}» ({task.lang}). Одновременно работает только один. + Дождитесь его окончания или остановите.{" "} + +
+ )} + {ready && ( <>
@@ -169,16 +152,18 @@ export function MachineTranslate({ session, versionId, lang, view, onFill, onSta