diff --git a/backend/app/main.py b/backend/app/main.py index 858aa0a..00febb0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -8,7 +8,7 @@ from app.blobs import BLOB_ID_RE, blob_path from app.bootstrap import bootstrap from app.config import get_settings from app.db import SessionLocal -from app.routers import auth, games, roles, users, versions +from app.routers import auth, games, i18n, metadata, roles, users, versions logging.basicConfig(level=logging.INFO) @@ -24,7 +24,7 @@ async def lifespan(_: FastAPI): app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json") -for r in (auth, users, roles, games, versions): +for r in (auth, users, roles, games, versions, i18n, metadata): app.include_router(r.router) diff --git a/backend/app/routers/games.py b/backend/app/routers/games.py index 79a01cd..4bba49e 100644 --- a/backend/app/routers/games.py +++ b/backend/app/routers/games.py @@ -1,158 +1,165 @@ -import io -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException, Request, status -from PIL import Image, UnidentifiedImageError -from sqlalchemy import 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 - -router = APIRouter(prefix="/api/games", tags=["games"]) -Player = Annotated[User, Depends(require("games.play"))] -Uploader = Annotated[User, Depends(require("games.upload"))] - -MAX_COVER_BYTES = 20 * 1024 * 1024 - - -def visible_versions_filter(user: User): - """Versions a user can see: ready ones, plus their own in any state.""" - if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"): - return true() - return or_(GameVersion.status == "ready", GameVersion.owner_id == user.id) - - -async def get_game(db: DB, game_id: int) -> Game: - game = await db.get(Game, game_id) - if not game: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Игра не найдена") - return game - - -def can_edit_game(user: User, game: Game) -> bool: - return has_perm(user, "games.edit") or ( - has_perm(user, "games.upload") and game.created_by_id == user.id - ) - - -@router.get("", response_model=list[GameOut]) -async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[dict]: - stmt = select(Game).order_by(Game.title) - if q.strip(): - pattern = f"%{q.strip()}%" - stmt = stmt.where( - or_( - Game.title.ilike(pattern), - Game.description.ilike(pattern), - Game.tags.any(q.strip().lower()), - ) - ) - if tag: - stmt = stmt.where(Game.tags.any(tag.lower())) - stmt = stmt.where( - exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user)) - | (Game.created_by_id == user.id) - ) - games = list((await db.scalars(stmt.limit(500))).all()) - counts: dict[int, dict[str, int]] = {} - if games: - rows = await db.execute( - select(GameVersion.game_id, GameVersion.status, func.count()) - .where(GameVersion.game_id.in_([g.id for g in games]), visible_versions_filter(user)) - .group_by(GameVersion.game_id, GameVersion.status) - ) - for game_id, st, n in rows: - key = "ready" if st == "ready" else "failed" if st == "failed" else "pending" - counts.setdefault(game_id, {}).setdefault(key, 0) - counts[game_id][key] += n - return [ - { - **GameOut.model_validate(g).model_dump(), - "ready_versions": counts.get(g.id, {}).get("ready", 0), - "pending_versions": counts.get(g.id, {}).get("pending", 0), - "failed_versions": counts.get(g.id, {}).get("failed", 0), - } - for g in games - ] - - -@router.post("", response_model=GameOut, status_code=201) -async def create_game(body: GameIn, user: Uploader, db: DB) -> Game: - game = Game( - title=body.title.strip(), - description=body.description, - tags=sorted({t.strip().lower() for t in body.tags if t.strip()}), - created_by_id=user.id, - ) - db.add(game) - await db.commit() - return game - - -@router.get("/{game_id}", response_model=GameDetailOut) -async def game_detail(game_id: int, user: Player, db: DB) -> dict: - game = await get_game(db, game_id) - versions = ( - await db.scalars( - select(GameVersion) - .where(GameVersion.game_id == game.id, visible_versions_filter(user)) - .order_by(GameVersion.parent_id.is_not(None), GameVersion.created_at) - ) - ).all() - return {**GameOut.model_validate(game).model_dump(), "versions": list(versions)} - - -@router.patch("/{game_id}", response_model=GameOut) -async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: DB) -> Game: - game = await get_game(db, game_id) - if not can_edit_game(user, game): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") - if body.title is not None: - game.title = body.title.strip() - if body.description is not None: - game.description = body.description - if body.tags is not None: - game.tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) - await db.commit() - return game - - -@router.put("/{game_id}/cover", response_model=GameOut) -async def upload_cover(game_id: int, request: Request, user: CurrentUser, db: DB) -> Game: - game = await get_game(db, game_id) - if not can_edit_game(user, game): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") - data = await request.body() - if len(data) > MAX_COVER_BYTES: - raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл") - try: - game.cover_hash = await run_in_threadpool(make_cover, data) - except (UnidentifiedImageError, OSError): - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Не удалось прочитать изображение") - await db.commit() - return game - - -@router.delete("/{game_id}", status_code=204) -async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.delete"))], - db: DB) -> None: - game = await get_game(db, game_id) - version_ids = (await db.scalars(select(GameVersion.id).where(GameVersion.game_id == game.id))).all() - await db.delete(game) - await db.commit() - for vid in version_ids: - remove_upload(vid) - - -def make_cover(data: bytes) -> str: - img = Image.open(io.BytesIO(data)) - img.thumbnail((640, 640)) - if img.mode not in ("RGB", "RGBA"): - img = img.convert("RGBA") - out = io.BytesIO() - img.save(out, "WEBP", quality=85) - return put_bytes(out.getvalue(), ".webp")[0] +import io +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from PIL import Image, UnidentifiedImageError +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 + +router = APIRouter(prefix="/api/games", tags=["games"]) +Player = Annotated[User, Depends(require("games.play"))] +Uploader = Annotated[User, Depends(require("games.upload"))] + +MAX_COVER_BYTES = 20 * 1024 * 1024 + + +def visible_versions_filter(user: User): + """Versions a user can see: ready published ones (reviewers also see drafts), + plus their own in any state.""" + if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"): + return true() + ready = GameVersion.status == "ready" + if not has_perm(user, "versions.publish"): + ready = and_(ready, GameVersion.visibility == "published") + return or_(ready, GameVersion.owner_id == user.id) + + +async def get_game(db: DB, game_id: int) -> Game: + game = await db.get(Game, game_id) + if not game: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Игра не найдена") + return game + + +def can_edit_game(user: User, game: Game) -> bool: + return has_perm(user, "games.edit") or ( + has_perm(user, "games.upload") and game.created_by_id == user.id + ) + + +@router.get("", response_model=list[GameOut]) +async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[dict]: + stmt = select(Game).order_by(Game.title) + if q.strip(): + pattern = f"%{q.strip()}%" + stmt = stmt.where( + or_( + Game.title.ilike(pattern), + Game.description.ilike(pattern), + Game.tags.any(q.strip().lower()), + ) + ) + if tag: + stmt = stmt.where(Game.tags.any(tag.lower())) + stmt = stmt.where( + exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user)) + | (Game.created_by_id == user.id) + ) + games = list((await db.scalars(stmt.limit(500))).all()) + counts: dict[int, dict[str, int]] = {} + if games: + rows = await db.execute( + select(GameVersion.game_id, GameVersion.status, func.count()) + .where(GameVersion.game_id.in_([g.id for g in games]), visible_versions_filter(user)) + .group_by(GameVersion.game_id, GameVersion.status) + ) + for game_id, st, n in rows: + key = "ready" if st == "ready" else "failed" if st == "failed" else "pending" + counts.setdefault(game_id, {}).setdefault(key, 0) + counts[game_id][key] += n + return [ + { + **GameOut.model_validate(g).model_dump(), + "ready_versions": counts.get(g.id, {}).get("ready", 0), + "pending_versions": counts.get(g.id, {}).get("pending", 0), + "failed_versions": counts.get(g.id, {}).get("failed", 0), + } + for g in games + ] + + +@router.post("", response_model=GameOut, status_code=201) +async def create_game(body: GameIn, user: Uploader, db: DB) -> Game: + game = Game( + title=body.title.strip(), + description=body.description, + tags=sorted({t.strip().lower() for t in body.tags if t.strip()}), + created_by_id=user.id, + ) + db.add(game) + await db.commit() + return game + + +@router.get("/{game_id}", response_model=GameDetailOut) +async def game_detail(game_id: int, user: Player, db: DB) -> dict: + game = await get_game(db, game_id) + versions = ( + await db.scalars( + select(GameVersion) + .where(GameVersion.game_id == game.id, visible_versions_filter(user)) + .order_by(GameVersion.parent_id.is_not(None), GameVersion.created_at) + ) + ).all() + return {**GameOut.model_validate(game).model_dump(), + "versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest)} + for v in versions]} + + +@router.patch("/{game_id}", response_model=GameOut) +async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: DB) -> Game: + game = await get_game(db, game_id) + if not can_edit_game(user, game): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") + if body.title is not None: + game.title = body.title.strip() + if body.description is not None: + game.description = body.description + if body.tags is not None: + game.tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) + await db.commit() + return game + + +@router.put("/{game_id}/cover", response_model=GameOut) +async def upload_cover(game_id: int, request: Request, user: CurrentUser, db: DB) -> Game: + game = await get_game(db, game_id) + if not can_edit_game(user, game): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") + data = await request.body() + if len(data) > MAX_COVER_BYTES: + raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл") + try: + game.cover_hash = await run_in_threadpool(make_cover, data) + except (UnidentifiedImageError, OSError): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Не удалось прочитать изображение") + await db.commit() + return game + + +@router.delete("/{game_id}", status_code=204) +async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.delete"))], + db: DB) -> None: + game = await get_game(db, game_id) + version_ids = (await db.scalars(select(GameVersion.id).where(GameVersion.game_id == game.id))).all() + await db.delete(game) + await db.commit() + for vid in version_ids: + remove_upload(vid) + + +def make_cover(data: bytes) -> str: + img = Image.open(io.BytesIO(data)) + img.thumbnail((640, 640)) + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGBA") + out = io.BytesIO() + img.save(out, "WEBP", quality=85) + return put_bytes(out.getvalue(), ".webp")[0] diff --git a/backend/app/routers/i18n.py b/backend/app/routers/i18n.py new file mode 100644 index 0000000..4cf77df --- /dev/null +++ b/backend/app/routers/i18n.py @@ -0,0 +1,246 @@ +"""Translation editing API for a version.""" + +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import Response +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool + +from app import translation as tr +from app.blobs import put_bytes +from app.converter.locales import LANG_NAMES +from app.deps import DB, has_perm, require +from app.models import Game, GameVersion, User +from app.routers.versions import can_manage, get_version, lock_version + +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 +MAX_IMPORT_BYTES = 50 * 1024 * 1024 + + +def can_edit(user: User, v: GameVersion) -> bool: + """Drafts are edited by their owner; publishers may also fix published versions.""" + if not has_perm(user, "translations.edit"): + return False + if has_perm(user, "versions.publish"): + return True + return v.visibility == "draft" and can_manage(user, v) + + +async def ready_version(db: DB, user: User, version_id: int) -> GameVersion: + v = await get_version(db, user, version_id) + if v.status != "ready" or not v.manifest: + raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") + return v + + +async def editable_version(db: DB, user: User, version_id: int) -> GameVersion: + await ready_version(db, user, version_id) + v = await lock_version(db, version_id) + if not can_edit(user, v): + raise HTTPException(status.HTTP_403_FORBIDDEN, + "Переводить можно в своём черновике (форке «перевод»)") + return v + + +def check_lang(lang: str) -> str: + import re + if not re.match(LANG_RE, lang): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Код языка в формате BCP-47, например ru") + return lang + + +@router.get("") +async def overview(version_id: int, user: Player, db: DB) -> dict[str, Any]: + v = await ready_version(db, user, version_id) + + def build() -> dict[str, Any]: + game = tr.file_json(v.manifest, "game.json") + idx = tr.text_index(v.manifest) + langs = [] + for code, info in sorted((game.get("locales") or {}).items()): + langs.append({"code": code, "name": info.get("name", code), "font": info.get("font"), + **tr.stats(v.manifest, code)}) + return { + "sourceLang": game.get("sourceLang"), + "editable": can_edit(user, v), + "canApprove": has_perm(user, "versions.publish"), + "lines": len(idx.lines), + "strings": len(idx.strings), + "modules": [{"path": p, "lines": n} for p, n in idx.modules if n], + "languages": langs, + "knownLanguages": LANG_NAMES, + } + + return await run_in_threadpool(build) + + +@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]: + 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, + "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) + + +@router.get("/{lang}/meta") +async def get_meta(version_id: int, lang: str, user: Player, db: DB) -> dict[str, Any]: + v = await ready_version(db, user, version_id) + _, _, meta = await run_in_threadpool(tr.locale_tables, v.manifest, lang) + return {"glossary": meta.get("glossary", []), "font": meta.get("font")} + + +class Changes(BaseModel): + name: str | None = Field(default=None, max_length=64) + lines: dict[str, dict[str, Any] | None] = {} + strings: dict[str, dict[str, Any] | None] = {} + glossary: list[dict[str, str]] | None = None + + +@router.put("/{lang}") +async def save(version_id: int, lang: str, body: Changes, user: Translator, db: DB + ) -> dict[str, Any]: + """Create a language or change entries. `null` removes an entry.""" + check_lang(lang) + v = await editable_version(db, user, version_id) + meta = None + if body.glossary is not None: + meta = {"glossary": [{"src": g.get("src", "").strip(), "t": g.get("t", "").strip(), + "note": g.get("note", "").strip()} + for g in body.glossary if g.get("src", "").strip()]} + try: + manifest = await run_in_threadpool( + lambda: tr.apply_changes(v.manifest, lang, lines=body.lines, strings=body.strings, + meta=meta, name=body.name, + can_approve=has_perm(user, "versions.publish"))) + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from None + v.manifest = manifest + await db.commit() + return await run_in_threadpool(tr.stats, manifest, lang) + + +@router.delete("/{lang}", status_code=204) +async def remove_language(version_id: int, lang: str, user: Translator, db: DB) -> None: + v = await editable_version(db, user, version_id) + files = {p: f for p, f in v.manifest["files"].items() if not p.startswith(f"locales/{lang}/")} + game = dict(tr.file_json(v.manifest, "game.json")) + game["locales"] = {k: i for k, i in (game.get("locales") or {}).items() if k != lang} + tr.put_json(files, "game.json", game) + v.manifest = {**v.manifest, "files": files} + await db.commit() + + +@router.put("/{lang}/font") +async def upload_font(version_id: int, lang: str, filename: str, request: Request, + user: Translator, db: DB) -> dict[str, Any]: + """A font for this language, for games whose fonts lack its glyphs.""" + check_lang(lang) + ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else "" + if ext not in FONT_EXTS: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Шрифт: {', '.join(FONT_EXTS)}") + data = await request.body() + if not data or len(data) > MAX_FONT_BYTES: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой или слишком большой файл") + v = await editable_version(db, user, version_id) + path = f"locales/{lang}/font{ext}" + + def store() -> dict[str, Any]: + blob_id, size = put_bytes(data, ext) + manifest = {**v.manifest, "files": {**v.manifest["files"], path: {"hash": blob_id, "size": size}}} + return tr.apply_changes(manifest, lang, meta={"font": path}) + + v.manifest = await run_in_threadpool(store) + await db.commit() + return {"font": path} + + +@router.get("/{lang}/export") +async def export(version_id: int, lang: str, user: Player, db: DB, format: str = "po") -> Response: + v = await ready_version(db, user, version_id) + game = await db.get(Game, v.game_id) + if format == "csv": + body = await run_in_threadpool(tr.export_csv, v.manifest, lang) + media, ext = "text/csv", "csv" + else: + body = await run_in_threadpool(tr.export_po, v.manifest, lang, game.title) + media, ext = "text/x-gettext-translation", "po" + name = f"{game.id}-{v.id}-{lang}.{ext}" + return Response("" + body if ext == "csv" else body, media_type=f"{media}; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{name}"'}) + + +@router.post("/{lang}/import") +async def import_file(version_id: int, lang: str, request: Request, user: Translator, db: DB, + format: str = "po", overwrite: bool = True) -> dict[str, Any]: + check_lang(lang) + data = await request.body() + if len(data) > MAX_IMPORT_BYTES: + raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл") + text = data.decode("utf-8-sig", errors="replace") + lines, strings = tr.parse_csv(text) if format == "csv" else tr.parse_po(text) + v = await editable_version(db, user, version_id) + if not overwrite: + have_lines, have_strings, _ = tr.locale_tables(v.manifest, lang) + lines = {k: x for k, x in lines.items() if not (have_lines.get(k) or {}).get("t")} + strings = {k: x for k, x in strings.items() if not (have_strings.get(k) or {}).get("t")} + try: + v.manifest = await run_in_threadpool( + lambda: tr.apply_changes(v.manifest, lang, lines=lines, strings=strings, + can_approve=has_perm(user, "versions.publish"))) + except ValueError as e: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from None + await db.commit() + return {"lines": len(lines), "strings": len(strings), **tr.stats(v.manifest, lang)} diff --git a/backend/app/routers/versions.py b/backend/app/routers/versions.py index 5549d7a..b98df71 100644 --- a/backend/app/routers/versions.py +++ b/backend/app/routers/versions.py @@ -5,9 +5,13 @@ from sqlalchemy import select from starlette.requests import ClientDisconnect from app.deps import DB, CurrentUser, has_perm, require -from app.models import GameVersion, Save, User +from app.models import Game, GameVersion, Save, User, VersionHistory from app.routers.games import get_game -from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut +from app.schemas import ( + 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"]) @@ -18,7 +22,8 @@ ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ". def can_see(user: User, v: GameVersion) -> bool: - return v.status == "ready" or v.owner_id == user.id or can_manage(user, v) + published = v.visibility == "published" or has_perm(user, "versions.publish") + return (v.status == "ready" and published) or v.owner_id == user.id or can_manage(user, v) def can_manage(user: User, v: GameVersion) -> bool: @@ -93,9 +98,12 @@ async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, A @router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201) -async def fork_version(version_id: int, body: VersionIn, - user: Annotated[User, Depends(require("games.play", "versions.fork"))], +async def fork_version(version_id: int, body: ForkIn, + user: Annotated[User, Depends(require("games.play"))], db: DB) -> GameVersion: + needed = "translations.edit" if body.kind == "translation" else "versions.fork" + if not has_perm(user, needed): + raise HTTPException(status.HTTP_403_FORBIDDEN, f"Нет прав: {needed}") parent = await get_version(db, user, version_id) if parent.status != "ready": raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию") @@ -106,6 +114,8 @@ async def fork_version(version_id: int, body: VersionIn, name=body.name.strip(), description=body.description, status="ready", + visibility="draft", + kind=body.kind, manifest=parent.manifest, report=parent.report, size_bytes=parent.size_bytes, @@ -115,6 +125,78 @@ async def fork_version(version_id: int, body: VersionIn, return fork +Publisher = Annotated[User, Depends(require("versions.publish"))] + + +async def lock_version(db: DB, version_id: int) -> GameVersion: + """Row-locked version for read-modify-write of its manifest.""" + v = await db.scalar(select(GameVersion).where(GameVersion.id == version_id) + .with_for_update(of=GameVersion) + .execution_options(populate_existing=True)) + if v is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена") + return v + + +@router.post("/versions/{version_id}/publish", response_model=VersionOut) +async def publish_version(version_id: int, body: PublishIn, user: Publisher, db: DB) -> GameVersion: + """Publish a draft. A translation is merged into the game's main version in place, + so players keep their saves; the previous manifest goes to the history.""" + v = await get_version(db, user, version_id) + if v.status != "ready": + raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") + game = await db.get(Game, v.game_id) + if v.kind == "translation" and game.main_version_id and game.main_version_id != v.id: + main = await lock_version(db, game.main_version_id) + parent = await db.get(GameVersion, v.parent_id) if v.parent_id else None + langs = translation.changed_languages(v.manifest, parent.manifest if parent else None) + if not langs: + raise HTTPException(status.HTTP_409_CONFLICT, "В версии нет изменённых переводов") + db.add(VersionHistory(version_id=main.id, user_id=user.id, manifest=main.manifest, + note=f"Перевод из «{v.name}»: {', '.join(langs)}")) + main.manifest = translation.merge_languages(main.manifest, v.manifest, langs) + elif body.make_main: + game.main_version_id = v.id + v.visibility = "published" + await db.commit() + await db.refresh(v) + return v + + +@router.post("/versions/{version_id}/make-main", response_model=VersionOut) +async def make_main(version_id: int, user: Publisher, db: DB) -> GameVersion: + v = await get_version(db, user, version_id) + if v.status != "ready": + raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") + game = await db.get(Game, v.game_id) + game.main_version_id = v.id + v.visibility = "published" + await db.commit() + await db.refresh(v) + return v + + +@router.get("/versions/{version_id}/history", response_model=list[HistoryOut]) +async def version_history(version_id: int, user: Player, db: DB) -> list[VersionHistory]: + await get_version(db, user, version_id) + return list((await db.scalars(select(VersionHistory).where(VersionHistory.version_id == version_id) + .order_by(VersionHistory.id.desc()))).all()) + + +@router.post("/versions/{version_id}/history/{history_id}/restore", response_model=VersionOut) +async def restore_history(version_id: int, history_id: int, user: Publisher, db: DB) -> GameVersion: + v = await lock_version(db, version_id) + h = await db.get(VersionHistory, history_id) + if h is None or h.version_id != v.id: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Запись истории не найдена") + db.add(VersionHistory(version_id=v.id, user_id=user.id, manifest=v.manifest, + note=f"До отката к записи от {h.created_at:%d.%m.%Y %H:%M}")) + v.manifest = h.manifest + await db.commit() + await db.refresh(v) + return v + + @router.patch("/versions/{version_id}", response_model=VersionOut) async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion: v = await get_version(db, user, version_id) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index fed2309..0605ade 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -76,6 +76,11 @@ class VersionOut(ORM): progress: str size_bytes: int created_at: datetime + visibility: str = "published" + kind: str = "original" + # from the version's game.json (filled by the game detail endpoint) + source_lang: str | None = None + languages: list[dict[str, Any]] = [] class VersionDetailOut(VersionOut): @@ -90,6 +95,7 @@ class GameOut(ORM): engine: str cover_hash: str | None created_at: datetime + main_version_id: int | None = None # version counts visible to the requesting user (filled by the list endpoint) ready_versions: int = 0 pending_versions: int = 0 @@ -117,6 +123,20 @@ class VersionIn(BaseModel): description: str = "" +class ForkIn(VersionIn): + kind: str = Field(default="mod", pattern="^(mod|translation)$") + + +class PublishIn(BaseModel): + make_main: bool = False + + +class HistoryOut(ORM): + id: int + note: str + created_at: datetime + + class SaveIn(BaseModel): title: str = "" data: dict[str, Any] diff --git a/backend/app/translation.py b/backend/app/translation.py new file mode 100644 index 0000000..af4590a --- /dev/null +++ b/backend/app/translation.py @@ -0,0 +1,420 @@ +"""Server-side work with a version's translations. + +Everything here reads immutable blobs, so parsed JSON and the text index are +cached by blob hash and never go stale. Writes produce new blobs and return a +new manifest; the caller stores it on the version. +""" + +import copy +import csv +import io +import json +import re +from dataclasses import dataclass, field +from functools import lru_cache +from pathlib import PurePosixPath +from typing import Any + +from app.blobs import put_bytes, read_bytes +from app.converter.locales import LANG_NAMES, RENPY_LANGS, short_hash, walk + +STATES = ("translated", "fuzzy", "machine", "approved") + + +@lru_cache(maxsize=512) +def blob_json(blob_id: str) -> Any: + """Parsed JSON of a blob. Shared between callers: treat as read-only.""" + return json.loads(read_bytes(blob_id)) + + +def file_json(manifest: dict[str, Any], path: str) -> Any: + f = manifest["files"].get(path) + return blob_json(f["hash"]) if f else None + + +def put_json(files: dict[str, Any], path: str, value: Any) -> None: + payload = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode() + blob_id, size = put_bytes(payload, ".json") + files[path] = {"hash": blob_id, "size": size} + + +# --- text index ------------------------------------------------------------------ + +@dataclass +class LineInfo: + tid: str + node_id: str + module: str # script path in the manifest + label: str + who: str + source: str + scene: str | None # logical path of the background shown at this line + prev: str | None = None + next: str | None = None + + +@dataclass +class TextIndex: + lines: list[LineInfo] = field(default_factory=list) + by_tid: dict[str, LineInfo] = field(default_factory=dict) + strings: dict[str, str] = field(default_factory=dict) # source text -> usage + modules: list[tuple[str, int]] = field(default_factory=list) + + +def text_index(manifest: dict[str, Any]) -> TextIndex: + game_hash = manifest["files"]["game.json"]["hash"] + game = blob_json(game_hash) + scripts = tuple((p, manifest["files"][p]["hash"]) for p in game["scripts"]) + return _build_index(game_hash, scripts) + + +@lru_cache(maxsize=16) +def _build_index(game_hash: str, scripts: tuple[tuple[str, str], ...]) -> TextIndex: + game = blob_json(game_hash) + chars = game.get("characters", {}) + images = game.get("images", {}) + idx = TextIndex() + for c in chars.values(): + if isinstance(c.get("name"), str) and c["name"].strip(): + idx.strings.setdefault(c["name"], "name") + for path, blob in scripts: + module = blob_json(blob) + count = 0 + for label in module["labels"]: + scene: str | None = None + group = label["name"] + for node in walk(label["body"]): + op = node.get("op") + if op == "label": + group = node["name"] + elif op == "scene": + scene = _image_file(images, node.get("img")) or (None if node.get("img") else scene) + elif op == "menu": + for item in node["items"]: + idx.strings.setdefault(item["text"], "menu") + elif op == "show" and isinstance((node.get("img") or {}).get("text"), str): + idx.strings.setdefault(node["img"]["text"], "text") + elif op == "say" and node.get("tid"): + who = node.get("whoStr") or "" + if not who and node.get("who"): + c = chars.get(node["who"], {}) + who = c.get("name") if isinstance(c.get("name"), str) else node["who"] + info = LineInfo(node["tid"], node.get("id", ""), path, group, who or "", + node["what"], scene) + if idx.lines and idx.lines[-1].module == path: + idx.lines[-1].next = info.source + info.prev = idx.lines[-1].source + idx.lines.append(info) + idx.by_tid[info.tid] = info + count += 1 + idx.modules.append((path, count)) + return idx + + +def _image_file(images: dict[str, Any], img: dict[str, Any] | None) -> str | None: + if not img or not img.get("name"): + return None + name = img["name"] + for n in range(len(name), 0, -1): + d = images.get(" ".join(name[:n])) + if d: + return d.get("file") or (d.get("frames") or [{}])[0].get("img", {}).get("file") + return None + + +def version_languages(manifest: dict[str, Any] | None) -> dict[str, Any]: + """Source language and translations of a version, for listings.""" + if not manifest or "game.json" not in manifest.get("files", {}): + return {"source_lang": None, "languages": []} + game = file_json(manifest, "game.json") + return { + "source_lang": game.get("sourceLang"), + "languages": [{"code": code, "name": info.get("name", code), + "progress": info.get("progress", 0)} + for code, info in sorted((game.get("locales") or {}).items())], + } + + +# --- reading locales ------------------------------------------------------------------ + +def locale_tables(manifest: dict[str, Any], lang: str) -> tuple[dict, dict, dict]: + """(lines by tid, strings by source text, meta) of a language; empty if absent.""" + game = file_json(manifest, "game.json") + info = (game.get("locales") or {}).get(lang) + if not info: + return {}, {}, {} + lines: dict[str, Any] = {} + for path in info.get("modules", {}).values(): + lines.update(file_json(manifest, path) or {}) + strings = file_json(manifest, info["strings"]) or {} if info.get("strings") else {} + meta = file_json(manifest, info["meta"]) or {} if info.get("meta") else {} + return lines, strings, meta + + +def line_state(entry: dict[str, Any] | None, info: LineInfo) -> str: + if not entry or not entry.get("t"): + return "untranslated" + if entry.get("src") and entry["src"] != short_hash(info.source): + return "outdated" + return entry.get("state", "translated") + + +def stats(manifest: dict[str, Any], lang: str) -> dict[str, Any]: + idx = text_index(manifest) + lines, strings, _ = locale_tables(manifest, lang) + counts = {s: 0 for s in ("untranslated", "outdated", *STATES)} + for info in idx.lines: + counts[line_state(lines.get(info.tid), info)] += 1 + total = len(idx.lines) or 1 + done = len(idx.lines) - counts["untranslated"] + return { + "lines": len(idx.lines), + "translated": done, + "progress": round(done / total, 3), + "states": counts, + "strings": len(idx.strings), + "stringsTranslated": sum(1 for s in idx.strings if (strings.get(s) or {}).get("t")), + } + + +# --- writing ----------------------------------------------------------------------------- + +def _locale_path(lang: str, script_path: str) -> str: + return f"locales/{lang}/" + str(PurePosixPath(script_path).relative_to("script")) + + +def renpy_name(lang: str) -> str: + return next((name for name, code in RENPY_LANGS.items() if code == lang), lang) + + +def apply_changes(manifest: dict[str, Any], lang: str, *, lines: dict[str, Any] | None = None, + strings: dict[str, Any] | None = None, meta: dict[str, Any] | None = None, + name: str | None = None, can_approve: bool = False) -> dict[str, Any]: + """Return a new manifest with the given entries changed (``None`` removes an entry).""" + manifest = {**manifest, "files": dict(manifest["files"])} + files = manifest["files"] + game = copy.deepcopy(file_json(manifest, "game.json")) + if lang == game.get("sourceLang"): + raise ValueError("Это исходный язык игры") + idx = text_index({"files": files}) + locales = game.setdefault("locales", {}) + info = locales.setdefault(lang, {"name": name or LANG_NAMES.get(lang, lang), + "renpy": renpy_name(lang), "modules": {}}) + if name: + info["name"] = name + + def entry(value: dict[str, Any], src: str | None) -> dict[str, Any] | None: + text = (value.get("t") or "").strip("\n") + if not text.strip(): + return None + state = value.get("state") or "translated" + if state not in STATES or (state == "approved" and not can_approve): + state = "translated" + e = {"t": text, "state": state} + if src is not None: + e["src"] = short_hash(src) + return e + + if lines: + by_module: dict[str, dict[str, Any]] = {} + for tid, value in lines.items(): + line = idx.by_tid.get(tid) + if line is None: + continue + by_module.setdefault(line.module, {})[tid] = value + for module, changes in by_module.items(): + path = info["modules"].get(module) or _locale_path(lang, module) + table = dict(file_json(manifest, path) or {}) if path in files else {} + for tid, value in changes.items(): + e = entry(value, idx.by_tid[tid].source) if value else None + if e: + table[tid] = e + else: + table.pop(tid, None) + put_json(files, path, table) + info["modules"][module] = path + + if strings: + path = info.get("strings") or f"locales/{lang}/strings.json" + table = dict(file_json(manifest, path) or {}) if path in files else {} + for src, value in strings.items(): + e = entry(value, None) if value else None + if e: + table[src] = e + else: + table.pop(src, None) + put_json(files, path, table) + info["strings"] = path + + if meta is not None: + path = f"locales/{lang}/meta.json" + current = dict(file_json(manifest, path) or {}) if path in files else {} + current.update(meta) + put_json(files, path, current) + info["meta"] = path + info["font"] = current.get("font") + + info.setdefault("strings", None) + # stats read the locale index from game.json, so store it before counting + put_json(files, "game.json", game) + st = stats(manifest, lang) + info["lines"] = st["translated"] + info["progress"] = st["progress"] + info["strings_count"] = st["stringsTranslated"] + put_json(files, "game.json", game) + return manifest + + +def merge_languages(target: dict[str, Any], source: dict[str, Any], langs: list[str] + ) -> dict[str, Any]: + """Copy whole languages from one manifest into another (publishing a translation).""" + target = {**target, "files": dict(target["files"])} + files = target["files"] + game = copy.deepcopy(file_json(target, "game.json")) + src_game = file_json(source, "game.json") + for lang in langs: + info = (src_game.get("locales") or {}).get(lang) + if not info: + continue + prefix = f"locales/{lang}/" + for path in [p for p in files if p.startswith(prefix)]: + del files[path] + for path, f in source["files"].items(): + if path.startswith(prefix): + files[path] = f + game.setdefault("locales", {})[lang] = copy.deepcopy(info) + put_json(files, "game.json", game) + for lang in langs: + if lang in game["locales"]: + st = stats(target, lang) # target scripts may differ from the fork's + game["locales"][lang].update(lines=st["translated"], progress=st["progress"]) + put_json(files, "game.json", game) + return target + + +def changed_languages(fork: dict[str, Any], base: dict[str, Any] | None) -> list[str]: + """Languages whose files differ between a fork and its base.""" + fork_locales = (file_json(fork, "game.json").get("locales") or {}) + if base is None: + return list(fork_locales) + out = [] + for lang in fork_locales: + prefix = f"locales/{lang}/" + mine = {p: f["hash"] for p, f in fork["files"].items() if p.startswith(prefix)} + theirs = {p: f["hash"] for p, f in base["files"].items() if p.startswith(prefix)} + if mine != theirs: + out.append(lang) + return out + + +# --- PO / CSV -------------------------------------------------------------------------- + +def _po_quote(s: str) -> str: + s = s.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t") + if "\n" not in s: + return f'"{s}"' + parts = s.split("\n") + return '""\n' + "\n".join(f'"{p}\\n"' if i < len(parts) - 1 else f'"{p}"' + for i, p in enumerate(parts) if p or i < len(parts) - 1) + + +def export_po(manifest: dict[str, Any], lang: str, game_title: str) -> str: + idx = text_index(manifest) + lines, strings, _ = locale_tables(manifest, lang) + out = [f'# {game_title}', 'msgid ""', 'msgstr ""', + '"Content-Type: text/plain; charset=UTF-8\\n"', f'"Language: {lang}\\n"', ""] + for info in idx.lines: + e = lines.get(info.tid) or {} + state = line_state(e, info) + out.append(f"#. {info.who}" if info.who else "#.") + out.append(f"#: {info.module} {info.label}") + if state in ("outdated", "fuzzy", "machine"): + out.append("#, fuzzy") + out += [f"msgctxt {_po_quote(info.tid)}", f"msgid {_po_quote(info.source)}", + 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)}", + f"msgstr {_po_quote(e.get('t', ''))}", ""] + return "\n".join(out) + + +_PO_LINE = re.compile(r'^(msgctxt|msgid|msgstr)\s+(".*")\s*$|^(".*")\s*$') + + +def _po_unquote(s: str) -> str: + body = s[1:-1] + return re.sub(r"\\(.)", lambda m: {"n": "\n", "t": "\t"}.get(m.group(1), m.group(1)), body) + + +def parse_po(text: str) -> tuple[dict[str, Any], dict[str, Any]]: + """Returns (lines by tid, strings by source) from a PO file made by export_po.""" + lines: dict[str, Any] = {} + strings: dict[str, Any] = {} + cur: dict[str, str] = {} + fuzzy = False + last = None + + def flush(): + nonlocal cur, fuzzy + if cur.get("msgid") and "msgstr" in cur: + value = {"t": cur["msgstr"], "state": "fuzzy" if fuzzy else "translated"} + if cur.get("msgctxt") == "string": + strings[cur["msgid"]] = value + elif cur.get("msgctxt"): + lines[cur["msgctxt"]] = value + cur, fuzzy = {}, False + + for raw in text.splitlines(): + line = raw.strip() + if not line: + flush() + last = None + continue + if line.startswith("#,") and "fuzzy" in line: + fuzzy = True + continue + if line.startswith("#"): + continue + m = _PO_LINE.match(line) + if not m: + continue + if m.group(1): + if m.group(1) == "msgctxt" and "msgid" in cur: + flush() + last = m.group(1) + cur[last] = _po_unquote(m.group(2)) + elif last: + cur[last] += _po_unquote(m.group(3)) + flush() + return lines, strings + + +def export_csv(manifest: dict[str, Any], lang: str) -> str: + idx = text_index(manifest) + lines, strings, _ = locale_tables(manifest, lang) + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(["kind", "key", "who", "source", "translation", "state"]) + for info in idx.lines: + e = lines.get(info.tid) or {} + w.writerow(["line", info.tid, info.who, info.source, e.get("t", ""), line_state(e, info)]) + for src, usage in idx.strings.items(): + e = strings.get(src) or {} + w.writerow(["string", src, usage, src, e.get("t", ""), e.get("state", "")]) + return buf.getvalue() + + +def parse_csv(text: str) -> tuple[dict[str, Any], dict[str, Any]]: + lines: dict[str, Any] = {} + strings: dict[str, Any] = {} + for row in csv.DictReader(io.StringIO(text.lstrip("\ufeff"))): + if not row.get("translation"): + continue + state = row.get("state") if row.get("state") in STATES else "translated" + value = {"t": row["translation"], "state": state} + if row.get("kind") == "string": + strings[row["key"]] = value + elif row.get("kind") == "line": + lines[row["key"]] = value + return lines, strings diff --git a/backend/app/worker.py b/backend/app/worker.py index 9dec60f..ffd93ca 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -87,6 +87,8 @@ async def convert(version_id: int, source: Path, filename: str) -> bool: read_bytes(result.cover_source)) except OSError: pass + if game and game.main_version_id is None: + game.main_version_id = version.id if game and result.title and game.title == source.name: game.title = result.title await db.commit() diff --git a/backend/migrations/versions/0002_publishing.py b/backend/migrations/versions/0002_publishing.py new file mode 100644 index 0000000..2566cd4 --- /dev/null +++ b/backend/migrations/versions/0002_publishing.py @@ -0,0 +1,55 @@ +"""main version, drafts, version kinds and history + +Revision ID: 0002 +Revises: 0001 +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("game_versions", sa.Column("visibility", sa.String(16), nullable=False, + server_default="published")) + op.add_column("game_versions", sa.Column("kind", sa.String(16), nullable=False, + server_default="original")) + op.execute("UPDATE game_versions SET kind = 'mod' WHERE parent_id IS NOT NULL") + op.add_column("games", sa.Column("main_version_id", sa.Integer, + sa.ForeignKey("game_versions.id", ondelete="SET NULL"))) + # existing games: the oldest ready original becomes the main version + op.execute(""" + UPDATE games g SET main_version_id = ( + SELECT v.id FROM game_versions v + WHERE v.game_id = g.id AND v.status = 'ready' AND v.parent_id IS NULL + ORDER BY v.created_at LIMIT 1) + """) + op.create_table( + "version_history", + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("version_id", sa.Integer, sa.ForeignKey("game_versions.id", ondelete="CASCADE"), + nullable=False, index=True), + sa.Column("user_id", sa.Integer, sa.ForeignKey("users.id", ondelete="SET NULL")), + sa.Column("note", sa.String(255), nullable=False, server_default=""), + sa.Column("manifest", postgresql.JSONB, nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), + nullable=False), + ) + # the stock moderator role gets the new permissions + op.execute(""" + UPDATE roles SET permissions = array_cat(permissions, + ARRAY['translations.edit', 'versions.publish']::varchar[]) + WHERE name = 'moderator' AND is_system + AND NOT permissions @> ARRAY['translations.edit']::varchar[] + """) + + +def downgrade() -> None: + op.drop_table("version_history") + op.drop_column("games", "main_version_id") + op.drop_column("game_versions", "kind") + op.drop_column("game_versions", "visibility") diff --git a/backend/tests/test_translation.py b/backend/tests/test_translation.py new file mode 100644 index 0000000..1b1a45d --- /dev/null +++ b/backend/tests/test_translation.py @@ -0,0 +1,69 @@ +import json + +import pytest + +from app import translation as tr +from app.config import get_settings +from app.converter.pipeline import convert_archive +from tests.make_sample import build + + +@pytest.fixture +def manifest(tmp_path, monkeypatch): + monkeypatch.setenv("DATA_DIR", str(tmp_path / "data")) + get_settings.cache_clear() + archive = build(tmp_path / "sample.zip") + yield convert_archive(archive, "sample.zip", tmp_path / "work", lambda _: None).manifest + get_settings.cache_clear() + + +def test_index_has_context(manifest): + idx = tr.text_index(manifest) + first = idx.lines[0] + assert first.label == "start" and first.scene == "images/bg room.webp" + assert idx.lines[1].who == "Эйлин" and idx.lines[1].prev == first.source + assert idx.strings["Пойдём гулять"] == "menu" and idx.strings["Эйлин"] == "name" + + +def test_edit_new_language_and_outdated(manifest): + idx = tr.text_index(manifest) + tid = idx.lines[0].tid + m = tr.apply_changes(manifest, "de", lines={tid: {"t": "Willkommen", "state": "approved"}}, + strings={"Эйлин": {"t": "Eileen"}}, meta={"glossary": [{"src": "Эйлин", "t": "Eileen"}]}) + lines, strings, meta = tr.locale_tables(m, "de") + assert lines[tid]["t"] == "Willkommen" and lines[tid]["state"] == "translated" # no approve right + assert strings["Эйлин"]["t"] == "Eileen" and meta["glossary"][0]["t"] == "Eileen" + game = tr.file_json(m, "game.json") + assert game["locales"]["de"]["renpy"] == "german" and game["locales"]["de"]["lines"] == 1 + # the original line changed in a mod: the translation is kept but marked outdated + info = idx.by_tid[tid] + changed = tr.LineInfo(**{**info.__dict__, "source": "другой текст"}) + assert tr.line_state(lines[tid], changed) == "outdated" + # removing an entry + m2 = tr.apply_changes(m, "de", lines={tid: None}) + assert tid not in tr.locale_tables(m2, "de")[0] + with pytest.raises(ValueError): + tr.apply_changes(manifest, "ru", lines={tid: {"t": "x"}}) # source language + + +def test_merge_languages_keeps_target_untouched(manifest): + tid = tr.text_index(manifest).lines[0].tid + fork = tr.apply_changes(manifest, "de", lines={tid: {"t": "Hallo"}}) + assert tr.changed_languages(fork, manifest) == ["de"] + merged = tr.merge_languages(manifest, fork, ["de"]) + assert tr.locale_tables(merged, "de")[0][tid]["t"] == "Hallo" + assert tr.locale_tables(merged, "en")[0] == tr.locale_tables(manifest, "en")[0] + assert "de" not in (tr.file_json(manifest, "game.json").get("locales") or {}) + + +def test_po_and_csv_roundtrip(manifest): + tid = tr.text_index(manifest).lines[0].tid + m = tr.apply_changes(manifest, "de", lines={tid: {"t": "Zeile 1\nZeile \"2\""}}, + strings={"Эйлин": {"t": "Eileen"}}) + lines, strings = tr.parse_po(tr.export_po(m, "de", "Test")) + assert lines[tid]["t"] == "Zeile 1\nZeile \"2\"" and strings["Эйлин"]["t"] == "Eileen" + lines, strings = tr.parse_csv(tr.export_csv(m, "de")) + assert lines[tid]["t"] == "Zeile 1\nZeile \"2\"" and strings["Эйлин"]["t"] == "Eileen" + st = tr.stats(m, "de") + assert st["translated"] == 1 and st["stringsTranslated"] == 1 + assert json.dumps(st) diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index d0fe628..835a397 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -164,6 +164,37 @@ translate russian strings: авторы ещё не перевели, а ~750 блоков на язык относятся к уже изменённым английским репликам. Ren'Py их тоже не показывает. +Этапы 2 и 3 сделаны: + +* у игры есть главная версия (`games.main_version_id`), у версий — + `visibility` (`draft`/`published`) и `kind` (`original`/`translation`/`mod`); + миграция `0002` проставила главную версию существующим играм; +* права `translations.edit` и `versions.publish`, у системной роли + moderator они включены; +* кнопка «Перевести» создаёт черновик-форк вида «перевод», «Опубликовать» + вливает изменённые языки **в саму главную версию**: её ID не меняется, + сохранения игроков остаются. Прежний манифест пишется в `version_history` + (`GET /api/versions/{id}/history`, откат — + `POST …/history/{hid}/restore`); +* редактор перевода `/versions/{id}/translate`: + * языки с прогрессом по состояниям, реплики и строки (имена, меню, текст + на экране); + * фильтры «к работе / не переведено / устарело / неточно / машинный / + переведено / одобрено», поиск, фильтр по файлу скрипта; + * автосохранение, Ctrl+Enter — к следующей строке; + * кнопки «одобрить» (только у публикующих), «актуально» для устаревших, + «неточно», «скопировать оригинал»; + * контекст строки: фон сцены, соседние реплики, «играть отсюда» + (`/play/{id}?at=<узел>&lang=<язык>`, картинка сцены восстанавливается); + * глоссарий с подсветкой терминов в оригинале; + * экспорт и импорт PO/CSV, шрифт для языка; +* API: `GET/PUT /api/versions/{id}/i18n[/{lang}]`, `…/entries`, `…/meta`, + `…/font`, `…/export`, `…/import`, `DELETE …/{lang}`. + +Пока не сделано: UI истории версии (сейчас есть только API), картинки и +озвучка для отдельного языка, правка реплик прямо в плеере, машинный +предперевод (заложено только состояние `machine`). + Уже сконвертированные версии остаются без ID и без переводов, их нужно переимпортировать. Пока в библиотеке только тестовые данные, отдельную миграцию не делаем. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ab8d6b8..529ed93 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { Login } from "./pages/Login"; import { NewGame } from "./pages/NewGame"; import { Play } from "./pages/Play"; import { Roles } from "./pages/Roles"; +import { TranslationEditor } from "./pages/TranslationEditor"; import { Users } from "./pages/Users"; import { useTheme } from "./theme"; import { Icon } from "./ui/Icon"; @@ -108,6 +109,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index eaffa65..2329d14 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,123 +1,128 @@ -export interface Role { - id: number; - name: string; - permissions: string[]; - is_system: boolean; -} - -export interface User { - id: number; - username: string; - role: Role; - is_active: boolean; - must_change_password: boolean; - must_change_username: boolean; - created_at: string; -} - -export interface Me extends User { - permissions: string[]; -} - -export interface Version { - id: number; - game_id: number; - parent_id: number | null; - owner: { id: number; username: string } | null; - name: string; - description: string; - status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed"; - progress: string; - size_bytes: number; - created_at: string; - report?: Record | null; -} - -export interface Game { - id: number; - title: string; - description: string; - tags: string[]; - engine: string; - cover_hash: string | null; - created_at: string; - ready_versions?: number; - pending_versions?: number; - failed_versions?: number; -} - -export interface GameDetail extends Game { - versions: Version[]; -} - -export class ApiError extends Error { - constructor(public status: number, message: string) { - super(message); - } -} - -async function request(method: string, url: string, body?: unknown): Promise { - const r = await fetch(url, { - method, - credentials: "same-origin", - headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - if (!r.ok) { - let msg = r.statusText; - try { - const data = await r.json(); - msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); - } catch { - /* not json */ - } - if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized")); - throw new ApiError(r.status, msg); - } - return r.status === 204 ? (undefined as T) : r.json(); -} - -export const api = { - get: (url: string) => request("GET", url), - post: (url: string, body?: unknown) => request("POST", url, body ?? {}), - put: (url: string, body?: unknown) => request("PUT", url, body ?? {}), - patch: (url: string, body?: unknown) => request("PATCH", url, body ?? {}), - del: (url: string) => request("DELETE", url), -}; - -/** Upload a raw body with progress (fetch has no upload progress). */ -export function uploadRaw(method: string, url: string, file: Blob, - onProgress: (fraction: number) => void): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open(method, url); - xhr.setRequestHeader("Content-Type", "application/octet-stream"); - xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total); - xhr.onload = () => { - let data: any = null; - try { - data = JSON.parse(xhr.responseText); - } catch { - /* empty */ - } - if (xhr.status >= 200 && xhr.status < 300) resolve(data); - else if (xhr.status === 413 && !data?.detail) - reject(new ApiError(413, "Файл слишком большой для прокси перед сервером (client_max_body_size)")); - else reject(new ApiError(xhr.status, data?.detail ?? (xhr.statusText || `Ошибка ${xhr.status}`))); - }; - xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка")); - xhr.send(file); - }); -} - -export function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} Б`; - const units = ["КБ", "МБ", "ГБ", "ТБ"]; - let v = bytes / 1024; - let i = 0; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i++; - } - return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; -} +export interface Role { + id: number; + name: string; + permissions: string[]; + is_system: boolean; +} + +export interface User { + id: number; + username: string; + role: Role; + is_active: boolean; + must_change_password: boolean; + must_change_username: boolean; + created_at: string; +} + +export interface Me extends User { + permissions: string[]; +} + +export interface Version { + id: number; + game_id: number; + parent_id: number | null; + owner: { id: number; username: string } | null; + name: string; + description: string; + status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed"; + visibility: "draft" | "published"; + kind: "original" | "translation" | "mod"; + source_lang: string | null; + languages: { code: string; name: string; progress: number }[]; + progress: string; + size_bytes: number; + created_at: string; + report?: Record | null; +} + +export interface Game { + id: number; + title: string; + description: string; + tags: string[]; + engine: string; + cover_hash: string | null; + main_version_id: number | null; + created_at: string; + ready_versions?: number; + pending_versions?: number; + failed_versions?: number; +} + +export interface GameDetail extends Game { + versions: Version[]; +} + +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + +async function request(method: string, url: string, body?: unknown): Promise { + const r = await fetch(url, { + method, + credentials: "same-origin", + headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!r.ok) { + let msg = r.statusText; + try { + const data = await r.json(); + msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); + } catch { + /* not json */ + } + if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized")); + throw new ApiError(r.status, msg); + } + return r.status === 204 ? (undefined as T) : r.json(); +} + +export const api = { + get: (url: string) => request("GET", url), + post: (url: string, body?: unknown) => request("POST", url, body ?? {}), + put: (url: string, body?: unknown) => request("PUT", url, body ?? {}), + patch: (url: string, body?: unknown) => request("PATCH", url, body ?? {}), + del: (url: string) => request("DELETE", url), +}; + +/** Upload a raw body with progress (fetch has no upload progress). */ +export function uploadRaw(method: string, url: string, file: Blob, + onProgress: (fraction: number) => void): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total); + xhr.onload = () => { + let data: any = null; + try { + data = JSON.parse(xhr.responseText); + } catch { + /* empty */ + } + if (xhr.status >= 200 && xhr.status < 300) resolve(data); + else if (xhr.status === 413 && !data?.detail) + reject(new ApiError(413, "Файл слишком большой для прокси перед сервером (client_max_body_size)")); + else reject(new ApiError(xhr.status, data?.detail ?? (xhr.statusText || `Ошибка ${xhr.status}`))); + }; + xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка")); + xhr.send(file); + }); +} + +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} Б`; + const units = ["КБ", "МБ", "ГБ", "ТБ"]; + let v = bytes / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; +} diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index 653868e..0047e42 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -80,6 +80,38 @@ export function GamePage() { if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан"); }; + const translate = async (v: Version) => { + const name = await ui.prompt({ + title: "Новый перевод", + text: "Создаётся ваш черновик — копия версии, в которой можно переводить. Игроки увидят перевод после публикации.", + label: "Название черновика", initial: `Перевод (${me?.username})`, confirmLabel: "Начать перевод", + }); + if (!name) return; + try { + const fork = await api.post(`/api/versions/${v.id}/fork`, { name, kind: "translation" }); + navigate(`/versions/${fork.id}/translate`); + } catch (e) { + ui.toast((e as Error).message, "error"); + } + }; + + const publish = async (v: Version) => { + const translationDraft = v.kind === "translation"; + const ok = await ui.confirm({ + title: `Опубликовать «${v.name}»?`, + text: translationDraft + ? "Изменённые языки будут влиты в главную версию. Сохранения игроков сохранятся, прежнее состояние останется в истории." + : "Версию увидят все игроки.", + confirmLabel: "Опубликовать", + }); + if (ok) void run(() => api.post(`/api/versions/${v.id}/publish`, {}), "Опубликовано"); + }; + + const makeMain = async (v: Version) => { + if (await ui.confirm({ title: `Сделать «${v.name}» главной версией?`, text: "Кнопка «Играть» будет открывать её.", confirmLabel: "Сделать главной" })) + void run(() => api.post(`/api/versions/${v.id}/make-main`, {}), "Главная версия изменена"); + }; + const uploadCover = (file: File) => run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}), "Обложка обновлена"); @@ -111,7 +143,8 @@ export function GamePage() { const ready = game.versions.filter((v) => v.status === "ready"); const lastId = getRecent().find((r) => r.gameId === game.id)?.versionId; - const playVersion = ready.find((v) => v.id === lastId) ?? ready.find((v) => v.parent_id === null) ?? ready[0]; + const mainVersion = ready.find((v) => v.id === game.main_version_id); + const playVersion = ready.find((v) => v.id === lastId) ?? mainVersion ?? ready.find((v) => v.parent_id === null) ?? ready[0]; const byParent = new Map(); for (const v of game.versions) { @@ -120,8 +153,10 @@ export function GamePage() { } const renderTree = (parent: number | null, depth: number): JSX.Element[] => (byParent.get(parent) ?? []).flatMap((v) => [ - void translate(v)} onPublish={() => void publish(v)} onMakeMain={() => void makeMain(v)} canFork={can("versions.fork")} onPlay={() => play(v)} onFork={() => void fork(v)} onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))} onDelete={() => void removeVersion(v)} />, @@ -188,9 +223,13 @@ export function GamePage() { ); } +const KIND: Record = { original: "", translation: "Перевод", mod: "Мод" }; + function VersionRow(props: { - v: Version; depth: number; current: boolean; canManage: boolean; canFork: boolean; + v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean; + canTranslate: boolean; canPublish: boolean; onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void; + onTranslate: () => void; onPublish: () => void; onMakeMain: () => void; }) { const { v } = props; const [report, setReport] = useState(undefined); @@ -206,16 +245,40 @@ function VersionRow(props: {
{props.depth > 0 && } {v.name} + {props.isMain && главная} + {KIND[v.kind] && {KIND[v.kind]}} + {v.visibility === "draft" && черновик}
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)} {v.progress && ` · ${v.progress}`}
+ {v.status === "ready" && v.languages?.length > 0 && ( +
+ + {v.languages.map((l) => ( + + {l.name} {Math.round(l.progress * 100)}% + + ))} +
+ )}
{v.status === "ready" && } {v.status === "ready" && props.canFork && } + {v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? ( + Редактор перевода + ) : v.status === "ready" && props.canTranslate && v.visibility === "published" && ( + + )} + {v.status === "ready" && props.canPublish && v.visibility === "draft" && ( + + )} + {v.status === "ready" && props.canPublish && !props.isMain && v.visibility === "published" && v.kind !== "translation" && ( + + )} {(v.status === "ready" || v.status === "failed") && ( + )} + /> + {!ov.editable && ( +
+ + Только просмотр. Чтобы переводить, создайте на странице игры версию «Перевод» — это ваш черновик. +
+ )} + +
+
+ {ov.languages.map((l) => ( + + ))} + {ov.editable && ( + + )} +
+ {stats && ( + <> + +
+ + PO + + + CSV + + {ov.editable && ( + + )} + {ov.editable && ( + + )} + Играть на этом языке +
+ + )} +
+ + {!ov.languages.length ? ( + void addLanguage()}>Добавить язык : undefined} /> + ) : stats ? ( + <> +
+ {([["lines", `Реплики · ${stats.lines}`], ["strings", `Имена и меню · ${stats.strings}`], ["glossary", "Глоссарий"]] as [Tab, string][]).map(([t, label]) => ( + + ))} +
+ setOv((o) => o && { ...o, languages: o.languages.map((l) => l.code === lang ? { ...l, ...s } : l) })} /> + + ) : null} +
+ ); +} + +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; +}) { + const ui = useUi(); + const [state, setState] = useState("todo"); + const [module, setModule] = useState(""); + const [query, setQuery] = useState(""); + const [q, setQ] = useState(""); + const [items, setItems] = useState(null); + const [total, setTotal] = useState(0); + 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); + return () => clearTimeout(t); + }, [query]); + + useEffect(() => { + api.get<{ glossary: GlossaryTerm[] }>(`/api/versions/${versionId}/i18n/${lang}/meta`) + .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}`); + }, [versionId, lang, tab, state, module, q]); + + useEffect(() => { + if (tab === "glossary") return; + let cancelled = false; + setItems(null); + fetchPage(0).then((r) => { + if (cancelled) return; + setItems(r.items); + setTotal(r.total); + }).catch((e) => ui.toast(e.message, "error")); + return () => { + cancelled = true; + }; + }, [fetchPage, tab, ui]); + + const flush = useCallback(async () => { + window.clearTimeout(timer.current); + const changes = pending.current; + if (!Object.keys(changes).length) return; + pending.current = {}; + setSaving("saving"); + try { + const s = await api.put>(`/api/versions/${versionId}/i18n/${lang}`, + tab === "strings" ? { strings: changes } : { lines: changes }); + onStats(s); + setSaving("saved"); + } catch (e) { + pending.current = { ...changes, ...pending.current }; + setSaving("error"); + ui.toast((e as Error).message, "error"); + } + }, [versionId, lang, tab, onStats, ui]); + + // don't lose edits when leaving the page + useEffect(() => () => void flush(), [flush]); + + const update = (key: string, t: string, nextState?: LineState) => { + setItems((list) => list?.map((it) => it.key === key ? { + ...it, translation: t, + state: nextState ?? (t.trim() ? (it.state === "approved" || it.state === "untranslated" || it.state === "outdated" ? "translated" : it.state) : "untranslated"), + } : it) ?? null); + const st = nextState ?? "translated"; + pending.current[key] = t.trim() ? { t, state: st === "untranslated" || st === "outdated" ? "translated" : st } : null; + window.clearTimeout(timer.current); + 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") { + return ; + } + + return ( +
+
+
+ + setQuery(e.target.value)} aria-label="Поиск" /> +
+ {tab === "lines" && ov.modules.length > 1 && ( + + )} + + {saving === "saving" ? "Сохранение…" : saving === "saved" ? "Сохранено" : saving === "error" ? "Ошибка сохранения" : ""} + +
+
+ {FILTERS.map(([v, label]) => ( + + ))} +
+ + {items === null ? : !items.length ? ( + + ) : ( + <> +
Найдено: {total}
+
+ {items.map((it, i) => ( + update(it.key, t, s)} onBlur={() => void flush()} + onNext={() => { + void flush(); + const next = document.querySelectorAll(".tr-list textarea")[i + 1]; + next?.focus(); + }} /> + ))} +
+ {items.length < total && ( +
+ +
+ )} + + )} +
+ ); +} + +function Row({ item, versionId, lang, editable, canApprove, terms, onChange, onBlur, onNext }: { + item: Item; versionId: string; lang: string; editable: boolean; canApprove: boolean; terms: GlossaryTerm[]; + onChange: (t: string, state?: LineState) => void; onBlur: () => void; onNext: () => void; +}) { + const [context, setContext] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${el.scrollHeight + 2}px`; + }, [item.translation]); + + return ( +
+
+
+ {item.who ? {item.who} : item.usage ? {USAGE[item.usage] ?? item.usage} : рассказчик} + {item.label && · {item.label}} + {STATE_LABEL[item.state]} +
+

{highlight(item.source, terms)}

+ {item.module !== undefined && ( +
+ + {item.nodeId && ( + + Играть отсюда + + )} +
+ )} + {context && ( +
+ {item.scene && } +
+ {item.prev &&

↑ {item.prev}

} +

→ {item.source}

+ {item.next &&

↓ {item.next}

} +
+
+ )} +
+
+