Localization stages 2-3: drafts, publishing, translation editor

- games.main_version_id, version visibility (draft/published) and kind
  (original/translation/mod), version_history; migration 0002
- permissions translations.edit and versions.publish
- "Translate" creates a translation draft; publishing merges the changed
  languages into the main version in place (players keep their saves), the
  previous manifest goes to the history and can be restored
- translation API: overview with per-state stats, entries with filters,
  search and scene context, save, glossary, locale font, PO/CSV export/import
- translation editor page: languages and progress, lines and strings,
  filters, autosave, approve/outdated/fuzzy actions, glossary highlighting,
  "play from here" with the scene restored
- player: ?lang= and ?at= from the editor, per-locale font

(models.py and permissions.py for this landed earlier in 9ee3e1f)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:56:19 +03:00
1 parent 4e047f3aa0
commit ac164d1782
20 files changed
+2039 -298

No files matched your search

+2 -2
View File
@@ -8,7 +8,7 @@ from app.blobs import BLOB_ID_RE, blob_path
from app.bootstrap import bootstrap from app.bootstrap import bootstrap
from app.config import get_settings from app.config import get_settings
from app.db import SessionLocal 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) logging.basicConfig(level=logging.INFO)
@@ -24,7 +24,7 @@ async def lifespan(_: FastAPI):
app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs", app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs",
openapi_url="/api/openapi.json") 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) app.include_router(r.router)
+165 -158
View File
@@ -1,158 +1,165 @@
import io import io
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from PIL import Image, UnidentifiedImageError from PIL import Image, UnidentifiedImageError
from sqlalchemy import exists, func, or_, select, true from sqlalchemy import and_, exists, func, or_, select, true
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from app.blobs import put_bytes from app.blobs import put_bytes
from app.uploads import remove_upload from app.uploads import remove_upload
from app.deps import DB, CurrentUser, has_perm, require from app.deps import DB, CurrentUser, has_perm, require
from app.models import Game, GameVersion, User from app.models import Game, GameVersion, User
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn 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"))] router = APIRouter(prefix="/api/games", tags=["games"])
Uploader = Annotated[User, Depends(require("games.upload"))] Player = Annotated[User, Depends(require("games.play"))]
Uploader = Annotated[User, Depends(require("games.upload"))]
MAX_COVER_BYTES = 20 * 1024 * 1024
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.""" def visible_versions_filter(user: User):
if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"): """Versions a user can see: ready published ones (reviewers also see drafts),
return true() plus their own in any state."""
return or_(GameVersion.status == "ready", GameVersion.owner_id == user.id) if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"):
return true()
ready = GameVersion.status == "ready"
async def get_game(db: DB, game_id: int) -> Game: if not has_perm(user, "versions.publish"):
game = await db.get(Game, game_id) ready = and_(ready, GameVersion.visibility == "published")
if not game: return or_(ready, GameVersion.owner_id == user.id)
raise HTTPException(status.HTTP_404_NOT_FOUND, "Игра не найдена")
return game
async def get_game(db: DB, game_id: int) -> Game:
game = await db.get(Game, game_id)
def can_edit_game(user: User, game: Game) -> bool: if not game:
return has_perm(user, "games.edit") or ( raise HTTPException(status.HTTP_404_NOT_FOUND, "Игра не найдена")
has_perm(user, "games.upload") and game.created_by_id == user.id return game
)
def can_edit_game(user: User, game: Game) -> bool:
@router.get("", response_model=list[GameOut]) return has_perm(user, "games.edit") or (
async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[dict]: has_perm(user, "games.upload") and game.created_by_id == user.id
stmt = select(Game).order_by(Game.title) )
if q.strip():
pattern = f"%{q.strip()}%"
stmt = stmt.where( @router.get("", response_model=list[GameOut])
or_( async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[dict]:
Game.title.ilike(pattern), stmt = select(Game).order_by(Game.title)
Game.description.ilike(pattern), if q.strip():
Game.tags.any(q.strip().lower()), pattern = f"%{q.strip()}%"
) stmt = stmt.where(
) or_(
if tag: Game.title.ilike(pattern),
stmt = stmt.where(Game.tags.any(tag.lower())) Game.description.ilike(pattern),
stmt = stmt.where( Game.tags.any(q.strip().lower()),
exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user)) )
| (Game.created_by_id == user.id) )
) if tag:
games = list((await db.scalars(stmt.limit(500))).all()) stmt = stmt.where(Game.tags.any(tag.lower()))
counts: dict[int, dict[str, int]] = {} stmt = stmt.where(
if games: exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user))
rows = await db.execute( | (Game.created_by_id == user.id)
select(GameVersion.game_id, GameVersion.status, func.count()) )
.where(GameVersion.game_id.in_([g.id for g in games]), visible_versions_filter(user)) games = list((await db.scalars(stmt.limit(500))).all())
.group_by(GameVersion.game_id, GameVersion.status) counts: dict[int, dict[str, int]] = {}
) if games:
for game_id, st, n in rows: rows = await db.execute(
key = "ready" if st == "ready" else "failed" if st == "failed" else "pending" select(GameVersion.game_id, GameVersion.status, func.count())
counts.setdefault(game_id, {}).setdefault(key, 0) .where(GameVersion.game_id.in_([g.id for g in games]), visible_versions_filter(user))
counts[game_id][key] += n .group_by(GameVersion.game_id, GameVersion.status)
return [ )
{ for game_id, st, n in rows:
**GameOut.model_validate(g).model_dump(), key = "ready" if st == "ready" else "failed" if st == "failed" else "pending"
"ready_versions": counts.get(g.id, {}).get("ready", 0), counts.setdefault(game_id, {}).setdefault(key, 0)
"pending_versions": counts.get(g.id, {}).get("pending", 0), counts[game_id][key] += n
"failed_versions": counts.get(g.id, {}).get("failed", 0), return [
} {
for g in games **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),
@router.post("", response_model=GameOut, status_code=201) }
async def create_game(body: GameIn, user: Uploader, db: DB) -> Game: for g in games
game = Game( ]
title=body.title.strip(),
description=body.description,
tags=sorted({t.strip().lower() for t in body.tags if t.strip()}), @router.post("", response_model=GameOut, status_code=201)
created_by_id=user.id, async def create_game(body: GameIn, user: Uploader, db: DB) -> Game:
) game = Game(
db.add(game) title=body.title.strip(),
await db.commit() description=body.description,
return game tags=sorted({t.strip().lower() for t in body.tags if t.strip()}),
created_by_id=user.id,
)
@router.get("/{game_id}", response_model=GameDetailOut) db.add(game)
async def game_detail(game_id: int, user: Player, db: DB) -> dict: await db.commit()
game = await get_game(db, game_id) return game
versions = (
await db.scalars(
select(GameVersion) @router.get("/{game_id}", response_model=GameDetailOut)
.where(GameVersion.game_id == game.id, visible_versions_filter(user)) async def game_detail(game_id: int, user: Player, db: DB) -> dict:
.order_by(GameVersion.parent_id.is_not(None), GameVersion.created_at) game = await get_game(db, game_id)
) versions = (
).all() await db.scalars(
return {**GameOut.model_validate(game).model_dump(), "versions": list(versions)} select(GameVersion)
.where(GameVersion.game_id == game.id, visible_versions_filter(user))
.order_by(GameVersion.parent_id.is_not(None), GameVersion.created_at)
@router.patch("/{game_id}", response_model=GameOut) )
async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: DB) -> Game: ).all()
game = await get_game(db, game_id) return {**GameOut.model_validate(game).model_dump(),
if not can_edit_game(user, game): "versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest)}
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") for v in versions]}
if body.title is not None:
game.title = body.title.strip()
if body.description is not None: @router.patch("/{game_id}", response_model=GameOut)
game.description = body.description async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: DB) -> Game:
if body.tags is not None: game = await get_game(db, game_id)
game.tags = sorted({t.strip().lower() for t in body.tags if t.strip()}) if not can_edit_game(user, game):
await db.commit() raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
return game if body.title is not None:
game.title = body.title.strip()
if body.description is not None:
@router.put("/{game_id}/cover", response_model=GameOut) game.description = body.description
async def upload_cover(game_id: int, request: Request, user: CurrentUser, db: DB) -> Game: if body.tags is not None:
game = await get_game(db, game_id) game.tags = sorted({t.strip().lower() for t in body.tags if t.strip()})
if not can_edit_game(user, game): await db.commit()
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование") return game
data = await request.body()
if len(data) > MAX_COVER_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл") @router.put("/{game_id}/cover", response_model=GameOut)
try: async def upload_cover(game_id: int, request: Request, user: CurrentUser, db: DB) -> Game:
game.cover_hash = await run_in_threadpool(make_cover, data) game = await get_game(db, game_id)
except (UnidentifiedImageError, OSError): if not can_edit_game(user, game):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Не удалось прочитать изображение") raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
await db.commit() data = await request.body()
return game if len(data) > MAX_COVER_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл")
try:
@router.delete("/{game_id}", status_code=204) game.cover_hash = await run_in_threadpool(make_cover, data)
async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.delete"))], except (UnidentifiedImageError, OSError):
db: DB) -> None: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Не удалось прочитать изображение")
game = await get_game(db, game_id) await db.commit()
version_ids = (await db.scalars(select(GameVersion.id).where(GameVersion.game_id == game.id))).all() return game
await db.delete(game)
await db.commit()
for vid in version_ids: @router.delete("/{game_id}", status_code=204)
remove_upload(vid) async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.delete"))],
db: DB) -> None:
game = await get_game(db, game_id)
def make_cover(data: bytes) -> str: version_ids = (await db.scalars(select(GameVersion.id).where(GameVersion.game_id == game.id))).all()
img = Image.open(io.BytesIO(data)) await db.delete(game)
img.thumbnail((640, 640)) await db.commit()
if img.mode not in ("RGB", "RGBA"): for vid in version_ids:
img = img.convert("RGBA") remove_upload(vid)
out = io.BytesIO()
img.save(out, "WEBP", quality=85)
return put_bytes(out.getvalue(), ".webp")[0] 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]
+246
View File
@@ -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)}
+87 -5
View File
@@ -5,9 +5,13 @@ from sqlalchemy import select
from starlette.requests import ClientDisconnect from starlette.requests import ClientDisconnect
from app.deps import DB, CurrentUser, has_perm, require 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.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 from app.uploads import remove_upload, upload_path
router = APIRouter(prefix="/api", tags=["versions"]) 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: 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: 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) @router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
async def fork_version(version_id: int, body: VersionIn, async def fork_version(version_id: int, body: ForkIn,
user: Annotated[User, Depends(require("games.play", "versions.fork"))], user: Annotated[User, Depends(require("games.play"))],
db: DB) -> GameVersion: 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) parent = await get_version(db, user, version_id)
if parent.status != "ready": if parent.status != "ready":
raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию") raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию")
@@ -106,6 +114,8 @@ async def fork_version(version_id: int, body: VersionIn,
name=body.name.strip(), name=body.name.strip(),
description=body.description, description=body.description,
status="ready", status="ready",
visibility="draft",
kind=body.kind,
manifest=parent.manifest, manifest=parent.manifest,
report=parent.report, report=parent.report,
size_bytes=parent.size_bytes, size_bytes=parent.size_bytes,
@@ -115,6 +125,78 @@ async def fork_version(version_id: int, body: VersionIn,
return fork 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) @router.patch("/versions/{version_id}", response_model=VersionOut)
async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion: async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
+20
View File
@@ -76,6 +76,11 @@ class VersionOut(ORM):
progress: str progress: str
size_bytes: int size_bytes: int
created_at: datetime 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): class VersionDetailOut(VersionOut):
@@ -90,6 +95,7 @@ class GameOut(ORM):
engine: str engine: str
cover_hash: str | None cover_hash: str | None
created_at: datetime created_at: datetime
main_version_id: int | None = None
# version counts visible to the requesting user (filled by the list endpoint) # version counts visible to the requesting user (filled by the list endpoint)
ready_versions: int = 0 ready_versions: int = 0
pending_versions: int = 0 pending_versions: int = 0
@@ -117,6 +123,20 @@ class VersionIn(BaseModel):
description: str = "" 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): class SaveIn(BaseModel):
title: str = "" title: str = ""
data: dict[str, Any] data: dict[str, Any]
+420
View File
@@ -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
+2
View File
@@ -87,6 +87,8 @@ async def convert(version_id: int, source: Path, filename: str) -> bool:
read_bytes(result.cover_source)) read_bytes(result.cover_source))
except OSError: except OSError:
pass 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: if game and result.title and game.title == source.name:
game.title = result.title game.title = result.title
await db.commit() await db.commit()
@@ -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")
+69
View File
@@ -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)
+31
View File
@@ -164,6 +164,37 @@ translate russian strings:
авторы ещё не перевели, а ~750 блоков на язык относятся к уже изменённым авторы ещё не перевели, а ~750 блоков на язык относятся к уже изменённым
английским репликам. Ren'Py их тоже не показывает. английским репликам. 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 и без переводов, их нужно Уже сконвертированные версии остаются без ID и без переводов, их нужно
переимпортировать. Пока в библиотеке только тестовые данные, отдельную переимпортировать. Пока в библиотеке только тестовые данные, отдельную
миграцию не делаем. миграцию не делаем.
+2
View File
@@ -8,6 +8,7 @@ import { Login } from "./pages/Login";
import { NewGame } from "./pages/NewGame"; import { NewGame } from "./pages/NewGame";
import { Play } from "./pages/Play"; import { Play } from "./pages/Play";
import { Roles } from "./pages/Roles"; import { Roles } from "./pages/Roles";
import { TranslationEditor } from "./pages/TranslationEditor";
import { Users } from "./pages/Users"; import { Users } from "./pages/Users";
import { useTheme } from "./theme"; import { useTheme } from "./theme";
import { Icon } from "./ui/Icon"; import { Icon } from "./ui/Icon";
@@ -108,6 +109,7 @@ export function App() {
<Route path="/" element={<RequirePerm perm="games.play"><Library /></RequirePerm>} /> <Route path="/" element={<RequirePerm perm="games.play"><Library /></RequirePerm>} />
<Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} /> <Route path="/games/new" element={<RequirePerm perm="games.upload"><NewGame /></RequirePerm>} />
<Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} /> <Route path="/games/:gameId" element={<RequirePerm perm="games.play"><GamePage /></RequirePerm>} />
<Route path="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></RequirePerm>} />
<Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} /> <Route path="/admin/users" element={<RequirePerm perm="users.manage"><Users /></RequirePerm>} />
<Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} /> <Route path="/admin/roles" element={<RequirePerm perm="roles.manage"><Roles /></RequirePerm>} />
<Route path="/account" element={<ChangeCredentials />} /> <Route path="/account" element={<ChangeCredentials />} />
+128 -123
View File
@@ -1,123 +1,128 @@
export interface Role { export interface Role {
id: number; id: number;
name: string; name: string;
permissions: string[]; permissions: string[];
is_system: boolean; is_system: boolean;
} }
export interface User { export interface User {
id: number; id: number;
username: string; username: string;
role: Role; role: Role;
is_active: boolean; is_active: boolean;
must_change_password: boolean; must_change_password: boolean;
must_change_username: boolean; must_change_username: boolean;
created_at: string; created_at: string;
} }
export interface Me extends User { export interface Me extends User {
permissions: string[]; permissions: string[];
} }
export interface Version { export interface Version {
id: number; id: number;
game_id: number; game_id: number;
parent_id: number | null; parent_id: number | null;
owner: { id: number; username: string } | null; owner: { id: number; username: string } | null;
name: string; name: string;
description: string; description: string;
status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed"; status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed";
progress: string; visibility: "draft" | "published";
size_bytes: number; kind: "original" | "translation" | "mod";
created_at: string; source_lang: string | null;
report?: Record<string, any> | null; languages: { code: string; name: string; progress: number }[];
} progress: string;
size_bytes: number;
export interface Game { created_at: string;
id: number; report?: Record<string, any> | null;
title: string; }
description: string;
tags: string[]; export interface Game {
engine: string; id: number;
cover_hash: string | null; title: string;
created_at: string; description: string;
ready_versions?: number; tags: string[];
pending_versions?: number; engine: string;
failed_versions?: number; cover_hash: string | null;
} main_version_id: number | null;
created_at: string;
export interface GameDetail extends Game { ready_versions?: number;
versions: Version[]; pending_versions?: number;
} failed_versions?: number;
}
export class ApiError extends Error {
constructor(public status: number, message: string) { export interface GameDetail extends Game {
super(message); versions: Version[];
} }
}
export class ApiError extends Error {
async function request<T>(method: string, url: string, body?: unknown): Promise<T> { constructor(public status: number, message: string) {
const r = await fetch(url, { super(message);
method, }
credentials: "same-origin", }
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined, async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
}); const r = await fetch(url, {
if (!r.ok) { method,
let msg = r.statusText; credentials: "same-origin",
try { headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
const data = await r.json(); body: body !== undefined ? JSON.stringify(body) : undefined,
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); });
} catch { if (!r.ok) {
/* not json */ let msg = r.statusText;
} try {
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized")); const data = await r.json();
throw new ApiError(r.status, msg); msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
} } catch {
return r.status === 204 ? (undefined as T) : r.json(); /* not json */
} }
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized"));
export const api = { throw new ApiError(r.status, msg);
get: <T>(url: string) => request<T>("GET", url), }
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}), return r.status === 204 ? (undefined as T) : r.json();
put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}), }
patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}),
del: (url: string) => request<void>("DELETE", url), export const api = {
}; get: <T>(url: string) => request<T>("GET", url),
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
/** Upload a raw body with progress (fetch has no upload progress). */ put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}),
export function uploadRaw<T>(method: string, url: string, file: Blob, patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}),
onProgress: (fraction: number) => void): Promise<T> { del: (url: string) => request<void>("DELETE", url),
return new Promise((resolve, reject) => { };
const xhr = new XMLHttpRequest();
xhr.open(method, url); /** Upload a raw body with progress (fetch has no upload progress). */
xhr.setRequestHeader("Content-Type", "application/octet-stream"); export function uploadRaw<T>(method: string, url: string, file: Blob,
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total); onProgress: (fraction: number) => void): Promise<T> {
xhr.onload = () => { return new Promise((resolve, reject) => {
let data: any = null; const xhr = new XMLHttpRequest();
try { xhr.open(method, url);
data = JSON.parse(xhr.responseText); xhr.setRequestHeader("Content-Type", "application/octet-stream");
} catch { xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
/* empty */ xhr.onload = () => {
} let data: any = null;
if (xhr.status >= 200 && xhr.status < 300) resolve(data); try {
else if (xhr.status === 413 && !data?.detail) data = JSON.parse(xhr.responseText);
reject(new ApiError(413, "Файл слишком большой для прокси перед сервером (client_max_body_size)")); } catch {
else reject(new ApiError(xhr.status, data?.detail ?? (xhr.statusText || `Ошибка ${xhr.status}`))); /* empty */
}; }
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка")); if (xhr.status >= 200 && xhr.status < 300) resolve(data);
xhr.send(file); 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}`)));
};
export function formatSize(bytes: number): string { xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
if (bytes < 1024) return `${bytes} Б`; xhr.send(file);
const units = ["КБ", "МБ", "ГБ", "ТБ"]; });
let v = bytes / 1024; }
let i = 0;
while (v >= 1024 && i < units.length - 1) { export function formatSize(bytes: number): string {
v /= 1024; if (bytes < 1024) return `${bytes} Б`;
i++; const units = ["КБ", "МБ", "ГБ", "ТБ"];
} let v = bytes / 1024;
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; let i = 0;
} while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
}
+66 -3
View File
@@ -80,6 +80,38 @@ export function GamePage() {
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан"); 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<Version>(`/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) => const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, 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 ready = game.versions.filter((v) => v.status === "ready");
const lastId = getRecent().find((r) => r.gameId === game.id)?.versionId; 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<number | null, Version[]>(); const byParent = new Map<number | null, Version[]>();
for (const v of game.versions) { for (const v of game.versions) {
@@ -120,8 +153,10 @@ export function GamePage() {
} }
const renderTree = (parent: number | null, depth: number): JSX.Element[] => const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [ (byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id} <VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id} isMain={v.id === game.main_version_id}
canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canTranslate={can("translations.edit")} canPublish={can("versions.publish")}
onTranslate={() => void translate(v)} onPublish={() => void publish(v)} onMakeMain={() => void makeMain(v)}
canFork={can("versions.fork")} onPlay={() => play(v)} onFork={() => void fork(v)} canFork={can("versions.fork")} onPlay={() => play(v)} onFork={() => void fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))} onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => void removeVersion(v)} />, onDelete={() => void removeVersion(v)} />,
@@ -188,9 +223,13 @@ export function GamePage() {
); );
} }
const KIND: Record<Version["kind"], string> = { original: "", translation: "Перевод", mod: "Мод" };
function VersionRow(props: { 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; onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
onTranslate: () => void; onPublish: () => void; onMakeMain: () => void;
}) { }) {
const { v } = props; const { v } = props;
const [report, setReport] = useState<Version["report"] | undefined>(undefined); const [report, setReport] = useState<Version["report"] | undefined>(undefined);
@@ -206,16 +245,40 @@ function VersionRow(props: {
<div className="version-title"> <div className="version-title">
{props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>} {props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>}
<b>{v.name}</b> <b>{v.name}</b>
{props.isMain && <span className="badge badge-ok" title="Её открывает кнопка «Играть»">главная</span>}
{KIND[v.kind] && <span className="badge badge-plain">{KIND[v.kind]}</span>}
{v.visibility === "draft" && <span className="badge badge-warn">черновик</span>}
<StatusBadge status={v.status} /> <StatusBadge status={v.status} />
</div> </div>
<div className="muted small version-meta"> <div className="muted small version-meta">
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)} {v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`} {v.progress && ` · ${v.progress}`}
</div> </div>
{v.status === "ready" && v.languages?.length > 0 && (
<div className="tags" style={{ marginTop: "0.35rem" }} title="Языки версии">
<Icon name="globe" size={14} />
{v.languages.map((l) => (
<Link key={l.code} className="tag" to={`/versions/${v.id}/translate?lang=${l.code}`}>
{l.name} {Math.round(l.progress * 100)}%
</Link>
))}
</div>
)}
</div> </div>
<div className="actions"> <div className="actions">
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>} {v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>} {v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
<Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link>
) : v.status === "ready" && props.canTranslate && v.visibility === "published" && (
<button className="btn btn-sm" onClick={props.onTranslate}><Icon name="globe" size={16} />Перевести</button>
)}
{v.status === "ready" && props.canPublish && v.visibility === "draft" && (
<button className="btn btn-sm btn-primary" onClick={props.onPublish}><Icon name="send" size={16} />Опубликовать</button>
)}
{v.status === "ready" && props.canPublish && !props.isMain && v.visibility === "published" && v.kind !== "translation" && (
<button className="btn btn-sm btn-ghost" onClick={props.onMakeMain} title="Сделать главной"><Icon name="star" size={16} />Главная</button>
)}
{(v.status === "ready" || v.status === "failed") && ( {(v.status === "ready" || v.status === "failed") && (
<button className="btn btn-sm btn-ghost" onClick={() => void toggleReport()} aria-expanded={report !== undefined}> <button className="btn btn-sm btn-ghost" onClick={() => void toggleReport()} aria-expanded={report !== undefined}>
<Icon name="file" size={16} />Отчёт <Icon name="file" size={16} />Отчёт
+590
View File
@@ -0,0 +1,590 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } 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 "./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<LineState, number>;
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<string, string>;
}
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<LineState, string> = {
untranslated: "Не переведено",
outdated: "Устарело",
fuzzy: "Неточно",
machine: "Машинный",
translated: "Переведено",
approved: "Одобрено",
};
const STATE_BADGE: Record<LineState, string> = {
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<string, string> = { name: "имя персонажа", menu: "пункт меню", text: "текст на экране" };
export function TranslationEditor() {
const { versionId } = useParams();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const ui = useUi();
const [version, setVersion] = useState<Version | null>(null);
const [ov, setOv] = useState<Overview | null>(null);
const [error, setError] = useState("");
const lang = params.get("lang") ?? "";
const tab = (params.get("tab") as Tab) ?? "lines";
const setParam = (key: string, value: string) => {
const next = new URLSearchParams(params);
if (value) next.set(key, value);
else next.delete(key);
setParams(next, { replace: true });
};
const loadOverview = useCallback(async () => {
const [v, o] = await Promise.all([
api.get<Version>(`/api/versions/${versionId}`),
api.get<Overview>(`/api/versions/${versionId}/i18n`),
]);
setVersion(v);
setOv(o);
return o;
}, [versionId]);
useEffect(() => {
loadOverview()
.then((o) => {
if (!params.get("lang") && o.languages.length) setParam("lang", o.languages[0].code);
})
.catch((e) => setError(e.message));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadOverview]);
const stats = ov?.languages.find((l) => l.code === lang);
const addLanguage = async () => {
const code = await ui.prompt({
title: "Новый язык",
text: "Код языка в формате BCP-47: ru, en, de, ja, pt-BR, zh-Hans…",
label: "Код языка", confirmLabel: "Добавить",
});
if (!code) return;
const clean = code.trim();
if (clean === ov?.sourceLang) {
ui.toast("Это исходный язык игры", "error");
return;
}
try {
await api.put(`/api/versions/${versionId}/i18n/${encodeURIComponent(clean)}`, {
name: ov?.knownLanguages[clean] ?? null,
});
await loadOverview();
setParam("lang", clean);
ui.toast("Язык добавлен", "ok");
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const publish = async () => {
if (!version) return;
const ok = await ui.confirm({
title: "Опубликовать перевод?",
text: "Изменённые языки будут влиты в главную версию игры. Сохранения игроков не пострадают, "
+ "прежнее состояние останется в истории версии.",
confirmLabel: "Опубликовать",
});
if (!ok) return;
try {
await api.post(`/api/versions/${version.id}/publish`, {});
ui.toast("Перевод опубликован", "ok");
navigate(`/games/${version.game_id}`);
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const importFile = async (file: File) => {
const format = file.name.toLowerCase().endsWith(".csv") ? "csv" : "po";
const overwrite = await ui.confirm({
title: `Импорт ${file.name}`,
text: "Перезаписать уже переведённые строки? «Отмена» — заполнить только пустые.",
confirmLabel: "Перезаписать",
});
try {
const res = await uploadRaw<{ lines: number; strings: number }>(
"POST", `/api/versions/${versionId}/i18n/${lang}/import?format=${format}&overwrite=${overwrite}`, file, () => {});
ui.toast(`Импортировано: ${res.lines} реплик, ${res.strings} строк`, "ok");
await loadOverview();
setParam("r", String(Date.now()));
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const uploadFont = async (file: File) => {
try {
await uploadRaw("PUT", `/api/versions/${versionId}/i18n/${lang}/font?filename=${encodeURIComponent(file.name)}`,
file, () => {});
ui.toast("Шрифт для языка загружен", "ok");
await loadOverview();
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
if (error) return <div className="page"><ErrorNote>{error}</ErrorNote></div>;
if (!ov || !version) return <div className="page"><Spinner /></div>;
const draft = version.visibility === "draft";
return (
<div className="page tr-page">
<Link to={`/games/${version.game_id}`} className="back-link"><Icon name="back" size={18} />К игре</Link>
<PageHeader
title={`Перевод · ${version.name}`}
subtitle={<>Исходный язык: <b>{ov.knownLanguages[ov.sourceLang ?? ""] ?? ov.sourceLang ?? "?"}</b>
{" · "}{ov.lines} реплик · {ov.strings} строк интерфейса
{draft && <> · <span className="badge badge-warn">черновик</span></>}</>}
actions={ov.canApprove && draft && version.kind === "translation" && (
<button className="btn btn-primary" onClick={() => void publish()}><Icon name="send" size={18} />Опубликовать</button>
)}
/>
{!ov.editable && (
<div className="note note-info" style={{ marginBottom: "var(--space-4)" }}>
<Icon name="info" size={18} />
<span>Только просмотр. Чтобы переводить, создайте на странице игры версию «Перевод» — это ваш черновик.</span>
</div>
)}
<div className="tr-langbar card">
<div className="tr-langs" role="tablist" aria-label="Языки">
{ov.languages.map((l) => (
<button key={l.code} role="tab" aria-selected={l.code === lang}
className={`tr-lang ${l.code === lang ? "on" : ""}`} onClick={() => setParam("lang", l.code)}>
<span>{l.name}</span>
<small>{Math.round(l.progress * 100)}%</small>
</button>
))}
{ov.editable && (
<button className="btn btn-sm btn-ghost" onClick={() => void addLanguage()}><Icon name="plus" size={16} />Язык</button>
)}
</div>
{stats && (
<>
<ProgressBar stats={stats} />
<div className="actions tr-tools">
<a className="btn btn-sm" href={`/api/versions/${versionId}/i18n/${lang}/export?format=po`}>
<Icon name="download" size={16} />PO
</a>
<a className="btn btn-sm" href={`/api/versions/${versionId}/i18n/${lang}/export?format=csv`}>
<Icon name="download" size={16} />CSV
</a>
{ov.editable && (
<label className="btn btn-sm">
<Icon name="upload" size={16} />Импорт PO/CSV
<input type="file" accept=".po,.csv" hidden
onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void importFile(f); }} />
</label>
)}
{ov.editable && (
<label className="btn btn-sm" title="Если в шрифтах игры нет нужных символов">
<Icon name="type" size={16} />{stats.font ? "Сменить шрифт" : "Шрифт языка"}
<input type="file" accept=".ttf,.otf,.woff,.woff2" hidden
onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void uploadFont(f); }} />
</label>
)}
<Link className="btn btn-sm btn-ghost" to={`/play/${versionId}?lang=${lang}`}><Icon name="play" size={16} />Играть на этом языке</Link>
</div>
</>
)}
</div>
{!ov.languages.length ? (
<EmptyState icon="globe" title="Переводов пока нет"
text={ov.editable ? "Добавьте язык, чтобы начать перевод." : "У этой версии нет переводов."}
action={ov.editable ? <button className="btn btn-primary" onClick={() => void addLanguage()}><Icon name="plus" size={18} />Добавить язык</button> : undefined} />
) : stats ? (
<>
<div className="chips tr-tabs" role="tablist">
{([["lines", `Реплики · ${stats.lines}`], ["strings", `Имена и меню · ${stats.strings}`], ["glossary", "Глоссарий"]] as [Tab, string][]).map(([t, label]) => (
<button key={t} role="tab" aria-selected={tab === t} className={`chip ${tab === t ? "on" : ""}`}
onClick={() => setParam("tab", t === "lines" ? "" : t)}>{label}</button>
))}
</div>
<EntriesView key={`${lang}-${tab}-${params.get("r") ?? ""}`} versionId={versionId!} lang={lang} tab={tab}
ov={ov} onStats={(s) => setOv((o) => o && { ...o, languages: o.languages.map((l) => l.code === lang ? { ...l, ...s } : l) })} />
</>
) : null}
</div>
);
}
function ProgressBar({ stats }: { stats: LangStats }) {
const total = stats.lines || 1;
const seg = (state: LineState, cls: string) =>
stats.states[state] ? <div className={cls} style={{ width: `${(stats.states[state] / total) * 100}%` }}
title={`${STATE_LABEL[state]}: ${stats.states[state]}`} /> : null;
return (
<div className="tr-progress">
<div className="tr-bar">
{seg("approved", "seg-approved")}
{seg("translated", "seg-ok")}
{seg("fuzzy", "seg-warn")}
{seg("machine", "seg-warn")}
{seg("outdated", "seg-error")}
</div>
<span className="muted small">
{stats.translated} из {stats.lines} реплик ({Math.round(stats.progress * 100)}%)
{stats.states.outdated ? ` · устарело ${stats.states.outdated}` : ""}
{" · "}строк {stats.stringsTranslated}/{stats.strings}
</span>
</div>
);
}
function EntriesView({ versionId, lang, tab, ov, onStats }: {
versionId: string; lang: string; tab: Tab; ov: Overview; onStats: (s: Partial<LangStats>) => 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<Item[] | null>(null);
const [total, setTotal] = useState(0);
const [glossary, setGlossary] = useState<GlossaryTerm[]>([]);
const [saving, setSaving] = useState<"idle" | "saving" | "saved" | "error">("idle");
const pending = useRef<Record<string, { t: string; state: string } | null>>({});
const timer = useRef<number>();
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<Partial<LangStats>>(`/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 <GlossaryEditor versionId={versionId} lang={lang} editable={ov.editable} terms={glossary} onChange={setGlossary} />;
}
return (
<div className="tr-entries">
<div className="toolbar tr-filters">
<div className="search">
<Icon name="search" size={20} />
<input type="search" placeholder="Поиск в оригинале, переводе или по персонажу…" value={query}
onChange={(e) => setQuery(e.target.value)} aria-label="Поиск" />
</div>
{tab === "lines" && ov.modules.length > 1 && (
<select value={module} onChange={(e) => setModule(e.target.value)} aria-label="Файл скрипта">
<option value="">Все файлы</option>
{ov.modules.map((m) => <option key={m.path} value={m.path}>{m.path.replace(/^script\//, "").replace(/\.json$/, "")} · {m.lines}</option>)}
</select>
)}
<span className={`muted small tr-saving ${saving}`}>
{saving === "saving" ? "Сохранение…" : saving === "saved" ? "Сохранено" : saving === "error" ? "Ошибка сохранения" : ""}
</span>
</div>
<div className="chips" style={{ marginBottom: "var(--space-4)" }}>
{FILTERS.map(([v, label]) => (
<button key={v} className={`chip ${state === v ? "on" : ""}`} onClick={() => setState(v)}>{label}</button>
))}
</div>
{items === null ? <Spinner /> : !items.length ? (
<EmptyState icon="check" title={state === "todo" ? "Здесь всё переведено" : "Ничего не найдено"} />
) : (
<>
<div className="muted small" style={{ marginBottom: "var(--space-2)" }}>Найдено: {total}</div>
<div className="tr-list">
{items.map((it, i) => (
<Row key={it.key} item={it} versionId={versionId} lang={lang} editable={ov.editable}
canApprove={ov.canApprove} terms={terms}
onChange={(t, s) => update(it.key, t, s)} onBlur={() => void flush()}
onNext={() => {
void flush();
const next = document.querySelectorAll<HTMLTextAreaElement>(".tr-list textarea")[i + 1];
next?.focus();
}} />
))}
</div>
{items.length < total && (
<div className="center" style={{ marginTop: "var(--space-4)" }}>
<button className="btn" onClick={() => void more()}>Показать ещё ({total - items.length})</button>
</div>
)}
</>
)}
</div>
);
}
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<HTMLTextAreaElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight + 2}px`;
}, [item.translation]);
return (
<div className={`tr-row state-${item.state}`}>
<div className="tr-src">
<div className="tr-meta">
{item.who ? <b>{item.who}</b> : item.usage ? <span>{USAGE[item.usage] ?? item.usage}</span> : <span className="muted">рассказчик</span>}
{item.label && <span className="muted">· {item.label}</span>}
<span className={`badge ${STATE_BADGE[item.state]}`}>{STATE_LABEL[item.state]}</span>
</div>
<p className="tr-text">{highlight(item.source, terms)}</p>
{item.module !== undefined && (
<div className="tr-row-actions">
<button className="btn btn-sm btn-ghost" onClick={() => setContext((c) => !c)} aria-expanded={context}>
<Icon name="info" size={14} />Контекст
</button>
{item.nodeId && (
<Link className="btn btn-sm btn-ghost" to={`/play/${versionId}?at=${item.nodeId}&lang=${lang}`} target="_blank">
<Icon name="play" size={14} />Играть отсюда
</Link>
)}
</div>
)}
{context && (
<div className="tr-context">
{item.scene && <img src={item.scene} alt="" loading="lazy" />}
<div>
{item.prev && <p className="muted small">↑ {item.prev}</p>}
<p className="small"><b>→ {item.source}</b></p>
{item.next && <p className="muted small">↓ {item.next}</p>}
</div>
</div>
)}
</div>
<div className="tr-dst">
<textarea ref={ref} rows={2} value={item.translation} readOnly={!editable} lang={lang}
placeholder={editable ? "Перевод…" : ""}
onChange={(e) => onChange(e.target.value)} onBlur={onBlur}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onNext();
}
}} />
{editable && (
<div className="tr-row-actions">
{!item.translation && (
<button className="btn btn-sm btn-ghost" title="Скопировать оригинал" onClick={() => onChange(item.source)}>
<Icon name="copy" size={14} />Оригинал
</button>
)}
{item.translation && item.state === "outdated" && (
<button className="btn btn-sm" title="Оригинал изменился, но перевод верен"
onClick={() => onChange(item.translation, "translated")}><Icon name="check" size={14} />Актуально</button>
)}
{item.translation && (item.state === "fuzzy" || item.state === "machine") && (
<button className="btn btn-sm" onClick={() => onChange(item.translation, "translated")}>
<Icon name="check" size={14} />Проверено
</button>
)}
{canApprove && item.translation && item.state !== "approved" && item.state !== "outdated" && (
<button className="btn btn-sm" onClick={() => onChange(item.translation, "approved")}>
<Icon name="star" size={14} />Одобрить
</button>
)}
{item.translation && item.state !== "fuzzy" && (
<button className="btn btn-sm btn-ghost" title="Пометить как требующий проверки"
onClick={() => onChange(item.translation, "fuzzy")}>Неточно</button>
)}
</div>
)}
</div>
</div>
);
}
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 ? <mark key={i} title={`${term.t}${term.note ? ` — ${term.note}` : ""}`}>{part}</mark> : 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<GlossaryTerm[]>(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 (
<div className="card">
<p className="muted small" style={{ marginTop: 0 }}>
Имена и термины, которые нужно переводить единообразно. В репликах они подсвечиваются,
при наведении виден принятый перевод.
</p>
<div className="table-wrap">
<table className="table tr-glossary">
<thead><tr><th>Оригинал</th><th>Перевод</th><th>Заметка</th><th /></tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
<td><input value={r.src} readOnly={!editable} onChange={(e) => set(i, "src", e.target.value)} /></td>
<td><input value={r.t} readOnly={!editable} onChange={(e) => set(i, "t", e.target.value)} lang={lang} /></td>
<td><input value={r.note} readOnly={!editable} onChange={(e) => set(i, "note", e.target.value)} /></td>
<td>
{editable && (
<button className="btn btn-sm btn-ghost btn-icon btn-danger" aria-label="Удалить термин"
onClick={() => setRows((rs) => rs.filter((_, n) => n !== i))}><Icon name="x" size={14} /></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{editable && (
<div className="actions" style={{ marginTop: "var(--space-3)" }}>
<button className="btn btn-sm" onClick={() => setRows((rs) => [...rs, { src: "", t: "", note: "" }])}>
<Icon name="plus" size={16} />Термин
</button>
<button className="btn btn-sm btn-primary" onClick={() => void save()}><Icon name="check" size={16} />Сохранить</button>
</div>
)}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
/* Translation editor */
.tr-langbar { display: flex; flex-direction: column; gap: var(--space-3); margin-bottom: var(--space-4); }
.tr-langs { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; }
.tr-lang {
display: inline-flex;
align-items: baseline;
gap: 0.45rem;
padding: 0.4rem 0.8rem;
border-radius: var(--radius-s);
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text-2);
font: inherit;
cursor: pointer;
}
.tr-lang small { color: var(--muted); }
.tr-lang:hover { border-color: var(--border-strong); color: var(--text); }
.tr-lang.on { border-color: var(--accent); background: var(--accent-soft); color: var(--text); }
.tr-tools { flex-wrap: wrap; }
.tr-progress { display: flex; flex-direction: column; gap: 0.35rem; }
.tr-bar { display: flex; height: 8px; border-radius: 99px; overflow: hidden; background: var(--surface-3); }
.tr-bar > div { height: 100%; }
.seg-approved { background: var(--ok); }
.seg-ok { background: color-mix(in srgb, var(--ok) 60%, var(--surface-3)); }
.seg-warn { background: var(--accent); }
.seg-error { background: var(--danger); }
.tr-tabs { margin-bottom: var(--space-4); }
.tr-filters { margin-bottom: var(--space-3); flex-wrap: wrap; }
.tr-filters select { height: 2.9rem; max-width: 18rem; }
.tr-saving { min-width: 8rem; }
.tr-saving.error { color: var(--danger); }
.tr-saving.saved { color: var(--ok); }
.tr-list { display: flex; flex-direction: column; gap: var(--space-2); }
.tr-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--border-strong);
border-radius: var(--radius-s);
}
.tr-row.state-untranslated { border-left-color: var(--border-strong); }
.tr-row.state-outdated { border-left-color: var(--danger); }
.tr-row.state-fuzzy, .tr-row.state-machine { border-left-color: var(--accent); }
.tr-row.state-translated, .tr-row.state-approved { border-left-color: var(--ok); }
.tr-row:focus-within { border-color: var(--accent); }
.tr-meta { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; font-size: 0.85rem; }
.tr-meta .badge { margin-left: auto; }
.tr-text { margin: 0.35rem 0; white-space: pre-wrap; line-height: 1.5; }
.tr-text mark { background: var(--info-soft); color: inherit; border-bottom: 1px dotted var(--info); cursor: help; }
.tr-row-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; }
.tr-dst { display: flex; flex-direction: column; gap: 0.35rem; }
.tr-dst textarea { width: 100%; resize: vertical; min-height: 3.2rem; line-height: 1.5; font: inherit; }
.tr-dst textarea[readonly] { opacity: 0.8; }
.tr-context {
display: flex;
gap: var(--space-3);
margin-top: var(--space-2);
padding: var(--space-2);
border-radius: var(--radius-s);
background: var(--surface-2);
}
.tr-context img { width: 180px; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.tr-context p { margin: 0.2rem 0; }
.tr-glossary input { width: 100%; }
@media (max-width: 860px) {
.tr-row { grid-template-columns: 1fr; gap: var(--space-2); }
.tr-context { flex-direction: column; }
.tr-context img { width: 100%; }
}
+57 -3
View File
@@ -407,6 +407,7 @@ export class Engine {
try { try {
this.tr = await this.loadLocale(lang); this.tr = await this.loadLocale(lang);
rememberLanguage(lang); rememberLanguage(lang);
this.applyLocaleFont();
this.notify(languageName(this.game, lang)); this.notify(languageName(this.game, lang));
} catch (err) { } catch (err) {
this.notify(`Не удалось загрузить язык: ${(err as Error).message}`); this.notify(`Не удалось загрузить язык: ${(err as Error).message}`);
@@ -995,7 +996,53 @@ export class Engine {
this.showMainMenu(); this.showMainMenu();
} }
private newGame() { /** Preview from a given line (translation editor): game variables keep their defaults. */
startAt(nodeId: string) {
const at = this.findNode(nodeId);
if (!at) {
this.start();
this.notify("Строка не найдена в этой версии");
return;
}
this.newGame(at);
// rebuild the picture: replay the visual statements of the label that come before the line
const body = this.labels.get(at.label)!.label.body;
for (const n of body.slice(0, at.path[0])) {
if (n.op === "scene") {
this.renderer.scene();
if (n.img) this.showNode(n);
} else if (n.op === "show") this.showNode(n);
else if (n.op === "hide") this.renderer.hide(n.tag);
}
this.renderer.commit();
this.notify("Предпросмотр с выбранной строки — переменные игры по умолчанию");
}
private findNode(id: string): Frame | null {
const visit = (nodes: Node[], label: string, prefix: number[]): Frame | null => {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const path = [...prefix, i];
if (n.id === id || (n.op === "menu" && n.say?.id === id)) return { label, path };
const children: [Node[], number][] =
n.op === "if" ? n.branches.map((b: any, k: number) => [b.body, k])
: n.op === "menu" ? n.items.map((it: any, k: number) => [it.body, k])
: n.op === "while" ? [[n.body, 0]] : [];
for (const [body, k] of children) {
const hit = visit(body, label, [...path, k]);
if (hit) return hit;
}
}
return null;
};
for (const { label } of this.labels.values()) {
const hit = visit(label.body, label.name, []);
if (hit) return hit;
}
return null;
}
private newGame(at?: Frame) {
this.restart(() => { this.restart(() => {
this.audio.stopAll(); this.audio.stopAll();
this.renderer.scene(); this.renderer.scene();
@@ -1006,8 +1053,8 @@ export class Engine {
this.history = []; this.history = [];
this.lastSay = null; this.lastSay = null;
this.textbox.classList.add("pg-hidden"); this.textbox.classList.add("pg-hidden");
this.cur = { label: this.game.entry, path: [0] }; this.cur = at ?? { label: this.game.entry, path: [0] };
this.markSeen(this.game.entry); this.markSeen(this.cur.label);
this.settle(); this.settle();
}); });
} }
@@ -1095,6 +1142,7 @@ export class Engine {
if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color); if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color);
this.loadFont("pg-game-font", ui.text_font); this.loadFont("pg-game-font", ui.text_font);
this.loadFont("pg-game-name-font", ui.name_text_font ?? ui.text_font); this.loadFont("pg-game-name-font", ui.name_text_font ?? ui.text_font);
this.applyLocaleFont();
this.textbox = el("div", "pg-textbox pg-hidden"); this.textbox = el("div", "pg-textbox pg-hidden");
const boxH = px(ui.textbox_height, h, Math.round(h * 0.257)); const boxH = px(ui.textbox_height, h, Math.round(h * 0.257));
@@ -1171,6 +1219,12 @@ export class Engine {
}, { passive: true }); }, { passive: true });
} }
/** A translation may bring its own font when the game's fonts lack its glyphs. */
private applyLocaleFont() {
this.renderer.stage.classList.remove("pg-locale-font");
if (this.tr.font) this.loadFont("pg-locale-font", this.tr.font);
}
private loadFont(family: string, file?: string) { private loadFont(family: string, file?: string) {
const url = file ? this.fileUrl(file) : null; const url = file ? this.fileUrl(file) : null;
if (!url) return; if (!url) return;
+6 -2
View File
@@ -7,6 +7,7 @@ export interface LocaleInfo {
renpy: string; renpy: string;
modules: Record<string, string>; modules: Record<string, string>;
strings: string; strings: string;
font?: string | null;
lines: number; lines: number;
progress: number; progress: number;
} }
@@ -24,6 +25,7 @@ export class Translator {
public readonly renpyName: string | null, public readonly renpyName: string | null,
private lines: Map<string, string> = new Map(), private lines: Map<string, string> = new Map(),
private strings: Map<string, string> = new Map(), private strings: Map<string, string> = new Map(),
public readonly font: string | null = null, // logical path of the locale's font
) {} ) {}
static source(game: GameData) { static source(game: GameData) {
@@ -77,11 +79,13 @@ export async function loadTranslator(game: GameData, manifest: Manifest, lang: s
return r.json() as Promise<Record<string, Entry>>; return r.json() as Promise<Record<string, Entry>>;
}); });
}; };
const [strings, ...modules] = await Promise.all([blob(info.strings), ...Object.values(info.modules).map(blob)]); const [strings, ...modules] = await Promise.all([
info.strings ? blob(info.strings) : Promise.resolve({} as Record<string, Entry>),
...Object.values(info.modules).map(blob)]);
const lines = new Map<string, string>(); const lines = new Map<string, string>();
for (const mod of modules) for (const [tid, e] of Object.entries(mod)) lines.set(tid, e.t); for (const mod of modules) for (const [tid, e] of Object.entries(mod)) lines.set(tid, e.t);
return new Translator(lang, info.renpy, lines, return new Translator(lang, info.renpy, lines,
new Map(Object.entries(strings).map(([k, e]) => [k, e.t]))); new Map(Object.entries(strings).map(([k, e]) => [k, e.t])), info.font ?? null);
} }
export function languageName(game: GameData, lang: string): string { export function languageName(game: GameData, lang: string): string {
+7 -2
View File
@@ -45,7 +45,10 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`); const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
const game = await getJSON<GameData>(blob(manifest, "game.json")); const game = await getJSON<GameData>(blob(manifest, "game.json"));
onProgress(`Загрузка скриптов (${game.scripts.length})`); onProgress(`Загрузка скриптов (${game.scripts.length})`);
const lang = preferredLanguage(game); // ?lang= and ?at= come from the translation editor ("play from here")
const url = new URLSearchParams(location.search);
const asked = url.get("lang");
const lang = asked && (asked === game.sourceLang || game.locales?.[asked]) ? asked : preferredLanguage(game);
const [modules, tr] = await Promise.all([ const [modules, tr] = await Promise.all([
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))), Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
loadTranslator(game, manifest, lang), loadTranslator(game, manifest, lang),
@@ -53,6 +56,8 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
const engine = new Engine(root, game, manifest, modules, saveApi(versionId), const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
`pgvn:persistent:${versionId}`, onExit, tr, `pgvn:persistent:${versionId}`, onExit, tr,
(code) => loadTranslator(game, manifest, code)); (code) => loadTranslator(game, manifest, code));
engine.start(); const at = url.get("at");
if (at) engine.startAt(at);
else engine.start();
return engine; return engine;
} }
+1
View File
@@ -23,6 +23,7 @@
} }
.pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; } .pg-stage.pg-game-font { font-family: "pg-game-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; } .pg-stage.pg-game-name-font .pg-namebox { font-family: "pg-game-name-font", "Segoe UI", system-ui, sans-serif; }
.pg-stage.pg-locale-font, .pg-stage.pg-locale-font .pg-namebox { font-family: "pg-locale-font", "Segoe UI", system-ui, sans-serif; }
.pg-layer { position: absolute; inset: 0; } .pg-layer { position: absolute; inset: 0; }
.pg-transition { pointer-events: none; } .pg-transition { pointer-events: none; }
+6
View File
@@ -31,6 +31,12 @@ const PATHS = {
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-15v5l3 2", clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-15v5l3 2",
menu: "M4 6h16M4 12h16M4 18h16", menu: "M4 6h16M4 12h16M4 18h16",
power: "M12 2v10m6.4-6.4a9 9 0 1 1-12.8 0", power: "M12 2v10m6.4-6.4a9 9 0 1 1-12.8 0",
globe: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20ZM2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10Z",
download: "M12 4v12m0 0-5-5m5 5 5-5M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3",
book: "M4 19.5A2.5 2.5 0 0 1 6.5 17H20V3H6.5A2.5 2.5 0 0 0 4 5.5v14ZM4 19.5A2.5 2.5 0 0 0 6.5 22H20v-5",
send: "M22 2 11 13M22 2l-7 20-4-9-9-4 20-7Z",
star: "m12 3 2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.3 6.5 20.2l1-6.2L3 9.6l6.2-.9L12 3Z",
type: "M4 7V4h16v3M9 20h6M12 4v16",
} as const; } as const;
export type IconName = keyof typeof PATHS; export type IconName = keyof typeof PATHS;