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:
1 parent
4e047f3aa0
commit
ac164d1782
20 files changed
+2039
-298
No files matched your search
+2
-2
@@ -8,7 +8,7 @@ from app.blobs import BLOB_ID_RE, blob_path
|
||||
from app.bootstrap import bootstrap
|
||||
from app.config import get_settings
|
||||
from app.db import SessionLocal
|
||||
from app.routers import auth, games, roles, users, versions
|
||||
from app.routers import auth, games, i18n, metadata, roles, users, versions
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
@@ -24,7 +24,7 @@ async def lifespan(_: FastAPI):
|
||||
|
||||
app = FastAPI(title="pgames-manager", lifespan=lifespan, docs_url="/api/docs",
|
||||
openapi_url="/api/openapi.json")
|
||||
for r in (auth, users, roles, games, versions):
|
||||
for r in (auth, users, roles, games, versions, i18n, metadata):
|
||||
app.include_router(r.router)
|
||||
|
||||
|
||||
|
||||
+165
-158
@@ -1,158 +1,165 @@
|
||||
import io
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import exists, func, or_, select, true
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.blobs import put_bytes
|
||||
from app.uploads import remove_upload
|
||||
from app.deps import DB, CurrentUser, has_perm, require
|
||||
from app.models import Game, GameVersion, User
|
||||
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn
|
||||
|
||||
router = APIRouter(prefix="/api/games", tags=["games"])
|
||||
Player = Annotated[User, Depends(require("games.play"))]
|
||||
Uploader = Annotated[User, Depends(require("games.upload"))]
|
||||
|
||||
MAX_COVER_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
def visible_versions_filter(user: User):
|
||||
"""Versions a user can see: ready ones, plus their own in any state."""
|
||||
if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"):
|
||||
return true()
|
||||
return or_(GameVersion.status == "ready", GameVersion.owner_id == user.id)
|
||||
|
||||
|
||||
async def get_game(db: DB, game_id: int) -> Game:
|
||||
game = await db.get(Game, game_id)
|
||||
if not game:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Игра не найдена")
|
||||
return game
|
||||
|
||||
|
||||
def can_edit_game(user: User, game: Game) -> bool:
|
||||
return has_perm(user, "games.edit") or (
|
||||
has_perm(user, "games.upload") and game.created_by_id == user.id
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[GameOut])
|
||||
async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[dict]:
|
||||
stmt = select(Game).order_by(Game.title)
|
||||
if q.strip():
|
||||
pattern = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Game.title.ilike(pattern),
|
||||
Game.description.ilike(pattern),
|
||||
Game.tags.any(q.strip().lower()),
|
||||
)
|
||||
)
|
||||
if tag:
|
||||
stmt = stmt.where(Game.tags.any(tag.lower()))
|
||||
stmt = stmt.where(
|
||||
exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user))
|
||||
| (Game.created_by_id == user.id)
|
||||
)
|
||||
games = list((await db.scalars(stmt.limit(500))).all())
|
||||
counts: dict[int, dict[str, int]] = {}
|
||||
if games:
|
||||
rows = await db.execute(
|
||||
select(GameVersion.game_id, GameVersion.status, func.count())
|
||||
.where(GameVersion.game_id.in_([g.id for g in games]), visible_versions_filter(user))
|
||||
.group_by(GameVersion.game_id, GameVersion.status)
|
||||
)
|
||||
for game_id, st, n in rows:
|
||||
key = "ready" if st == "ready" else "failed" if st == "failed" else "pending"
|
||||
counts.setdefault(game_id, {}).setdefault(key, 0)
|
||||
counts[game_id][key] += n
|
||||
return [
|
||||
{
|
||||
**GameOut.model_validate(g).model_dump(),
|
||||
"ready_versions": counts.get(g.id, {}).get("ready", 0),
|
||||
"pending_versions": counts.get(g.id, {}).get("pending", 0),
|
||||
"failed_versions": counts.get(g.id, {}).get("failed", 0),
|
||||
}
|
||||
for g in games
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=GameOut, status_code=201)
|
||||
async def create_game(body: GameIn, user: Uploader, db: DB) -> Game:
|
||||
game = Game(
|
||||
title=body.title.strip(),
|
||||
description=body.description,
|
||||
tags=sorted({t.strip().lower() for t in body.tags if t.strip()}),
|
||||
created_by_id=user.id,
|
||||
)
|
||||
db.add(game)
|
||||
await db.commit()
|
||||
return game
|
||||
|
||||
|
||||
@router.get("/{game_id}", response_model=GameDetailOut)
|
||||
async def game_detail(game_id: int, user: Player, db: DB) -> dict:
|
||||
game = await get_game(db, game_id)
|
||||
versions = (
|
||||
await db.scalars(
|
||||
select(GameVersion)
|
||||
.where(GameVersion.game_id == game.id, visible_versions_filter(user))
|
||||
.order_by(GameVersion.parent_id.is_not(None), GameVersion.created_at)
|
||||
)
|
||||
).all()
|
||||
return {**GameOut.model_validate(game).model_dump(), "versions": list(versions)}
|
||||
|
||||
|
||||
@router.patch("/{game_id}", response_model=GameOut)
|
||||
async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: DB) -> Game:
|
||||
game = await get_game(db, game_id)
|
||||
if not can_edit_game(user, game):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
|
||||
if body.title is not None:
|
||||
game.title = body.title.strip()
|
||||
if body.description is not None:
|
||||
game.description = body.description
|
||||
if body.tags is not None:
|
||||
game.tags = sorted({t.strip().lower() for t in body.tags if t.strip()})
|
||||
await db.commit()
|
||||
return game
|
||||
|
||||
|
||||
@router.put("/{game_id}/cover", response_model=GameOut)
|
||||
async def upload_cover(game_id: int, request: Request, user: CurrentUser, db: DB) -> Game:
|
||||
game = await get_game(db, game_id)
|
||||
if not can_edit_game(user, game):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
|
||||
data = await request.body()
|
||||
if len(data) > MAX_COVER_BYTES:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл")
|
||||
try:
|
||||
game.cover_hash = await run_in_threadpool(make_cover, data)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Не удалось прочитать изображение")
|
||||
await db.commit()
|
||||
return game
|
||||
|
||||
|
||||
@router.delete("/{game_id}", status_code=204)
|
||||
async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.delete"))],
|
||||
db: DB) -> None:
|
||||
game = await get_game(db, game_id)
|
||||
version_ids = (await db.scalars(select(GameVersion.id).where(GameVersion.game_id == game.id))).all()
|
||||
await db.delete(game)
|
||||
await db.commit()
|
||||
for vid in version_ids:
|
||||
remove_upload(vid)
|
||||
|
||||
|
||||
def make_cover(data: bytes) -> str:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.thumbnail((640, 640))
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGBA")
|
||||
out = io.BytesIO()
|
||||
img.save(out, "WEBP", quality=85)
|
||||
return put_bytes(out.getvalue(), ".webp")[0]
|
||||
import io
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from sqlalchemy import and_, exists, func, or_, select, true
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from app.blobs import put_bytes
|
||||
from app.uploads import remove_upload
|
||||
from app.deps import DB, CurrentUser, has_perm, require
|
||||
from app.models import Game, GameVersion, User
|
||||
from app.schemas import GameDetailOut, GameIn, GameOut, GameUpdateIn, VersionOut
|
||||
from app.translation import version_languages
|
||||
|
||||
router = APIRouter(prefix="/api/games", tags=["games"])
|
||||
Player = Annotated[User, Depends(require("games.play"))]
|
||||
Uploader = Annotated[User, Depends(require("games.upload"))]
|
||||
|
||||
MAX_COVER_BYTES = 20 * 1024 * 1024
|
||||
|
||||
|
||||
def visible_versions_filter(user: User):
|
||||
"""Versions a user can see: ready published ones (reviewers also see drafts),
|
||||
plus their own in any state."""
|
||||
if has_perm(user, "versions.manage_any") or has_perm(user, "games.delete"):
|
||||
return true()
|
||||
ready = GameVersion.status == "ready"
|
||||
if not has_perm(user, "versions.publish"):
|
||||
ready = and_(ready, GameVersion.visibility == "published")
|
||||
return or_(ready, GameVersion.owner_id == user.id)
|
||||
|
||||
|
||||
async def get_game(db: DB, game_id: int) -> Game:
|
||||
game = await db.get(Game, game_id)
|
||||
if not game:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Игра не найдена")
|
||||
return game
|
||||
|
||||
|
||||
def can_edit_game(user: User, game: Game) -> bool:
|
||||
return has_perm(user, "games.edit") or (
|
||||
has_perm(user, "games.upload") and game.created_by_id == user.id
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[GameOut])
|
||||
async def list_games(user: Player, db: DB, q: str = "", tag: str = "") -> list[dict]:
|
||||
stmt = select(Game).order_by(Game.title)
|
||||
if q.strip():
|
||||
pattern = f"%{q.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
Game.title.ilike(pattern),
|
||||
Game.description.ilike(pattern),
|
||||
Game.tags.any(q.strip().lower()),
|
||||
)
|
||||
)
|
||||
if tag:
|
||||
stmt = stmt.where(Game.tags.any(tag.lower()))
|
||||
stmt = stmt.where(
|
||||
exists().where(GameVersion.game_id == Game.id, visible_versions_filter(user))
|
||||
| (Game.created_by_id == user.id)
|
||||
)
|
||||
games = list((await db.scalars(stmt.limit(500))).all())
|
||||
counts: dict[int, dict[str, int]] = {}
|
||||
if games:
|
||||
rows = await db.execute(
|
||||
select(GameVersion.game_id, GameVersion.status, func.count())
|
||||
.where(GameVersion.game_id.in_([g.id for g in games]), visible_versions_filter(user))
|
||||
.group_by(GameVersion.game_id, GameVersion.status)
|
||||
)
|
||||
for game_id, st, n in rows:
|
||||
key = "ready" if st == "ready" else "failed" if st == "failed" else "pending"
|
||||
counts.setdefault(game_id, {}).setdefault(key, 0)
|
||||
counts[game_id][key] += n
|
||||
return [
|
||||
{
|
||||
**GameOut.model_validate(g).model_dump(),
|
||||
"ready_versions": counts.get(g.id, {}).get("ready", 0),
|
||||
"pending_versions": counts.get(g.id, {}).get("pending", 0),
|
||||
"failed_versions": counts.get(g.id, {}).get("failed", 0),
|
||||
}
|
||||
for g in games
|
||||
]
|
||||
|
||||
|
||||
@router.post("", response_model=GameOut, status_code=201)
|
||||
async def create_game(body: GameIn, user: Uploader, db: DB) -> Game:
|
||||
game = Game(
|
||||
title=body.title.strip(),
|
||||
description=body.description,
|
||||
tags=sorted({t.strip().lower() for t in body.tags if t.strip()}),
|
||||
created_by_id=user.id,
|
||||
)
|
||||
db.add(game)
|
||||
await db.commit()
|
||||
return game
|
||||
|
||||
|
||||
@router.get("/{game_id}", response_model=GameDetailOut)
|
||||
async def game_detail(game_id: int, user: Player, db: DB) -> dict:
|
||||
game = await get_game(db, game_id)
|
||||
versions = (
|
||||
await db.scalars(
|
||||
select(GameVersion)
|
||||
.where(GameVersion.game_id == game.id, visible_versions_filter(user))
|
||||
.order_by(GameVersion.parent_id.is_not(None), GameVersion.created_at)
|
||||
)
|
||||
).all()
|
||||
return {**GameOut.model_validate(game).model_dump(),
|
||||
"versions": [{**VersionOut.model_validate(v).model_dump(), **version_languages(v.manifest)}
|
||||
for v in versions]}
|
||||
|
||||
|
||||
@router.patch("/{game_id}", response_model=GameOut)
|
||||
async def update_game(game_id: int, body: GameUpdateIn, user: CurrentUser, db: DB) -> Game:
|
||||
game = await get_game(db, game_id)
|
||||
if not can_edit_game(user, game):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
|
||||
if body.title is not None:
|
||||
game.title = body.title.strip()
|
||||
if body.description is not None:
|
||||
game.description = body.description
|
||||
if body.tags is not None:
|
||||
game.tags = sorted({t.strip().lower() for t in body.tags if t.strip()})
|
||||
await db.commit()
|
||||
return game
|
||||
|
||||
|
||||
@router.put("/{game_id}/cover", response_model=GameOut)
|
||||
async def upload_cover(game_id: int, request: Request, user: CurrentUser, db: DB) -> Game:
|
||||
game = await get_game(db, game_id)
|
||||
if not can_edit_game(user, game):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на редактирование")
|
||||
data = await request.body()
|
||||
if len(data) > MAX_COVER_BYTES:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл")
|
||||
try:
|
||||
game.cover_hash = await run_in_threadpool(make_cover, data)
|
||||
except (UnidentifiedImageError, OSError):
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Не удалось прочитать изображение")
|
||||
await db.commit()
|
||||
return game
|
||||
|
||||
|
||||
@router.delete("/{game_id}", status_code=204)
|
||||
async def delete_game(game_id: int, _: Annotated[User, Depends(require("games.delete"))],
|
||||
db: DB) -> None:
|
||||
game = await get_game(db, game_id)
|
||||
version_ids = (await db.scalars(select(GameVersion.id).where(GameVersion.game_id == game.id))).all()
|
||||
await db.delete(game)
|
||||
await db.commit()
|
||||
for vid in version_ids:
|
||||
remove_upload(vid)
|
||||
|
||||
|
||||
def make_cover(data: bytes) -> str:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.thumbnail((640, 640))
|
||||
if img.mode not in ("RGB", "RGBA"):
|
||||
img = img.convert("RGBA")
|
||||
out = io.BytesIO()
|
||||
img.save(out, "WEBP", quality=85)
|
||||
return put_bytes(out.getvalue(), ".webp")[0]
|
||||
@@ -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)}
|
||||
@@ -5,9 +5,13 @@ from sqlalchemy import select
|
||||
from starlette.requests import ClientDisconnect
|
||||
|
||||
from app.deps import DB, CurrentUser, has_perm, require
|
||||
from app.models import GameVersion, Save, User
|
||||
from app.models import Game, GameVersion, Save, User, VersionHistory
|
||||
from app.routers.games import get_game
|
||||
from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut
|
||||
from app.schemas import (
|
||||
ForkIn, HistoryOut, PublishIn, SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn,
|
||||
VersionOut,
|
||||
)
|
||||
from app import translation
|
||||
from app.uploads import remove_upload, upload_path
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["versions"])
|
||||
@@ -18,7 +22,8 @@ ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".
|
||||
|
||||
|
||||
def can_see(user: User, v: GameVersion) -> bool:
|
||||
return v.status == "ready" or v.owner_id == user.id or can_manage(user, v)
|
||||
published = v.visibility == "published" or has_perm(user, "versions.publish")
|
||||
return (v.status == "ready" and published) or v.owner_id == user.id or can_manage(user, v)
|
||||
|
||||
|
||||
def can_manage(user: User, v: GameVersion) -> bool:
|
||||
@@ -93,9 +98,12 @@ async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, A
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
|
||||
async def fork_version(version_id: int, body: VersionIn,
|
||||
user: Annotated[User, Depends(require("games.play", "versions.fork"))],
|
||||
async def fork_version(version_id: int, body: ForkIn,
|
||||
user: Annotated[User, Depends(require("games.play"))],
|
||||
db: DB) -> GameVersion:
|
||||
needed = "translations.edit" if body.kind == "translation" else "versions.fork"
|
||||
if not has_perm(user, needed):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, f"Нет прав: {needed}")
|
||||
parent = await get_version(db, user, version_id)
|
||||
if parent.status != "ready":
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию")
|
||||
@@ -106,6 +114,8 @@ async def fork_version(version_id: int, body: VersionIn,
|
||||
name=body.name.strip(),
|
||||
description=body.description,
|
||||
status="ready",
|
||||
visibility="draft",
|
||||
kind=body.kind,
|
||||
manifest=parent.manifest,
|
||||
report=parent.report,
|
||||
size_bytes=parent.size_bytes,
|
||||
@@ -115,6 +125,78 @@ async def fork_version(version_id: int, body: VersionIn,
|
||||
return fork
|
||||
|
||||
|
||||
Publisher = Annotated[User, Depends(require("versions.publish"))]
|
||||
|
||||
|
||||
async def lock_version(db: DB, version_id: int) -> GameVersion:
|
||||
"""Row-locked version for read-modify-write of its manifest."""
|
||||
v = await db.scalar(select(GameVersion).where(GameVersion.id == version_id)
|
||||
.with_for_update(of=GameVersion)
|
||||
.execution_options(populate_existing=True))
|
||||
if v is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
|
||||
return v
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/publish", response_model=VersionOut)
|
||||
async def publish_version(version_id: int, body: PublishIn, user: Publisher, db: DB) -> GameVersion:
|
||||
"""Publish a draft. A translation is merged into the game's main version in place,
|
||||
so players keep their saves; the previous manifest goes to the history."""
|
||||
v = await get_version(db, user, version_id)
|
||||
if v.status != "ready":
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
|
||||
game = await db.get(Game, v.game_id)
|
||||
if v.kind == "translation" and game.main_version_id and game.main_version_id != v.id:
|
||||
main = await lock_version(db, game.main_version_id)
|
||||
parent = await db.get(GameVersion, v.parent_id) if v.parent_id else None
|
||||
langs = translation.changed_languages(v.manifest, parent.manifest if parent else None)
|
||||
if not langs:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "В версии нет изменённых переводов")
|
||||
db.add(VersionHistory(version_id=main.id, user_id=user.id, manifest=main.manifest,
|
||||
note=f"Перевод из «{v.name}»: {', '.join(langs)}"))
|
||||
main.manifest = translation.merge_languages(main.manifest, v.manifest, langs)
|
||||
elif body.make_main:
|
||||
game.main_version_id = v.id
|
||||
v.visibility = "published"
|
||||
await db.commit()
|
||||
await db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/make-main", response_model=VersionOut)
|
||||
async def make_main(version_id: int, user: Publisher, db: DB) -> GameVersion:
|
||||
v = await get_version(db, user, version_id)
|
||||
if v.status != "ready":
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
|
||||
game = await db.get(Game, v.game_id)
|
||||
game.main_version_id = v.id
|
||||
v.visibility = "published"
|
||||
await db.commit()
|
||||
await db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
@router.get("/versions/{version_id}/history", response_model=list[HistoryOut])
|
||||
async def version_history(version_id: int, user: Player, db: DB) -> list[VersionHistory]:
|
||||
await get_version(db, user, version_id)
|
||||
return list((await db.scalars(select(VersionHistory).where(VersionHistory.version_id == version_id)
|
||||
.order_by(VersionHistory.id.desc()))).all())
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/history/{history_id}/restore", response_model=VersionOut)
|
||||
async def restore_history(version_id: int, history_id: int, user: Publisher, db: DB) -> GameVersion:
|
||||
v = await lock_version(db, version_id)
|
||||
h = await db.get(VersionHistory, history_id)
|
||||
if h is None or h.version_id != v.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Запись истории не найдена")
|
||||
db.add(VersionHistory(version_id=v.id, user_id=user.id, manifest=v.manifest,
|
||||
note=f"До отката к записи от {h.created_at:%d.%m.%Y %H:%M}"))
|
||||
v.manifest = h.manifest
|
||||
await db.commit()
|
||||
await db.refresh(v)
|
||||
return v
|
||||
|
||||
|
||||
@router.patch("/versions/{version_id}", response_model=VersionOut)
|
||||
async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion:
|
||||
v = await get_version(db, user, version_id)
|
||||
|
||||
@@ -76,6 +76,11 @@ class VersionOut(ORM):
|
||||
progress: str
|
||||
size_bytes: int
|
||||
created_at: datetime
|
||||
visibility: str = "published"
|
||||
kind: str = "original"
|
||||
# from the version's game.json (filled by the game detail endpoint)
|
||||
source_lang: str | None = None
|
||||
languages: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
class VersionDetailOut(VersionOut):
|
||||
@@ -90,6 +95,7 @@ class GameOut(ORM):
|
||||
engine: str
|
||||
cover_hash: str | None
|
||||
created_at: datetime
|
||||
main_version_id: int | None = None
|
||||
# version counts visible to the requesting user (filled by the list endpoint)
|
||||
ready_versions: int = 0
|
||||
pending_versions: int = 0
|
||||
@@ -117,6 +123,20 @@ class VersionIn(BaseModel):
|
||||
description: str = ""
|
||||
|
||||
|
||||
class ForkIn(VersionIn):
|
||||
kind: str = Field(default="mod", pattern="^(mod|translation)$")
|
||||
|
||||
|
||||
class PublishIn(BaseModel):
|
||||
make_main: bool = False
|
||||
|
||||
|
||||
class HistoryOut(ORM):
|
||||
id: int
|
||||
note: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class SaveIn(BaseModel):
|
||||
title: str = ""
|
||||
data: dict[str, Any]
|
||||
|
||||
@@ -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
|
||||
@@ -87,6 +87,8 @@ async def convert(version_id: int, source: Path, filename: str) -> bool:
|
||||
read_bytes(result.cover_source))
|
||||
except OSError:
|
||||
pass
|
||||
if game and game.main_version_id is None:
|
||||
game.main_version_id = version.id
|
||||
if game and result.title and game.title == source.name:
|
||||
game.title = result.title
|
||||
await db.commit()
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -164,6 +164,37 @@ translate russian strings:
|
||||
авторы ещё не перевели, а ~750 блоков на язык относятся к уже изменённым
|
||||
английским репликам. Ren'Py их тоже не показывает.
|
||||
|
||||
Этапы 2 и 3 сделаны:
|
||||
|
||||
* у игры есть главная версия (`games.main_version_id`), у версий —
|
||||
`visibility` (`draft`/`published`) и `kind` (`original`/`translation`/`mod`);
|
||||
миграция `0002` проставила главную версию существующим играм;
|
||||
* права `translations.edit` и `versions.publish`, у системной роли
|
||||
moderator они включены;
|
||||
* кнопка «Перевести» создаёт черновик-форк вида «перевод», «Опубликовать»
|
||||
вливает изменённые языки **в саму главную версию**: её ID не меняется,
|
||||
сохранения игроков остаются. Прежний манифест пишется в `version_history`
|
||||
(`GET /api/versions/{id}/history`, откат —
|
||||
`POST …/history/{hid}/restore`);
|
||||
* редактор перевода `/versions/{id}/translate`:
|
||||
* языки с прогрессом по состояниям, реплики и строки (имена, меню, текст
|
||||
на экране);
|
||||
* фильтры «к работе / не переведено / устарело / неточно / машинный /
|
||||
переведено / одобрено», поиск, фильтр по файлу скрипта;
|
||||
* автосохранение, Ctrl+Enter — к следующей строке;
|
||||
* кнопки «одобрить» (только у публикующих), «актуально» для устаревших,
|
||||
«неточно», «скопировать оригинал»;
|
||||
* контекст строки: фон сцены, соседние реплики, «играть отсюда»
|
||||
(`/play/{id}?at=<узел>&lang=<язык>`, картинка сцены восстанавливается);
|
||||
* глоссарий с подсветкой терминов в оригинале;
|
||||
* экспорт и импорт PO/CSV, шрифт для языка;
|
||||
* API: `GET/PUT /api/versions/{id}/i18n[/{lang}]`, `…/entries`, `…/meta`,
|
||||
`…/font`, `…/export`, `…/import`, `DELETE …/{lang}`.
|
||||
|
||||
Пока не сделано: UI истории версии (сейчас есть только API), картинки и
|
||||
озвучка для отдельного языка, правка реплик прямо в плеере, машинный
|
||||
предперевод (заложено только состояние `machine`).
|
||||
|
||||
Уже сконвертированные версии остаются без ID и без переводов, их нужно
|
||||
переимпортировать. Пока в библиотеке только тестовые данные, отдельную
|
||||
миграцию не делаем.
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Login } from "./pages/Login";
|
||||
import { NewGame } from "./pages/NewGame";
|
||||
import { Play } from "./pages/Play";
|
||||
import { Roles } from "./pages/Roles";
|
||||
import { TranslationEditor } from "./pages/TranslationEditor";
|
||||
import { Users } from "./pages/Users";
|
||||
import { useTheme } from "./theme";
|
||||
import { Icon } from "./ui/Icon";
|
||||
@@ -108,6 +109,7 @@ export function App() {
|
||||
<Route path="/" element={<RequirePerm perm="games.play"><Library /></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="/versions/:versionId/translate" element={<RequirePerm perm="games.play"><TranslationEditor /></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="/account" element={<ChangeCredentials />} />
|
||||
|
||||
+128
-123
@@ -1,123 +1,128 @@
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: Role;
|
||||
is_active: boolean;
|
||||
must_change_password: boolean;
|
||||
must_change_username: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Me extends User {
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
id: number;
|
||||
game_id: number;
|
||||
parent_id: number | null;
|
||||
owner: { id: number; username: string } | null;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed";
|
||||
progress: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
report?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
engine: string;
|
||||
cover_hash: string | null;
|
||||
created_at: string;
|
||||
ready_versions?: number;
|
||||
pending_versions?: number;
|
||||
failed_versions?: number;
|
||||
}
|
||||
|
||||
export interface GameDetail extends Game {
|
||||
versions: Version[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!r.ok) {
|
||||
let msg = r.statusText;
|
||||
try {
|
||||
const data = await r.json();
|
||||
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
|
||||
} catch {
|
||||
/* not json */
|
||||
}
|
||||
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized"));
|
||||
throw new ApiError(r.status, msg);
|
||||
}
|
||||
return r.status === 204 ? (undefined as T) : r.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string) => request<T>("GET", url),
|
||||
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
|
||||
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),
|
||||
};
|
||||
|
||||
/** Upload a raw body with progress (fetch has no upload progress). */
|
||||
export function uploadRaw<T>(method: string, url: string, file: Blob,
|
||||
onProgress: (fraction: number) => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
|
||||
xhr.onload = () => {
|
||||
let data: any = null;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
|
||||
else if (xhr.status === 413 && !data?.detail)
|
||||
reject(new ApiError(413, "Файл слишком большой для прокси перед сервером (client_max_body_size)"));
|
||||
else reject(new ApiError(xhr.status, data?.detail ?? (xhr.statusText || `Ошибка ${xhr.status}`)));
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
const units = ["КБ", "МБ", "ГБ", "ТБ"];
|
||||
let v = bytes / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
export interface Role {
|
||||
id: number;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
is_system: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
role: Role;
|
||||
is_active: boolean;
|
||||
must_change_password: boolean;
|
||||
must_change_username: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Me extends User {
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface Version {
|
||||
id: number;
|
||||
game_id: number;
|
||||
parent_id: number | null;
|
||||
owner: { id: number; username: string } | null;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed";
|
||||
visibility: "draft" | "published";
|
||||
kind: "original" | "translation" | "mod";
|
||||
source_lang: string | null;
|
||||
languages: { code: string; name: string; progress: number }[];
|
||||
progress: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
report?: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
engine: string;
|
||||
cover_hash: string | null;
|
||||
main_version_id: number | null;
|
||||
created_at: string;
|
||||
ready_versions?: number;
|
||||
pending_versions?: number;
|
||||
failed_versions?: number;
|
||||
}
|
||||
|
||||
export interface GameDetail extends Game {
|
||||
versions: Version[];
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
|
||||
const r = await fetch(url, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!r.ok) {
|
||||
let msg = r.statusText;
|
||||
try {
|
||||
const data = await r.json();
|
||||
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
|
||||
} catch {
|
||||
/* not json */
|
||||
}
|
||||
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized"));
|
||||
throw new ApiError(r.status, msg);
|
||||
}
|
||||
return r.status === 204 ? (undefined as T) : r.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string) => request<T>("GET", url),
|
||||
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
|
||||
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),
|
||||
};
|
||||
|
||||
/** Upload a raw body with progress (fetch has no upload progress). */
|
||||
export function uploadRaw<T>(method: string, url: string, file: Blob,
|
||||
onProgress: (fraction: number) => void): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url);
|
||||
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
||||
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
|
||||
xhr.onload = () => {
|
||||
let data: any = null;
|
||||
try {
|
||||
data = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(data);
|
||||
else if (xhr.status === 413 && !data?.detail)
|
||||
reject(new ApiError(413, "Файл слишком большой для прокси перед сервером (client_max_body_size)"));
|
||||
else reject(new ApiError(xhr.status, data?.detail ?? (xhr.statusText || `Ошибка ${xhr.status}`)));
|
||||
};
|
||||
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
const units = ["КБ", "МБ", "ГБ", "ТБ"];
|
||||
let v = bytes / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
|
||||
}
|
||||
@@ -80,6 +80,38 @@ export function GamePage() {
|
||||
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан");
|
||||
};
|
||||
|
||||
const translate = async (v: Version) => {
|
||||
const name = await ui.prompt({
|
||||
title: "Новый перевод",
|
||||
text: "Создаётся ваш черновик — копия версии, в которой можно переводить. Игроки увидят перевод после публикации.",
|
||||
label: "Название черновика", initial: `Перевод (${me?.username})`, confirmLabel: "Начать перевод",
|
||||
});
|
||||
if (!name) return;
|
||||
try {
|
||||
const fork = await api.post<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) =>
|
||||
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}), "Обложка обновлена");
|
||||
|
||||
@@ -111,7 +143,8 @@ export function GamePage() {
|
||||
|
||||
const ready = game.versions.filter((v) => v.status === "ready");
|
||||
const lastId = getRecent().find((r) => r.gameId === game.id)?.versionId;
|
||||
const playVersion = ready.find((v) => v.id === lastId) ?? ready.find((v) => v.parent_id === null) ?? ready[0];
|
||||
const mainVersion = ready.find((v) => v.id === game.main_version_id);
|
||||
const playVersion = ready.find((v) => v.id === lastId) ?? mainVersion ?? ready.find((v) => v.parent_id === null) ?? ready[0];
|
||||
|
||||
const byParent = new Map<number | null, Version[]>();
|
||||
for (const v of game.versions) {
|
||||
@@ -120,8 +153,10 @@ export function GamePage() {
|
||||
}
|
||||
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
|
||||
(byParent.get(parent) ?? []).flatMap((v) => [
|
||||
<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")}
|
||||
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)}
|
||||
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
|
||||
onDelete={() => void removeVersion(v)} />,
|
||||
@@ -188,9 +223,13 @@ export function GamePage() {
|
||||
);
|
||||
}
|
||||
|
||||
const KIND: Record<Version["kind"], string> = { original: "", translation: "Перевод", mod: "Мод" };
|
||||
|
||||
function VersionRow(props: {
|
||||
v: Version; depth: number; current: boolean; canManage: boolean; canFork: boolean;
|
||||
v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean;
|
||||
canTranslate: boolean; canPublish: boolean;
|
||||
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
|
||||
onTranslate: () => void; onPublish: () => void; onMakeMain: () => void;
|
||||
}) {
|
||||
const { v } = props;
|
||||
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
|
||||
@@ -206,16 +245,40 @@ function VersionRow(props: {
|
||||
<div className="version-title">
|
||||
{props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>}
|
||||
<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} />
|
||||
</div>
|
||||
<div className="muted small version-meta">
|
||||
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
|
||||
{v.progress && ` · ${v.progress}`}
|
||||
</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 className="actions">
|
||||
{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.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") && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => void toggleReport()} aria-expanded={report !== undefined}>
|
||||
<Icon name="file" size={16} />Отчёт
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%; }
|
||||
}
|
||||
@@ -407,6 +407,7 @@ export class Engine {
|
||||
try {
|
||||
this.tr = await this.loadLocale(lang);
|
||||
rememberLanguage(lang);
|
||||
this.applyLocaleFont();
|
||||
this.notify(languageName(this.game, lang));
|
||||
} catch (err) {
|
||||
this.notify(`Не удалось загрузить язык: ${(err as Error).message}`);
|
||||
@@ -995,7 +996,53 @@ export class Engine {
|
||||
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.audio.stopAll();
|
||||
this.renderer.scene();
|
||||
@@ -1006,8 +1053,8 @@ export class Engine {
|
||||
this.history = [];
|
||||
this.lastSay = null;
|
||||
this.textbox.classList.add("pg-hidden");
|
||||
this.cur = { label: this.game.entry, path: [0] };
|
||||
this.markSeen(this.game.entry);
|
||||
this.cur = at ?? { label: this.game.entry, path: [0] };
|
||||
this.markSeen(this.cur.label);
|
||||
this.settle();
|
||||
});
|
||||
}
|
||||
@@ -1095,6 +1142,7 @@ export class Engine {
|
||||
if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color);
|
||||
this.loadFont("pg-game-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");
|
||||
const boxH = px(ui.textbox_height, h, Math.round(h * 0.257));
|
||||
@@ -1171,6 +1219,12 @@ export class Engine {
|
||||
}, { 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) {
|
||||
const url = file ? this.fileUrl(file) : null;
|
||||
if (!url) return;
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface LocaleInfo {
|
||||
renpy: string;
|
||||
modules: Record<string, string>;
|
||||
strings: string;
|
||||
font?: string | null;
|
||||
lines: number;
|
||||
progress: number;
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export class Translator {
|
||||
public readonly renpyName: string | null,
|
||||
private lines: 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) {
|
||||
@@ -77,11 +79,13 @@ export async function loadTranslator(game: GameData, manifest: Manifest, lang: s
|
||||
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>();
|
||||
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,
|
||||
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 {
|
||||
|
||||
@@ -45,7 +45,10 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
|
||||
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
|
||||
const game = await getJSON<GameData>(blob(manifest, "game.json"));
|
||||
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([
|
||||
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
|
||||
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),
|
||||
`pgvn:persistent:${versionId}`, onExit, tr,
|
||||
(code) => loadTranslator(game, manifest, code));
|
||||
engine.start();
|
||||
const at = url.get("at");
|
||||
if (at) engine.startAt(at);
|
||||
else engine.start();
|
||||
return engine;
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
}
|
||||
.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-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-transition { pointer-events: none; }
|
||||
|
||||
@@ -31,6 +31,12 @@ const PATHS = {
|
||||
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-15v5l3 2",
|
||||
menu: "M4 6h16M4 12h16M4 18h16",
|
||||
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;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
Reference in new issue
Block a user