From bbbcc6a5d857ba3a05b9e9fe006ea9c93d57708e Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:23:22 +0300 Subject: [PATCH] chore: add CI, lint config, login rate limit and split large modules - Rate-limit failed logins (8 per 15 min per IP or username, 429 + Retry-After) - Add ruff/mypy config and GitHub Actions workflow (ruff, pytest, tsc, tests, build) - Apply ruff autofixes (import order, pyupgrade) - Extract rpy_helpers.py, engine-util.ts, TranslationParts.tsx and translation-types.ts from oversized modules - Add dependency-free React UI tests - Refresh README and ARCHITECTURE (Naninovel/Tomie, status and plans) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 45 ++++++ README.md | 21 ++- backend/app/converter/analyze.py | 4 +- backend/app/converter/compatibility.py | 2 +- backend/app/converter/locales.py | 2 +- backend/app/converter/nani_compiler.py | 2 +- backend/app/converter/nani_expr.py | 2 +- backend/app/converter/nani_package.py | 8 +- backend/app/converter/naninovel.py | 5 +- backend/app/converter/pipeline.py | 8 +- backend/app/converter/preflight.py | 6 +- backend/app/converter/rpy_helpers.py | 130 +++++++++++++++++ backend/app/converter/rpy_parser.py | 141 +++--------------- backend/app/converter/source.py | 2 +- backend/app/converter/tomie_assets.py | 2 +- backend/app/converter/tomie_profile.py | 5 +- backend/app/converter/tomie_validation.py | 1 - backend/app/converter/unity_bundle.py | 2 +- backend/app/converter/unity_scripts.py | 2 +- backend/app/import_dir.py | 2 +- backend/app/import_library.py | 5 +- backend/app/mods.py | 4 +- backend/app/ratelimit.py | 55 +++++++ backend/app/routers/auth.py | 14 +- backend/app/routers/games.py | 2 +- backend/app/routers/versions.py | 15 +- backend/app/translation.py | 2 +- backend/app/worker.py | 2 +- backend/mypy.ini | 5 + backend/ruff.toml | 10 ++ backend/tests/test_compatibility.py | 18 ++- backend/tests/test_mod_api.py | 4 +- backend/tests/test_ratelimit.py | 25 ++++ backend/tests/test_tomie_format.py | 4 +- backend/tests/test_tomie_profile.py | 12 +- docs/ARCHITECTURE.md | 23 ++- frontend/src/pages/TranslationEditor.tsx | 167 +--------------------- frontend/src/pages/TranslationParts.tsx | 93 ++++++++++++ frontend/src/pages/translation-types.ts | 74 ++++++++++ frontend/src/player/engine-util.ts | 51 +++++++ frontend/src/player/engine.ts | 50 +------ frontend/tests/ui.test.mjs | 42 ++++++ 42 files changed, 678 insertions(+), 391 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/app/converter/rpy_helpers.py create mode 100644 backend/app/ratelimit.py create mode 100644 backend/mypy.ini create mode 100644 backend/ruff.toml create mode 100644 backend/tests/test_ratelimit.py create mode 100644 frontend/src/pages/TranslationParts.tsx create mode 100644 frontend/src/pages/translation-types.ts create mode 100644 frontend/src/player/engine-util.ts create mode 100644 frontend/tests/ui.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7dbce4e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: backend/requirements*.txt + - name: Install system tools + run: sudo apt-get update && sudo apt-get install -y --no-install-recommends ffmpeg + - run: pip install -r requirements-dev.txt ruff mypy + - run: ruff check . + - name: Type check (informational, existing backlog) + run: mypy app + continue-on-error: true + - run: python -m pytest -q + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm run typecheck + - run: npm test + - run: npm run build diff --git a/README.md b/README.md index 0684db0..f4ef705 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # PGames — локальная библиотека визуальных новелл -Загружаете архив с игрой на Ren'Py — сервер распаковывает его, переводит +Загружаете архив с игрой на Ren'Py или Unity-игру на Naninovel (включая +отдельный адаптер Tomie) — сервер распаковывает его, переводит скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM) -и удаляет исходники. Играете в браузере во встроенном плеере. У каждой игры +и удаляет исходники. Игры других движков отклоняются на входе. Играете в браузере во встроенном плеере. У каждой игры может быть несколько версий (форков) от разных пользователей; версии делят общие ассеты, поэтому форк почти не занимает места. @@ -81,11 +82,23 @@ docker compose run --rm -v /srv/games:/games:ro backend \ `reset` — удалить стенд вместе с данными, `logs` — логи бэкенда и воркера. ```bash -cd backend && pip install -r requirements.txt pytest && python -m pytest +cd backend && pip install -r requirements-dev.txt ruff && ruff check . && python -m pytest cd frontend && npm install && npm run dev # проксирует /api на :8000 +cd frontend && npm run typecheck && npm test python -m tests.make_sample sample.zip # тестовая игра (из backend/) ``` +Тесты конвертера требуют `ffmpeg` в `PATH`. Те же проверки (ruff, pytest, +`tsc`, тесты и сборка фронтенда) выполняет GitHub Actions +(`.github/workflows/ci.yml`); `mypy` запускается там как справочная проверка. + +Каталоги `data/` и `data-test/` содержат библиотеку и тестовый стенд (гигабайты), +они в `.gitignore` — не добавляйте их в коммиты и в образы Docker. + +Вход ограничен по числу неудачных попыток: 8 ошибок за 15 минут по IP или по +логину блокируют вход на оставшееся время (ответ 429 с `Retry-After`). Счётчик +хранится в памяти процесса и сбрасывается при перезапуске backend. + ## Что поддерживает конвертер Ren'Py `label`, реплики (в т.ч. атрибуты, `extend`, `centered`, NVL), `scene/show/hide` @@ -101,6 +114,8 @@ python -m tests.make_sample sample.zip # тестовая игра (из ba каналы (`register_channel`), `pause`, `.rpa`, `.rpyc` (через unrpyc), автоопределение картинок из `images/`, GUI-текстбокс и шрифты игры. +Для Naninovel/Tomie см. [docs/IMPORT.md](docs/IMPORT.md). + Пока **не** поддерживается: `screen`-язык (кастомные экраны, карты, мини-игры), движение и твины в ATL, функции/классы в `python` и картинки, которые игра вычисляет своими функциями. Такие места перечислены в отчёте diff --git a/backend/app/converter/analyze.py b/backend/app/converter/analyze.py index 5d30177..a674c69 100644 --- a/backend/app/converter/analyze.py +++ b/backend/app/converter/analyze.py @@ -12,12 +12,12 @@ import sys import tempfile import time from collections import Counter +from dataclasses import asdict from pathlib import Path from app.converter import assets, locales -from app.converter.compatibility import Issue, audit_ir -from dataclasses import asdict from app.converter.archive import ArchiveError, find_game_dir +from app.converter.compatibility import Issue, audit_ir from app.converter.pipeline import _auto_images, _is_engine_file, _resolve_image, anchors from app.converter.rpa import RpaError, extract_rpa, read_index from app.converter.rpy_lexer import ParseError diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 236c8dc..797986f 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -1,7 +1,7 @@ """Engine detection and fail-closed import diagnostics. Never runs game executables.""" +import os from dataclasses import asdict, dataclass from pathlib import Path, PurePosixPath -import os @dataclass(frozen=True) diff --git a/backend/app/converter/locales.py b/backend/app/converter/locales.py index 264ebfd..27d5570 100644 --- a/backend/app/converter/locales.py +++ b/backend/app/converter/locales.py @@ -8,11 +8,11 @@ import hashlib import re -from uuid import uuid4 from collections import Counter, defaultdict from dataclasses import dataclass, field from pathlib import Path from typing import Any +from uuid import uuid4 from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree from app.converter.source import read_source diff --git a/backend/app/converter/nani_compiler.py b/backend/app/converter/nani_compiler.py index 88895d1..402e9dd 100644 --- a/backend/app/converter/nani_compiler.py +++ b/backend/app/converter/nani_compiler.py @@ -1,7 +1,7 @@ """Compile the supported Naninovel command subset to the shared PGVN player.""" -from collections import Counter import hashlib import re +from collections import Counter from app.converter import nani_expr from app.converter.compatibility import CompatibilityError, Issue diff --git a/backend/app/converter/nani_expr.py b/backend/app/converter/nani_expr.py index 7f55297..2566b14 100644 --- a/backend/app/converter/nani_expr.py +++ b/backend/app/converter/nani_expr.py @@ -1,6 +1,6 @@ """Parse Naninovel expressions into data, never Python/JavaScript source code.""" -import re import math +import re TOKEN = re.compile(r'''\s*(?:(\d+(?:\.\d*)?(?:[eE][+-]?\d+)?|\.\d+)|([A-Za-z_]\w*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^]))''') PRECEDENCE = {"or": 2, "||": 2, "and": 3, "&&": 3, "|": 4, "^": 5, diff --git a/backend/app/converter/nani_package.py b/backend/app/converter/nani_package.py index 7accef0..3a25bed 100644 --- a/backend/app/converter/nani_package.py +++ b/backend/app/converter/nani_package.py @@ -1,9 +1,8 @@ """Recover Naninovel data from Unity builds without executing their managed code.""" -from dataclasses import dataclass, field -from pathlib import Path import hashlib import json -import re +from dataclasses import dataclass, field +from pathlib import Path from app.converter.compatibility import CompatibilityError, Issue from app.converter.unity_bundle import unpack @@ -133,8 +132,9 @@ def read_package(root: Path, work: Path, *, media: bool = False) -> Package: def _read_package(root: Path, work: Path, package: Package, *, media: bool) -> Package: import UnityPy + + from app.converter.tomie_profile import extract_tables, identify from app.converter.unity_scripts import ScriptCodec, script_objects - from app.converter.tomie_profile import identify, extract_tables package.profile = identify(root) if package.profile: diff --git a/backend/app/converter/naninovel.py b/backend/app/converter/naninovel.py index df5de3b..f63fdf0 100644 --- a/backend/app/converter/naninovel.py +++ b/backend/app/converter/naninovel.py @@ -1,8 +1,7 @@ """Naninovel importer: compiled Unity data -> validated PGVN package.""" -from pathlib import Path, PurePosixPath import json -import re import tempfile +from pathlib import Path, PurePosixPath from app.blobs import put_bytes, put_file from app.converter import assets, locales @@ -158,8 +157,8 @@ def _convert_package(root: Path, work: Path, progress, package): def _convert_tomie(root, work, progress, package): from app.converter.pipeline import Result, anchors - from app.converter.tomie_format import RUNTIME, modules, prepare from app.converter.tomie_assets import export_media as export_tomie_media + from app.converter.tomie_format import RUNTIME, modules, prepare native = prepare(package) # all script checks precede media decoding/blob writes scripts = modules(native, package.texts) diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py index 1361998..9f72617 100644 --- a/backend/app/converter/pipeline.py +++ b/backend/app/converter/pipeline.py @@ -15,12 +15,18 @@ from app.blobs import put_bytes, put_file from app.config import get_settings from app.converter import assets, locales from app.converter.archive import extract, find_game_dir +from app.converter.compatibility import ( + CompatibilityError, + Issue, + audit_ir, + detect, + require_supported_scripts, +) from app.converter.rpa import RpaError, extract_rpa from app.converter.rpy_lexer import ParseError from app.converter.rpy_parser import RpyParser, ScriptData from app.converter.rpyc import decompile_missing from app.converter.source import read_source -from app.converter.compatibility import CompatibilityError, Issue, audit_ir, detect, require_supported_scripts log = logging.getLogger(__name__) FORMAT = "pgvn/1" diff --git a/backend/app/converter/preflight.py b/backend/app/converter/preflight.py index 93d973c..0da3a86 100644 --- a/backend/app/converter/preflight.py +++ b/backend/app/converter/preflight.py @@ -1,9 +1,9 @@ """Read-only compatibility check, shared by uploads, CLI and the converter.""" -from pathlib import Path -import tempfile -import zipfile import shutil import subprocess +import tempfile +import zipfile +from pathlib import Path from app.converter.archive import extract from app.converter.compatibility import CompatibilityError, Issue, detect diff --git a/backend/app/converter/rpy_helpers.py b/backend/app/converter/rpy_helpers.py new file mode 100644 index 0000000..72943ec --- /dev/null +++ b/backend/app/converter/rpy_helpers.py @@ -0,0 +1,130 @@ +"""Module-level helpers of the Ren'Py parser (Python snippets, ATL props, constants).""" + +import ast +import re +import warnings +from typing import Any + +from app.converter.pyexpr import Expr + +Node = dict[str, Any] + +POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter", + "ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset", + "align", "pos", "anchor", "xysize", "size"} + + +_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"} +_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$") +_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(") +_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed", + "ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip", + "im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"} +_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus", + "flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"} + + +def _dedent(source: str) -> str: + import textwrap + return textwrap.dedent(source) + + +def _parse_py(source: str, mode: str = "exec") -> ast.AST: + # decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return ast.parse(_dedent(source), mode=mode) + + +def _dotted_name(e: Expr) -> str: + if e["k"] == "name": + return e["id"] + if e["k"] == "attr": + return f"{_dotted_name(e['o'])}.{e['a']}" + return "" + + +def _assign_name(t: ast.AST) -> str | None: + if isinstance(t, ast.Name): + return t.id + if isinstance(t, ast.Attribute): + inner = _assign_name(t.value) + return f"{inner}.{t.attr}" if inner else None + return None + + +def _const(e: Expr | None) -> Any: + if e is None: + return None + if e.get("k") == "const": + return e["v"] + if e.get("k") in ("tuple", "list"): + return [_const(x) for x in e["v"]] + if e.get("k") == "unary" and e["op"] == "-": + v = _const(e["v"]) + return -v if isinstance(v, (int, float)) else None + return None + + +def _num(s: str | None) -> float | None: + try: + return float(s) if s is not None else None + except ValueError: + return None + + +def _static_props(kw: dict[str, Expr]) -> dict[str, Any]: + props = {} + for k, v in kw.items(): + if k in POSITION_PROPS: + val = _const(v) + if val is not None: + props[k] = val + return props + + +def _atl_props(text: str) -> dict[str, Any]: + """Extract static `prop value` pairs from one ATL line, ignoring timing.""" + tokens = text.replace(",", " ").split() + props: dict[str, Any] = {} + i = 0 + while i < len(tokens) - 1: + key = tokens[i] + if key in POSITION_PROPS: + raw = tokens[i + 1] + try: + props[key] = ast.literal_eval(raw) + except (ValueError, SyntaxError): + if raw.startswith("(") and i + 2 < len(tokens): + try: + props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2])) + i += 1 + except (ValueError, SyntaxError): + pass + i += 2 + else: + i += 1 + return props + + +def _split_top(source: str) -> list[str]: + parts, depth, cur = [], 0, [] + for ch in source: + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + if ch == "," and depth == 0: + parts.append("".join(cur)) + cur = [] + else: + cur.append(ch) + if "".join(cur).strip(): + parts.append("".join(cur)) + return [p.strip() for p in parts if p.strip()] + + +def _jump_or_call(op: str, target: Expr) -> Node: + if target.get("k") == "const": + return {"op": op, "target": target["v"]} + return {"op": op, "expr": target} diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index 84a0f08..5dd44ff 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -1,14 +1,30 @@ """Ren'Py script (.rpy) → PGVN IR.""" import ast -import re -import warnings from collections import Counter from dataclasses import dataclass, field from typing import Any from app.converter import pyexpr from app.converter.pyexpr import Expr, Stmt, Unsupported, const +from app.converter.rpy_helpers import ( + _CALL_LINE, + _DISPLAYABLE_CALLS, + _MEDIA_EXTS, + _NUMBER_LINE, + _SHOW_KEYWORDS, + POSITION_PROPS, + _assign_name, + _atl_props, + _const, + _dedent, + _dotted_name, + _jump_or_call, + _num, + _parse_py, + _split_top, + _static_props, +) from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree Node = dict[str, Any] @@ -41,9 +57,6 @@ TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pix POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top", "offscreenleft", "offscreenright", "default", "reset"} -POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter", - "ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset", - "align", "pos", "anchor", "xysize", "size"} CHANNELS = {"music", "sound", "voice", "audio", "movie"} SKIP_BLOCKS = {"screen", "style", "translate", "testcase"} BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"} @@ -1100,7 +1113,7 @@ class RpyParser: if c.string_raw() is not None: c.pos = save break - p = c.image_part() or (("@" if c.char("@") else None)) + p = c.image_part() or ("@" if c.char("@") else None) if p is None: c.pos = save break @@ -1130,119 +1143,3 @@ class RpyParser: c.rest() self.d.stats["say"] += 1 return [node] - - -_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"} -_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$") -_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(") -_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed", - "ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip", - "im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"} -_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus", - "flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"} - - -def _dedent(source: str) -> str: - import textwrap - return textwrap.dedent(source) - - -def _parse_py(source: str, mode: str = "exec") -> ast.AST: - # decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - return ast.parse(_dedent(source), mode=mode) - - -def _dotted_name(e: Expr) -> str: - if e["k"] == "name": - return e["id"] - if e["k"] == "attr": - return f"{_dotted_name(e['o'])}.{e['a']}" - return "" - - -def _assign_name(t: ast.AST) -> str | None: - if isinstance(t, ast.Name): - return t.id - if isinstance(t, ast.Attribute): - inner = _assign_name(t.value) - return f"{inner}.{t.attr}" if inner else None - return None - - -def _const(e: Expr | None) -> Any: - if e is None: - return None - if e.get("k") == "const": - return e["v"] - if e.get("k") in ("tuple", "list"): - return [_const(x) for x in e["v"]] - if e.get("k") == "unary" and e["op"] == "-": - v = _const(e["v"]) - return -v if isinstance(v, (int, float)) else None - return None - - -def _num(s: str | None) -> float | None: - try: - return float(s) if s is not None else None - except ValueError: - return None - - -def _static_props(kw: dict[str, Expr]) -> dict[str, Any]: - props = {} - for k, v in kw.items(): - if k in POSITION_PROPS: - val = _const(v) - if val is not None: - props[k] = val - return props - - -def _atl_props(text: str) -> dict[str, Any]: - """Extract static `prop value` pairs from one ATL line, ignoring timing.""" - tokens = text.replace(",", " ").split() - props: dict[str, Any] = {} - i = 0 - while i < len(tokens) - 1: - key = tokens[i] - if key in POSITION_PROPS: - raw = tokens[i + 1] - try: - props[key] = ast.literal_eval(raw) - except (ValueError, SyntaxError): - if raw.startswith("(") and i + 2 < len(tokens): - try: - props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2])) - i += 1 - except (ValueError, SyntaxError): - pass - i += 2 - else: - i += 1 - return props - - -def _split_top(source: str) -> list[str]: - parts, depth, cur = [], 0, [] - for ch in source: - if ch in "([{": - depth += 1 - elif ch in ")]}": - depth -= 1 - if ch == "," and depth == 0: - parts.append("".join(cur)) - cur = [] - else: - cur.append(ch) - if "".join(cur).strip(): - parts.append("".join(cur)) - return [p.strip() for p in parts if p.strip()] - - -def _jump_or_call(op: str, target: Expr) -> Node: - if target.get("k") == "const": - return {"op": op, "target": target["v"]} - return {"op": op, "expr": target} diff --git a/backend/app/converter/source.py b/backend/app/converter/source.py index 711e5b9..d15e20c 100644 --- a/backend/app/converter/source.py +++ b/backend/app/converter/source.py @@ -1,7 +1,7 @@ """Decode source text without silently replacing characters in localisations.""" import io -from pathlib import Path import tokenize +from pathlib import Path from app.converter.rpy_lexer import ParseError diff --git a/backend/app/converter/tomie_assets.py b/backend/app/converter/tomie_assets.py index 59e12b1..61b82d8 100644 --- a/backend/app/converter/tomie_assets.py +++ b/backend/app/converter/tomie_assets.py @@ -1,6 +1,6 @@ """Recover the pinned Tomie build's media and ResourceManager actor bindings.""" -from pathlib import Path import hashlib +from pathlib import Path from app.blobs import put_file from app.converter import assets diff --git a/backend/app/converter/tomie_profile.py b/backend/app/converter/tomie_profile.py index 1207b4d..9614b49 100644 --- a/backend/app/converter/tomie_profile.py +++ b/backend/app/converter/tomie_profile.py @@ -1,7 +1,7 @@ """Version-pinned Tomie metadata. Reads IL constants; never executes managed code.""" import hashlib -from pathlib import Path import re +from pathlib import Path PROFILE = "tomie/expansion-1.3801" ASSEMBLY_HASH = "1c6aec12894362f1b9c62858a5a4c286eb93aee175ed1ef339218dc5a7b2729b" @@ -110,8 +110,9 @@ def runtime_data(package) -> dict: def validate_expressions(scripts: dict) -> dict: """Check the full recovered expression corpus, including dynamic templates.""" - from app.converter.nani_expr import assignment, parse from collections import Counter + + from app.converter.nani_expr import assignment, parse expressions, errors = {}, Counter() examples = {} diff --git a/backend/app/converter/tomie_validation.py b/backend/app/converter/tomie_validation.py index 3727202..f1ebdea 100644 --- a/backend/app/converter/tomie_validation.py +++ b/backend/app/converter/tomie_validation.py @@ -9,7 +9,6 @@ from app.converter.compatibility import Issue from app.converter.nani_expr import assignment, parse from app.converter.tomie_profile import FUNCTIONS, validate_expressions - ARGUMENTS = { "ModifyBackground": "AppearanceAndTransition Duration Scale TintColor", "ModifyTextPrinter": "Duration HideOther IdAndAppearance MakeDefault ScenePosition", diff --git a/backend/app/converter/unity_bundle.py b/backend/app/converter/unity_bundle.py index 13d9036..54a1aad 100644 --- a/backend/app/converter/unity_bundle.py +++ b/backend/app/converter/unity_bundle.py @@ -3,8 +3,8 @@ from pathlib import Path def unpack(source: Path, target: Path) -> list[Path]: - from UnityPy.streams import EndianBinaryReader from UnityPy.helpers.CompressionHelper import DECOMPRESSION_MAP + from UnityPy.streams import EndianBinaryReader target.mkdir(parents=True, exist_ok=True) with source.open("rb") as stream: diff --git a/backend/app/converter/unity_scripts.py b/backend/app/converter/unity_scripts.py index cbb2671..7e1b382 100644 --- a/backend/app/converter/unity_scripts.py +++ b/backend/app/converter/unity_scripts.py @@ -98,8 +98,8 @@ class ScriptCodec: helper.read_typetree_boost, helper.get_ref_type_node = old_boost, old_resolve def read(self, obj): - from UnityPy.streams import EndianBinaryWriter from UnityPy.helpers import TypeTreeHelper as helper + from UnityPy.streams import EndianBinaryWriter # Bundles can retain exact schemas which differ from generated metadata # (notably editor-compiled Init). Prefer those schemas when available. self.templates = {(ref.m_AssemblyName.removesuffix(".dll"), ref.m_NameSpace, ref.m_ClassName): ref.node diff --git a/backend/app/import_dir.py b/backend/app/import_dir.py index e5bf4b9..91d5492 100644 --- a/backend/app/import_dir.py +++ b/backend/app/import_dir.py @@ -25,8 +25,8 @@ async def main() -> None: if not args.path.is_dir(): raise SystemExit(f"{args.path} is not a directory") - from app.converter.preflight import inspect_directory from app.converter.compatibility import CompatibilityError + from app.converter.preflight import inspect_directory try: report = await asyncio.to_thread(inspect_directory, args.path) except CompatibilityError as exc: diff --git a/backend/app/import_library.py b/backend/app/import_library.py index 512117c..af3a926 100644 --- a/backend/app/import_library.py +++ b/backend/app/import_library.py @@ -6,11 +6,9 @@ python -m app.import_library /games --import --owner admin import argparse import asyncio import json -from pathlib import Path -import tempfile import sys +from pathlib import Path -from app.converter.archive import ArchiveError, extract from app.converter.compatibility import CompatibilityError, Issue from app.converter.preflight import inspect_archive, inspect_directory @@ -52,6 +50,7 @@ def check(source: Path) -> dict: async def import_one(source: Path, report: dict, username: str | None) -> dict: from sqlalchemy import select + from app.db import SessionLocal from app.models import Game, GameVersion, Role, User from app.worker import convert diff --git a/backend/app/mods.py b/backend/app/mods.py index d646a29..e068d97 100644 --- a/backend/app/mods.py +++ b/backend/app/mods.py @@ -2,12 +2,12 @@ import copy import difflib import json -from uuid import uuid4, uuid5, NAMESPACE_URL +from uuid import NAMESPACE_URL, uuid4, uuid5 from app.blobs import BLOB_ID_RE -from app.pgvn import expression, statements from app.converter.locales import walk from app.converter.pipeline import anchors +from app.pgvn import expression, statements from app.translation import file_json, put_json OPS = set("label camera say scene show hide with play stop queue voice pause menu if while jump call return exec window noop nvl".split()) diff --git a/backend/app/ratelimit.py b/backend/app/ratelimit.py new file mode 100644 index 0000000..7005fb9 --- /dev/null +++ b/backend/app/ratelimit.py @@ -0,0 +1,55 @@ +import time +from collections import defaultdict, deque + +from fastapi import Request + + +class FailureLimiter: + """Sliding-window counter of failed attempts, kept in process memory. + + Several uvicorn workers would each count separately, which only loosens the + limit; the service runs a single backend process. + """ + + def __init__(self, max_failures: int, window_seconds: float) -> None: + self.max_failures = max_failures + self.window = window_seconds + self._hits: dict[str, deque[float]] = defaultdict(deque) + + def _prune(self, key: str, now: float) -> deque[float]: + hits = self._hits[key] + while hits and now - hits[0] > self.window: + hits.popleft() + if not hits: + self._hits.pop(key, None) + return hits + + def retry_after(self, *keys: str) -> int: + """Seconds until a blocked key is allowed again, 0 if none is blocked.""" + now = time.monotonic() + wait = 0.0 + for key in keys: + hits = self._prune(key, now) + if len(hits) >= self.max_failures: + wait = max(wait, hits[0] + self.window - now) + return int(wait) + 1 if wait > 0 else 0 + + def fail(self, *keys: str) -> None: + now = time.monotonic() + for key in keys: + self._prune(key, now) + self._hits[key].append(now) + + def reset(self, *keys: str) -> None: + for key in keys: + self._hits.pop(key, None) + + +def client_ip(request: Request) -> str: + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +login_limiter = FailureLimiter(max_failures=8, window_seconds=15 * 60) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 70105f1..abd4a56 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,9 +1,10 @@ -from fastapi import APIRouter, HTTPException, Response, status +from fastapi import APIRouter, HTTPException, Request, Response, status from sqlalchemy import select from app.config import get_settings from app.deps import DB, SessionUser from app.models import User +from app.ratelimit import client_ip, login_limiter from app.schemas import ChangeCredentialsIn, LoginIn, MeOut from app.security import ( COOKIE_NAME, @@ -30,10 +31,19 @@ def _set_cookie(response: Response, user: User) -> None: @router.post("/login", response_model=MeOut) -async def login(body: LoginIn, response: Response, db: DB) -> User: +async def login(body: LoginIn, request: Request, response: Response, db: DB) -> User: + keys = (f"ip:{client_ip(request)}", f"user:{body.username.lower()}") + if wait := login_limiter.retry_after(*keys): + raise HTTPException( + status.HTTP_429_TOO_MANY_REQUESTS, + f"Слишком много неудачных попыток входа. Повторите через {wait} с", + headers={"Retry-After": str(wait)}, + ) user = await db.scalar(select(User).where(User.username == body.username)) if not user or not user.is_active or not verify_password(user.password_hash, body.password): + login_limiter.fail(*keys) raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Неверный логин или пароль") + login_limiter.reset(*keys) _set_cookie(response, user) return user diff --git a/backend/app/routers/games.py b/backend/app/routers/games.py index 4bba49e..0283390 100644 --- a/backend/app/routers/games.py +++ b/backend/app/routers/games.py @@ -7,11 +7,11 @@ from sqlalchemy import and_, exists, func, or_, select, true from starlette.concurrency import run_in_threadpool from app.blobs import put_bytes -from app.uploads import remove_upload from app.deps import DB, CurrentUser, has_perm, require from app.models import Game, GameVersion, User from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut from app.translation import version_languages +from app.uploads import remove_upload router = APIRouter(prefix="/api/games", tags=["games"]) Player = Annotated[User, Depends(require("games.play"))] diff --git a/backend/app/routers/versions.py b/backend/app/routers/versions.py index 3a6af36..445ea6a 100644 --- a/backend/app/routers/versions.py +++ b/backend/app/routers/versions.py @@ -1,19 +1,26 @@ -from typing import Annotated, Any import asyncio +from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import select from starlette.concurrency import run_in_threadpool from starlette.requests import ClientDisconnect +from app import translation from app.deps import DB, CurrentUser, has_perm, require from app.models import Game, GameVersion, Revision, Save, User, VersionHistory from app.routers.games import get_game from app.schemas import ( - ForkIn, HistoryOut, PublishIn, SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, + ForkIn, + HistoryOut, + PublishIn, + SaveDetailOut, + SaveIn, + SaveOut, + VersionDetailOut, + VersionIn, VersionOut, ) -from app import translation from app.uploads import remove_upload, upload_path router = APIRouter(prefix="/api", tags=["versions"]) @@ -79,9 +86,9 @@ async def upload_version( await db.commit() raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл") - from app.converter.preflight import probe_archive from app.converter.archive import ArchiveError from app.converter.compatibility import CompatibilityError + from app.converter.preflight import probe_archive try: probe = await asyncio.to_thread(probe_archive, path, filename) except (CompatibilityError, ArchiveError, OSError, ValueError) as exc: diff --git a/backend/app/translation.py b/backend/app/translation.py index 0de78eb..887b3df 100644 --- a/backend/app/translation.py +++ b/backend/app/translation.py @@ -384,7 +384,7 @@ def export_po(manifest: dict[str, Any], lang: str, game_title: str) -> str: f"msgstr {_po_quote(e.get('t', ''))}", ""] for src, usage in idx.strings.items(): e = strings.get(src) or {} - out += [f"#. {usage}", f'msgctxt "string"', f"msgid {_po_quote(src)}", + out += [f"#. {usage}", 'msgctxt "string"', f"msgid {_po_quote(src)}", f"msgstr {_po_quote(e.get('t', ''))}", ""] return "\n".join(out) diff --git a/backend/app/worker.py b/backend/app/worker.py index 0ef15ba..f6c0f9f 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -9,8 +9,8 @@ from sqlalchemy import select, update from app.blobs import read_bytes from app.config import get_settings -from app.converter.pipeline import convert_archive from app.converter.compatibility import CompatibilityError +from app.converter.pipeline import convert_archive from app.db import SessionLocal from app.models import Game, GameVersion from app.routers.games import make_cover diff --git a/backend/mypy.ini b/backend/mypy.ini new file mode 100644 index 0000000..f44b79f --- /dev/null +++ b/backend/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +python_version = 3.12 +ignore_missing_imports = True +check_untyped_defs = False +exclude = (migrations|tests)/ diff --git a/backend/ruff.toml b/backend/ruff.toml new file mode 100644 index 0000000..37f3249 --- /dev/null +++ b/backend/ruff.toml @@ -0,0 +1,10 @@ +target-version = "py312" +line-length = 100 +extend-exclude = ["migrations/versions"] + +[lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = ["E501", "E741", "E702", "B904", "B905", "B007", "F402"] + +[lint.per-file-ignores] +"tests/*" = ["F811"] diff --git a/backend/tests/test_compatibility.py b/backend/tests/test_compatibility.py index 066404e..6638df1 100644 --- a/backend/tests/test_compatibility.py +++ b/backend/tests/test_compatibility.py @@ -1,13 +1,17 @@ import asyncio import json -from pathlib import Path -from types import SimpleNamespace import zipfile +from types import SimpleNamespace import pytest from app.config import get_settings -from app.converter.compatibility import CompatibilityError, audit_ir, detect, require_supported_scripts +from app.converter.compatibility import ( + CompatibilityError, + audit_ir, + detect, + require_supported_scripts, +) from app.converter.nani_compiler import compile_package from app.converter.nani_expr import assignment, parse from app.converter.nani_package import Package, decode_script, text_records @@ -92,8 +96,8 @@ def test_renpy_atl_animation_is_rejected_instead_of_becoming_static(): def test_source_encoding_is_preserved_or_explicitly_rejected(tmp_path): - from app.converter.source import read_source from app.converter.rpy_lexer import ParseError + from app.converter.source import read_source source = tmp_path / "script.rpy" source.write_bytes('# coding: cp1251\nlabel start:\n "Привет"'.encode("cp1251")) assert "Привет" in read_source(source) @@ -222,8 +226,8 @@ def test_naninovel_resources_keep_keys_delimiters_and_context(): def test_naninovel_import_localization_and_mod_editor_roundtrip(tmp_path, monkeypatch): from app.blobs import read_bytes from app.converter.naninovel import convert_naninovel - from app.translation import apply_changes, locale_tables, text_index from app.mods import document, validate + from app.translation import apply_changes, locale_tables, text_index monkeypatch.setenv("DATA_DIR", str(tmp_path / "library")) get_settings.cache_clear() @@ -250,6 +254,7 @@ def test_naninovel_import_localization_and_mod_editor_roundtrip(tmp_path, monkey def test_unity_codec_restores_global_hooks_after_error(): from UnityPy.helpers import TypeTreeHelper as helper + from app.converter.unity_scripts import ScriptCodec codec = ScriptCodec.__new__(ScriptCodec) before = (helper.read_value, helper.write_value, helper.read_typetree_boost, helper.get_ref_type_node) @@ -288,9 +293,10 @@ def test_unused_renpy_translation_entries_are_not_script_blockers(tmp_path): def test_upload_rejects_unsupported_engine_without_queueing(tmp_path, monkeypatch): + from fastapi import HTTPException + from app.routers import versions from app.uploads import upload_path - from fastapi import HTTPException source = tmp_path / "unsupported.zip" with zipfile.ZipFile(source, "w") as archive: diff --git a/backend/tests/test_mod_api.py b/backend/tests/test_mod_api.py index 77a43ee..0c844f1 100644 --- a/backend/tests/test_mod_api.py +++ b/backend/tests/test_mod_api.py @@ -1,12 +1,11 @@ """Integration against PostgreSQL in a temporary schema, never touches library rows.""" import asyncio -import copy import os from uuid import uuid4 import httpx import pytest -from sqlalchemy import select, text +from sqlalchemy import text from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from app import db as database @@ -128,6 +127,7 @@ def test_complete_mod_workflow(pg_manifest, monkeypatch): assert repeated_lines.count("Upstream addition") == 1 # A real replacement is converted, and a stale upload is refused. import io + from PIL import Image image = io.BytesIO() Image.new("RGB", (32, 32), "red").save(image, "PNG") diff --git a/backend/tests/test_ratelimit.py b/backend/tests/test_ratelimit.py new file mode 100644 index 0000000..c29c5a7 --- /dev/null +++ b/backend/tests/test_ratelimit.py @@ -0,0 +1,25 @@ +from app.ratelimit import FailureLimiter + + +def test_blocks_after_max_failures_and_resets(): + limiter = FailureLimiter(max_failures=3, window_seconds=60) + for _ in range(2): + limiter.fail("ip:1", "user:a") + assert limiter.retry_after("ip:1", "user:a") == 0 + limiter.fail("ip:1", "user:a") + assert limiter.retry_after("ip:1", "user:a") > 0 + assert limiter.retry_after("ip:2", "user:b") == 0 + limiter.reset("ip:1", "user:a") + assert limiter.retry_after("ip:1", "user:a") == 0 + + +def test_window_expires(monkeypatch): + import app.ratelimit as rl + + now = [1000.0] + monkeypatch.setattr(rl.time, "monotonic", lambda: now[0]) + limiter = FailureLimiter(max_failures=1, window_seconds=10) + limiter.fail("k") + assert limiter.retry_after("k") > 0 + now[0] += 11 + assert limiter.retry_after("k") == 0 diff --git a/backend/tests/test_tomie_format.py b/backend/tests/test_tomie_format.py index 5668e65..b74076a 100644 --- a/backend/tests/test_tomie_format.py +++ b/backend/tests/test_tomie_format.py @@ -6,11 +6,11 @@ import pytest def test_native_translation_and_mod_edits_rebuild_the_same_vm_input(tmp_path, monkeypatch): from app import mods from app.config import get_settings - from app.converter.naninovel import _convert_package from app.converter.nani_package import Package, text_records + from app.converter.naninovel import _convert_package from app.converter.tomie_format import unpack_modules from app.converter.tomie_profile import PROFILE - from app.translation import apply_changes, text_index, locale_tables + from app.translation import apply_changes, locale_tables, text_index monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) get_settings.cache_clear() package = Package(profile=PROFILE, defaults={"points": 3}, function_data={"names": ["Tomie"]}) diff --git a/backend/tests/test_tomie_profile.py b/backend/tests/test_tomie_profile.py index 89b050b..ef167b2 100644 --- a/backend/tests/test_tomie_profile.py +++ b/backend/tests/test_tomie_profile.py @@ -1,12 +1,18 @@ +import json +import os from pathlib import Path from types import SimpleNamespace -import os -import json import pytest from app.converter.nani_expr import assignment, parse -from app.converter.tomie_profile import ASSEMBLY_HASH, FUNCTIONS, constant_value, extract_tables, identify +from app.converter.tomie_profile import ( + ASSEMBLY_HASH, + FUNCTIONS, + constant_value, + extract_tables, + identify, +) def instruction(op, operand=None): diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 05ae6dd..56a8da9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -131,10 +131,21 @@ LOCKED`), Redis не нужен. * меню выборов, история реплик, skip/auto, быстрые и слотовые сохранения на сервере, громкость каналов, предзагрузка ближайших картинок. -## Дорожная карта +## Состояние и планы -* Реализованы редактор PGVN-форка, замена ассетов, ревизии, трёхстороннее слияние - и перенос на новый оригинал — см. [MODDING.md](MODDING.md). -* Поддержка screens/ATL-трансформаций в IR. -* Другие движки (TyranoBuilder, RPG Maker MV) через тот же IR. -* Сборщик мусора доступен как `python -m app.gc`, включая периодический режим. +Реализовано: + +* редактор PGVN-форка, замена ассетов, ревизии, трёхстороннее слияние и перенос + на новый оригинал — см. [MODDING.md](MODDING.md); +* импорт Naninovel и адаптер Tomie, пакетный импорт и строгая проверка + совместимости — см. [IMPORT.md](IMPORT.md); +* редактор переводов — см. [LOCALIZATION.md](LOCALIZATION.md); +* сборщик мусора (`python -m app.gc`, есть периодический режим); +* ограничение неудачных попыток входа (`app/ratelimit.py`), CI в GitHub Actions. + +Планы: + +* поддержка `screen`-языка и ATL-анимаций с движением в IR; +* другие движки (TyranoBuilder, RPG Maker MV) через тот же IR; +* дальнейшее дробление `engine.ts` на модули (рендер сцены, UI, сохранения); +* типизация backend под `mypy` (сейчас проверка справочная). diff --git a/frontend/src/pages/TranslationEditor.tsx b/frontend/src/pages/TranslationEditor.tsx index bc2c264..aac802d 100644 --- a/frontend/src/pages/TranslationEditor.tsx +++ b/frontend/src/pages/TranslationEditor.tsx @@ -1,84 +1,16 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { useUi } from "../ui/Ui"; +import { GlossaryEditor, ProgressBar, highlight } from "./TranslationParts"; +import { + FILTERS, PAGE, STATE_BADGE, STATE_LABEL, USAGE, + type GlossaryTerm, type Item, type LangStats, type LineState, type Overview, type Tab, +} from "./translation-types"; import "./translate.css"; -type LineState = "untranslated" | "outdated" | "translated" | "fuzzy" | "machine" | "approved"; - -interface LangStats { - code: string; - name: string; - font: string | null; - lines: number; - translated: number; - progress: number; - states: Record; - strings: number; - stringsTranslated: number; -} - -interface Overview { - sourceLang: string | null; - editable: boolean; - canApprove: boolean; - lines: number; - strings: number; - modules: { path: string; lines: number }[]; - languages: LangStats[]; - knownLanguages: Record; -} - -interface Item { - key: string; - source: string; - translation: string; - state: LineState; - // lines - nodeId?: string; - module?: string; - label?: string; - who?: string; - prev?: string | null; - next?: string | null; - scene?: string | null; - // strings - usage?: string; -} - -interface GlossaryTerm { - src: string; - t: string; - note: string; -} - -type Tab = "lines" | "strings" | "glossary"; - -const STATE_LABEL: Record = { - untranslated: "Не переведено", - outdated: "Устарело", - fuzzy: "Неточно", - machine: "Машинный", - translated: "Переведено", - approved: "Одобрено", -}; -const STATE_BADGE: Record = { - untranslated: "badge-plain", - outdated: "badge-error", - fuzzy: "badge-warn", - machine: "badge-warn", - translated: "badge-ok", - approved: "badge-ok", -}; -const FILTERS: [string, string][] = [ - ["todo", "К работе"], ["all", "Все"], ["untranslated", "Не переведено"], ["outdated", "Устарело"], - ["fuzzy", "Неточно"], ["machine", "Машинный"], ["translated", "Переведено"], ["approved", "Одобрено"], -]; -const PAGE = 40; -const USAGE: Record = { name: "имя персонажа", menu: "пункт меню", text: "текст на экране" }; - export function TranslationEditor() { const { versionId } = useParams(); const [params, setParams] = useSearchParams(); @@ -275,29 +207,6 @@ export function TranslationEditor() { ); } -function ProgressBar({ stats }: { stats: LangStats }) { - const total = stats.lines || 1; - const seg = (state: LineState, cls: string) => - stats.states[state] ?
: null; - return ( -
-
- {seg("approved", "seg-approved")} - {seg("translated", "seg-ok")} - {seg("fuzzy", "seg-warn")} - {seg("machine", "seg-warn")} - {seg("outdated", "seg-error")} -
- - {stats.translated} из {stats.lines} реплик ({Math.round(stats.progress * 100)}%) - {stats.states.outdated ? ` · устарело ${stats.states.outdated}` : ""} - {" · "}строк {stats.stringsTranslated}/{stats.strings} - -
- ); -} - function EntriesView({ versionId, lang, tab, ov, onStats }: { versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial) => void; }) { @@ -524,67 +433,3 @@ function Row({ item, versionId, lang, editable, canApprove, terms, onChange, onB
); } - -function highlight(text: string, terms: GlossaryTerm[]): ReactNode { - if (!terms.length) return text; - const escaped = terms.map((t) => t.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).sort((a, b) => b.length - a.length); - const re = new RegExp(`(${escaped.join("|")})`, "gi"); - return text.split(re).map((part, i) => { - const term = i % 2 ? terms.find((t) => t.src.toLowerCase() === part.toLowerCase()) : undefined; - return term ? {part} : part; - }); -} - -function GlossaryEditor({ versionId, lang, editable, terms, onChange }: { - versionId: string; lang: string; editable: boolean; terms: GlossaryTerm[]; onChange: (t: GlossaryTerm[]) => void; -}) { - const ui = useUi(); - const [rows, setRows] = useState(terms.length ? terms : [{ src: "", t: "", note: "" }]); - const save = async () => { - try { - await api.put(`/api/versions/${versionId}/i18n/${lang}`, { glossary: rows }); - onChange(rows.filter((r) => r.src.trim())); - ui.toast("Глоссарий сохранён", "ok"); - } catch (e) { - ui.toast((e as Error).message, "error"); - } - }; - const set = (i: number, key: keyof GlossaryTerm, value: string) => - setRows((rs) => rs.map((r, n) => n === i ? { ...r, [key]: value } : r)); - return ( -
-

- Имена и термины, которые нужно переводить единообразно. В репликах они подсвечиваются, - при наведении виден принятый перевод. -

-
- - - - {rows.map((r, i) => ( - - - - - - - ))} - -
ОригиналПереводЗаметка
set(i, "src", e.target.value)} /> set(i, "t", e.target.value)} lang={lang} /> set(i, "note", e.target.value)} /> - {editable && ( - - )} -
-
- {editable && ( -
- - -
- )} -
- ); -} diff --git a/frontend/src/pages/TranslationParts.tsx b/frontend/src/pages/TranslationParts.tsx new file mode 100644 index 0000000..fcbd6e2 --- /dev/null +++ b/frontend/src/pages/TranslationParts.tsx @@ -0,0 +1,93 @@ +import { useState, type ReactNode } from "react"; +import { api } from "../api"; +import { Icon } from "../ui/Icon"; +import { useUi } from "../ui/Ui"; +import { STATE_LABEL, type GlossaryTerm, type LangStats, type LineState } from "./translation-types"; + +export function ProgressBar({ stats }: { stats: LangStats }) { + const total = stats.lines || 1; + const seg = (state: LineState, cls: string) => + stats.states[state] ?
: null; + return ( +
+
+ {seg("approved", "seg-approved")} + {seg("translated", "seg-ok")} + {seg("fuzzy", "seg-warn")} + {seg("machine", "seg-warn")} + {seg("outdated", "seg-error")} +
+ + {stats.translated} из {stats.lines} реплик ({Math.round(stats.progress * 100)}%) + {stats.states.outdated ? ` · устарело ${stats.states.outdated}` : ""} + {" · "}строк {stats.stringsTranslated}/{stats.strings} + +
+ ); +} + + +export function highlight(text: string, terms: GlossaryTerm[]): ReactNode { + if (!terms.length) return text; + const escaped = terms.map((t) => t.src.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).sort((a, b) => b.length - a.length); + const re = new RegExp(`(${escaped.join("|")})`, "gi"); + return text.split(re).map((part, i) => { + const term = i % 2 ? terms.find((t) => t.src.toLowerCase() === part.toLowerCase()) : undefined; + return term ? {part} : part; + }); +} + +export function GlossaryEditor({ versionId, lang, editable, terms, onChange }: { + versionId: string; lang: string; editable: boolean; terms: GlossaryTerm[]; onChange: (t: GlossaryTerm[]) => void; +}) { + const ui = useUi(); + const [rows, setRows] = useState(terms.length ? terms : [{ src: "", t: "", note: "" }]); + const save = async () => { + try { + await api.put(`/api/versions/${versionId}/i18n/${lang}`, { glossary: rows }); + onChange(rows.filter((r) => r.src.trim())); + ui.toast("Глоссарий сохранён", "ok"); + } catch (e) { + ui.toast((e as Error).message, "error"); + } + }; + const set = (i: number, key: keyof GlossaryTerm, value: string) => + setRows((rs) => rs.map((r, n) => n === i ? { ...r, [key]: value } : r)); + return ( +
+

+ Имена и термины, которые нужно переводить единообразно. В репликах они подсвечиваются, + при наведении виден принятый перевод. +

+
+ + + + {rows.map((r, i) => ( + + + + + + + ))} + +
ОригиналПереводЗаметка
set(i, "src", e.target.value)} /> set(i, "t", e.target.value)} lang={lang} /> set(i, "note", e.target.value)} /> + {editable && ( + + )} +
+
+ {editable && ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/translation-types.ts b/frontend/src/pages/translation-types.ts new file mode 100644 index 0000000..357a124 --- /dev/null +++ b/frontend/src/pages/translation-types.ts @@ -0,0 +1,74 @@ +// Shared types and labels of the translation editor. + +export type LineState = "untranslated" | "outdated" | "translated" | "fuzzy" | "machine" | "approved"; + +export interface LangStats { + code: string; + name: string; + font: string | null; + lines: number; + translated: number; + progress: number; + states: Record; + strings: number; + stringsTranslated: number; +} + +export interface Overview { + sourceLang: string | null; + editable: boolean; + canApprove: boolean; + lines: number; + strings: number; + modules: { path: string; lines: number }[]; + languages: LangStats[]; + knownLanguages: Record; +} + +export interface Item { + key: string; + source: string; + translation: string; + state: LineState; + // lines + nodeId?: string; + module?: string; + label?: string; + who?: string; + prev?: string | null; + next?: string | null; + scene?: string | null; + // strings + usage?: string; +} + +export interface GlossaryTerm { + src: string; + t: string; + note: string; +} + +export type Tab = "lines" | "strings" | "glossary"; + +export const STATE_LABEL: Record = { + untranslated: "Не переведено", + outdated: "Устарело", + fuzzy: "Неточно", + machine: "Машинный", + translated: "Переведено", + approved: "Одобрено", +}; +export const STATE_BADGE: Record = { + untranslated: "badge-plain", + outdated: "badge-error", + fuzzy: "badge-warn", + machine: "badge-warn", + translated: "badge-ok", + approved: "badge-ok", +}; +export const FILTERS: [string, string][] = [ + ["todo", "К работе"], ["all", "Все"], ["untranslated", "Не переведено"], ["outdated", "Устарело"], + ["fuzzy", "Неточно"], ["machine", "Машинный"], ["translated", "Переведено"], ["approved", "Одобрено"], +]; +export const PAGE = 40; +export const USAGE: Record = { name: "имя персонажа", menu: "пункт меню", text: "текст на экране" }; diff --git a/frontend/src/player/engine-util.ts b/frontend/src/player/engine-util.ts new file mode 100644 index 0000000..c9c81c0 --- /dev/null +++ b/frontend/src/player/engine-util.ts @@ -0,0 +1,51 @@ +// Small shared pieces of the engine: save API types, settings and DOM/storage helpers. + +import type { Volumes } from "./audio"; +import type { SaveData } from "./types"; + +export interface SaveMeta { + slot: string; + title: string; + updated_at: string; +} + +export interface SaveApi { + list(): Promise; + get(slot: string): Promise; + put(slot: string, title: string, data: T): Promise; + remove(slot: string): Promise; +} + +export interface Settings extends Volumes { + cps: number; // characters per second, 0 = instant + autoDelay: number; // seconds per 20 characters in auto mode +} + +export const DEFAULT_SETTINGS: Settings = { cps: 45, autoDelay: 1.2, music: 0.7, sound: 0.8, voice: 1 }; +export const SLOTS = 12; + +export class Restart extends Error {} + +export function el(tag: K, cls = "", text = ""): HTMLElementTagNameMap[K] { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text) e.textContent = text; + return e; +} + +export function loadJSON(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw ? { ...fallback, ...JSON.parse(raw) } : fallback; + } catch { + return fallback; + } +} + +export function saveJSON(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* storage unavailable */ + } +} diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 79a9c0a..e7ba1c0 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -1,35 +1,17 @@ // PGVN interpreter + in-game UI. -import { AudioSystem, type Volumes } from "./audio"; +import { AudioSystem } from "./audio"; import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, type Scope } from "./python"; import { Renderer, type ResolvedImage } from "./renderer"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { languageName, rememberLanguage, type Translator } from "./i18n"; import { naniText } from "./naninovel"; +import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util"; import type { CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, } from "./types"; -export interface SaveMeta { - slot: string; - title: string; - updated_at: string; -} - -export interface SaveApi { - list(): Promise; - get(slot: string): Promise; - put(slot: string, title: string, data: T): Promise; - remove(slot: string): Promise; -} - -interface Settings extends Volumes { - cps: number; // characters per second, 0 = instant - autoDelay: number; // seconds per 20 characters in auto mode -} - -const DEFAULT_SETTINGS: Settings = { cps: 45, autoDelay: 1.2, music: 0.7, sound: 0.8, voice: 1 }; -const SLOTS = 12; +export type { SaveApi, SaveMeta } from "./engine-util"; interface LabelRef { label: Label; @@ -37,32 +19,6 @@ interface LabelRef { index: number; } -class Restart extends Error {} - -function el(tag: K, cls = "", text = ""): HTMLElementTagNameMap[K] { - const e = document.createElement(tag); - if (cls) e.className = cls; - if (text) e.textContent = text; - return e; -} - -function loadJSON(key: string, fallback: T): T { - try { - const raw = localStorage.getItem(key); - return raw ? { ...fallback, ...JSON.parse(raw) } : fallback; - } catch { - return fallback; - } -} - -function saveJSON(key: string, value: unknown) { - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch { - /* storage unavailable */ - } -} - export class Engine { private labels = new Map(); private anchors = new Map(); diff --git a/frontend/tests/ui.test.mjs b/frontend/tests/ui.test.mjs new file mode 100644 index 0000000..4380b28 --- /dev/null +++ b/frontend/tests/ui.test.mjs @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { build } from "esbuild"; + +// Bundle React together with the code under test so that both share one React instance. +async function load(entry) { + const result = await build({ + stdin: { contents: entry, resolveDir: fileURLToPath(new URL("../src/", import.meta.url)), loader: "tsx" }, + bundle: true, write: false, format: "esm", platform: "node", jsx: "automatic", logLevel: "silent", + banner: { js: "import { createRequire } from \"node:module\"; const require = createRequire(process.cwd() + \"/package.json\");" }, + }); + const code = Buffer.from(result.outputFiles[0].text).toString("base64"); + return import(`data:text/javascript;base64,${code}`); +} + +const { renderToStaticMarkup, h, ProgressBar, highlight } = await load(` + export { renderToStaticMarkup } from "react-dom/server"; + export { createElement as h } from "react"; + export { ProgressBar, highlight } from "./pages/TranslationParts"; +`); + +const stats = { + lines: 10, translated: 5, progress: 0.5, strings: 4, stringsTranslated: 2, + states: { untranslated: 5, outdated: 1, translated: 3, fuzzy: 0, machine: 0, approved: 1 }, +}; + +test("ProgressBar renders counts and only non-empty segments", () => { + const html = renderToStaticMarkup(h(ProgressBar, { stats })); + assert.match(html, /5 из 10 реплик \(50%\)/); + assert.match(html, /устарело 1/); + assert.match(html, /seg-approved/); + assert.doesNotMatch(html, /seg-warn/); +}); + +test("highlight wraps glossary terms case-insensitively and escapes regex characters", () => { + const terms = [{ src: "Eileen", t: "Эйлин", note: "" }, { src: "a+b", t: "x", note: "" }]; + const html = renderToStaticMarkup(h("p", null, highlight("eileen met a+b", terms))); + assert.match(html, /eileen<\/mark>/); + assert.match(html, /a\+b<\/mark>/); + assert.equal(highlight("plain", []), "plain"); +});