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

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

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

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

No files matched your search

+2 -2
View File
@@ -8,7 +8,7 @@ from app.blobs import BLOB_ID_RE, blob_path
from app.bootstrap import bootstrap
from app.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
View File
@@ -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]
+246
View File
@@ -0,0 +1,246 @@
"""Translation editing API for a version."""
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import Response
from pydantic import BaseModel, Field
from starlette.concurrency import run_in_threadpool
from app import translation as tr
from app.blobs import put_bytes
from app.converter.locales import LANG_NAMES
from app.deps import DB, has_perm, require
from app.models import Game, GameVersion, User
from app.routers.versions import can_manage, get_version, lock_version
router = APIRouter(prefix="/api/versions/{version_id}/i18n", tags=["i18n"])
Player = Annotated[User, Depends(require("games.play"))]
Translator = Annotated[User, Depends(require("games.play", "translations.edit"))]
TODO_STATES = {"untranslated", "outdated", "fuzzy", "machine"}
LANG_RE = r"^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$"
FONT_EXTS = (".ttf", ".otf", ".woff", ".woff2")
MAX_FONT_BYTES = 30 * 1024 * 1024
MAX_IMPORT_BYTES = 50 * 1024 * 1024
def can_edit(user: User, v: GameVersion) -> bool:
"""Drafts are edited by their owner; publishers may also fix published versions."""
if not has_perm(user, "translations.edit"):
return False
if has_perm(user, "versions.publish"):
return True
return v.visibility == "draft" and can_manage(user, v)
async def ready_version(db: DB, user: User, version_id: int) -> GameVersion:
v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return v
async def editable_version(db: DB, user: User, version_id: int) -> GameVersion:
await ready_version(db, user, version_id)
v = await lock_version(db, version_id)
if not can_edit(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN,
"Переводить можно в своём черновике (форке «перевод»)")
return v
def check_lang(lang: str) -> str:
import re
if not re.match(LANG_RE, lang):
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Код языка в формате BCP-47, например ru")
return lang
@router.get("")
async def overview(version_id: int, user: Player, db: DB) -> dict[str, Any]:
v = await ready_version(db, user, version_id)
def build() -> dict[str, Any]:
game = tr.file_json(v.manifest, "game.json")
idx = tr.text_index(v.manifest)
langs = []
for code, info in sorted((game.get("locales") or {}).items()):
langs.append({"code": code, "name": info.get("name", code), "font": info.get("font"),
**tr.stats(v.manifest, code)})
return {
"sourceLang": game.get("sourceLang"),
"editable": can_edit(user, v),
"canApprove": has_perm(user, "versions.publish"),
"lines": len(idx.lines),
"strings": len(idx.strings),
"modules": [{"path": p, "lines": n} for p, n in idx.modules if n],
"languages": langs,
"knownLanguages": LANG_NAMES,
}
return await run_in_threadpool(build)
@router.get("/{lang}/entries")
async def entries(version_id: int, lang: str, user: Player, db: DB, kind: str = "lines",
state: str = "all", module: str = "", q: str = "", offset: int = 0,
limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]:
v = await ready_version(db, user, version_id)
needle = q.strip().lower()
def matches(st: str, *texts: str) -> bool:
if state == "todo" and st not in TODO_STATES:
return False
if state not in ("all", "todo") and st != state:
return False
return not needle or any(needle in t.lower() for t in texts if t)
def build() -> dict[str, Any]:
idx = tr.text_index(v.manifest)
lines, strings, _ = tr.locale_tables(v.manifest, lang)
files = v.manifest["files"]
items: list[dict[str, Any]] = []
total = 0
if kind == "strings":
for src, usage in idx.strings.items():
e = strings.get(src) or {}
st = e.get("state", "translated") if e.get("t") else "untranslated"
if not matches(st, src, e.get("t", "")):
continue
total += 1
if offset < total <= offset + limit:
items.append({"key": src, "usage": usage, "source": src,
"translation": e.get("t", ""), "state": st})
else:
for info in idx.lines:
if module and info.module != module:
continue
e = lines.get(info.tid)
st = tr.line_state(e, info)
if not matches(st, info.source, (e or {}).get("t", ""), info.who):
continue
total += 1
if offset < total <= offset + limit:
scene = files.get(info.scene) if info.scene else None
items.append({
"key": info.tid, "nodeId": info.node_id, "module": info.module,
"label": info.label, "who": info.who, "source": info.source,
"prev": info.prev, "next": info.next,
"scene": f"/blobs/{scene['hash']}" if scene else None,
"translation": (e or {}).get("t", ""), "state": st,
})
return {"total": total, "items": items}
return await run_in_threadpool(build)
@router.get("/{lang}/meta")
async def get_meta(version_id: int, lang: str, user: Player, db: DB) -> dict[str, Any]:
v = await ready_version(db, user, version_id)
_, _, meta = await run_in_threadpool(tr.locale_tables, v.manifest, lang)
return {"glossary": meta.get("glossary", []), "font": meta.get("font")}
class Changes(BaseModel):
name: str | None = Field(default=None, max_length=64)
lines: dict[str, dict[str, Any] | None] = {}
strings: dict[str, dict[str, Any] | None] = {}
glossary: list[dict[str, str]] | None = None
@router.put("/{lang}")
async def save(version_id: int, lang: str, body: Changes, user: Translator, db: DB
) -> dict[str, Any]:
"""Create a language or change entries. `null` removes an entry."""
check_lang(lang)
v = await editable_version(db, user, version_id)
meta = None
if body.glossary is not None:
meta = {"glossary": [{"src": g.get("src", "").strip(), "t": g.get("t", "").strip(),
"note": g.get("note", "").strip()}
for g in body.glossary if g.get("src", "").strip()]}
try:
manifest = await run_in_threadpool(
lambda: tr.apply_changes(v.manifest, lang, lines=body.lines, strings=body.strings,
meta=meta, name=body.name,
can_approve=has_perm(user, "versions.publish")))
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from None
v.manifest = manifest
await db.commit()
return await run_in_threadpool(tr.stats, manifest, lang)
@router.delete("/{lang}", status_code=204)
async def remove_language(version_id: int, lang: str, user: Translator, db: DB) -> None:
v = await editable_version(db, user, version_id)
files = {p: f for p, f in v.manifest["files"].items() if not p.startswith(f"locales/{lang}/")}
game = dict(tr.file_json(v.manifest, "game.json"))
game["locales"] = {k: i for k, i in (game.get("locales") or {}).items() if k != lang}
tr.put_json(files, "game.json", game)
v.manifest = {**v.manifest, "files": files}
await db.commit()
@router.put("/{lang}/font")
async def upload_font(version_id: int, lang: str, filename: str, request: Request,
user: Translator, db: DB) -> dict[str, Any]:
"""A font for this language, for games whose fonts lack its glyphs."""
check_lang(lang)
ext = "." + filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
if ext not in FONT_EXTS:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Шрифт: {', '.join(FONT_EXTS)}")
data = await request.body()
if not data or len(data) > MAX_FONT_BYTES:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой или слишком большой файл")
v = await editable_version(db, user, version_id)
path = f"locales/{lang}/font{ext}"
def store() -> dict[str, Any]:
blob_id, size = put_bytes(data, ext)
manifest = {**v.manifest, "files": {**v.manifest["files"], path: {"hash": blob_id, "size": size}}}
return tr.apply_changes(manifest, lang, meta={"font": path})
v.manifest = await run_in_threadpool(store)
await db.commit()
return {"font": path}
@router.get("/{lang}/export")
async def export(version_id: int, lang: str, user: Player, db: DB, format: str = "po") -> Response:
v = await ready_version(db, user, version_id)
game = await db.get(Game, v.game_id)
if format == "csv":
body = await run_in_threadpool(tr.export_csv, v.manifest, lang)
media, ext = "text/csv", "csv"
else:
body = await run_in_threadpool(tr.export_po, v.manifest, lang, game.title)
media, ext = "text/x-gettext-translation", "po"
name = f"{game.id}-{v.id}-{lang}.{ext}"
return Response("" + body if ext == "csv" else body, media_type=f"{media}; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{name}"'})
@router.post("/{lang}/import")
async def import_file(version_id: int, lang: str, request: Request, user: Translator, db: DB,
format: str = "po", overwrite: bool = True) -> dict[str, Any]:
check_lang(lang)
data = await request.body()
if len(data) > MAX_IMPORT_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Слишком большой файл")
text = data.decode("utf-8-sig", errors="replace")
lines, strings = tr.parse_csv(text) if format == "csv" else tr.parse_po(text)
v = await editable_version(db, user, version_id)
if not overwrite:
have_lines, have_strings, _ = tr.locale_tables(v.manifest, lang)
lines = {k: x for k, x in lines.items() if not (have_lines.get(k) or {}).get("t")}
strings = {k: x for k, x in strings.items() if not (have_strings.get(k) or {}).get("t")}
try:
v.manifest = await run_in_threadpool(
lambda: tr.apply_changes(v.manifest, lang, lines=lines, strings=strings,
can_approve=has_perm(user, "versions.publish")))
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e)) from None
await db.commit()
return {"lines": len(lines), "strings": len(strings), **tr.stats(v.manifest, lang)}
+87 -5
View File
@@ -5,9 +5,13 @@ from sqlalchemy import select
from starlette.requests import ClientDisconnect
from 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)
+20
View File
@@ -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]
+420
View File
@@ -0,0 +1,420 @@
"""Server-side work with a version's translations.
Everything here reads immutable blobs, so parsed JSON and the text index are
cached by blob hash and never go stale. Writes produce new blobs and return a
new manifest; the caller stores it on the version.
"""
import copy
import csv
import io
import json
import re
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import PurePosixPath
from typing import Any
from app.blobs import put_bytes, read_bytes
from app.converter.locales import LANG_NAMES, RENPY_LANGS, short_hash, walk
STATES = ("translated", "fuzzy", "machine", "approved")
@lru_cache(maxsize=512)
def blob_json(blob_id: str) -> Any:
"""Parsed JSON of a blob. Shared between callers: treat as read-only."""
return json.loads(read_bytes(blob_id))
def file_json(manifest: dict[str, Any], path: str) -> Any:
f = manifest["files"].get(path)
return blob_json(f["hash"]) if f else None
def put_json(files: dict[str, Any], path: str, value: Any) -> None:
payload = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json")
files[path] = {"hash": blob_id, "size": size}
# --- text index ------------------------------------------------------------------
@dataclass
class LineInfo:
tid: str
node_id: str
module: str # script path in the manifest
label: str
who: str
source: str
scene: str | None # logical path of the background shown at this line
prev: str | None = None
next: str | None = None
@dataclass
class TextIndex:
lines: list[LineInfo] = field(default_factory=list)
by_tid: dict[str, LineInfo] = field(default_factory=dict)
strings: dict[str, str] = field(default_factory=dict) # source text -> usage
modules: list[tuple[str, int]] = field(default_factory=list)
def text_index(manifest: dict[str, Any]) -> TextIndex:
game_hash = manifest["files"]["game.json"]["hash"]
game = blob_json(game_hash)
scripts = tuple((p, manifest["files"][p]["hash"]) for p in game["scripts"])
return _build_index(game_hash, scripts)
@lru_cache(maxsize=16)
def _build_index(game_hash: str, scripts: tuple[tuple[str, str], ...]) -> TextIndex:
game = blob_json(game_hash)
chars = game.get("characters", {})
images = game.get("images", {})
idx = TextIndex()
for c in chars.values():
if isinstance(c.get("name"), str) and c["name"].strip():
idx.strings.setdefault(c["name"], "name")
for path, blob in scripts:
module = blob_json(blob)
count = 0
for label in module["labels"]:
scene: str | None = None
group = label["name"]
for node in walk(label["body"]):
op = node.get("op")
if op == "label":
group = node["name"]
elif op == "scene":
scene = _image_file(images, node.get("img")) or (None if node.get("img") else scene)
elif op == "menu":
for item in node["items"]:
idx.strings.setdefault(item["text"], "menu")
elif op == "show" and isinstance((node.get("img") or {}).get("text"), str):
idx.strings.setdefault(node["img"]["text"], "text")
elif op == "say" and node.get("tid"):
who = node.get("whoStr") or ""
if not who and node.get("who"):
c = chars.get(node["who"], {})
who = c.get("name") if isinstance(c.get("name"), str) else node["who"]
info = LineInfo(node["tid"], node.get("id", ""), path, group, who or "",
node["what"], scene)
if idx.lines and idx.lines[-1].module == path:
idx.lines[-1].next = info.source
info.prev = idx.lines[-1].source
idx.lines.append(info)
idx.by_tid[info.tid] = info
count += 1
idx.modules.append((path, count))
return idx
def _image_file(images: dict[str, Any], img: dict[str, Any] | None) -> str | None:
if not img or not img.get("name"):
return None
name = img["name"]
for n in range(len(name), 0, -1):
d = images.get(" ".join(name[:n]))
if d:
return d.get("file") or (d.get("frames") or [{}])[0].get("img", {}).get("file")
return None
def version_languages(manifest: dict[str, Any] | None) -> dict[str, Any]:
"""Source language and translations of a version, for listings."""
if not manifest or "game.json" not in manifest.get("files", {}):
return {"source_lang": None, "languages": []}
game = file_json(manifest, "game.json")
return {
"source_lang": game.get("sourceLang"),
"languages": [{"code": code, "name": info.get("name", code),
"progress": info.get("progress", 0)}
for code, info in sorted((game.get("locales") or {}).items())],
}
# --- reading locales ------------------------------------------------------------------
def locale_tables(manifest: dict[str, Any], lang: str) -> tuple[dict, dict, dict]:
"""(lines by tid, strings by source text, meta) of a language; empty if absent."""
game = file_json(manifest, "game.json")
info = (game.get("locales") or {}).get(lang)
if not info:
return {}, {}, {}
lines: dict[str, Any] = {}
for path in info.get("modules", {}).values():
lines.update(file_json(manifest, path) or {})
strings = file_json(manifest, info["strings"]) or {} if info.get("strings") else {}
meta = file_json(manifest, info["meta"]) or {} if info.get("meta") else {}
return lines, strings, meta
def line_state(entry: dict[str, Any] | None, info: LineInfo) -> str:
if not entry or not entry.get("t"):
return "untranslated"
if entry.get("src") and entry["src"] != short_hash(info.source):
return "outdated"
return entry.get("state", "translated")
def stats(manifest: dict[str, Any], lang: str) -> dict[str, Any]:
idx = text_index(manifest)
lines, strings, _ = locale_tables(manifest, lang)
counts = {s: 0 for s in ("untranslated", "outdated", *STATES)}
for info in idx.lines:
counts[line_state(lines.get(info.tid), info)] += 1
total = len(idx.lines) or 1
done = len(idx.lines) - counts["untranslated"]
return {
"lines": len(idx.lines),
"translated": done,
"progress": round(done / total, 3),
"states": counts,
"strings": len(idx.strings),
"stringsTranslated": sum(1 for s in idx.strings if (strings.get(s) or {}).get("t")),
}
# --- writing -----------------------------------------------------------------------------
def _locale_path(lang: str, script_path: str) -> str:
return f"locales/{lang}/" + str(PurePosixPath(script_path).relative_to("script"))
def renpy_name(lang: str) -> str:
return next((name for name, code in RENPY_LANGS.items() if code == lang), lang)
def apply_changes(manifest: dict[str, Any], lang: str, *, lines: dict[str, Any] | None = None,
strings: dict[str, Any] | None = None, meta: dict[str, Any] | None = None,
name: str | None = None, can_approve: bool = False) -> dict[str, Any]:
"""Return a new manifest with the given entries changed (``None`` removes an entry)."""
manifest = {**manifest, "files": dict(manifest["files"])}
files = manifest["files"]
game = copy.deepcopy(file_json(manifest, "game.json"))
if lang == game.get("sourceLang"):
raise ValueError("Это исходный язык игры")
idx = text_index({"files": files})
locales = game.setdefault("locales", {})
info = locales.setdefault(lang, {"name": name or LANG_NAMES.get(lang, lang),
"renpy": renpy_name(lang), "modules": {}})
if name:
info["name"] = name
def entry(value: dict[str, Any], src: str | None) -> dict[str, Any] | None:
text = (value.get("t") or "").strip("\n")
if not text.strip():
return None
state = value.get("state") or "translated"
if state not in STATES or (state == "approved" and not can_approve):
state = "translated"
e = {"t": text, "state": state}
if src is not None:
e["src"] = short_hash(src)
return e
if lines:
by_module: dict[str, dict[str, Any]] = {}
for tid, value in lines.items():
line = idx.by_tid.get(tid)
if line is None:
continue
by_module.setdefault(line.module, {})[tid] = value
for module, changes in by_module.items():
path = info["modules"].get(module) or _locale_path(lang, module)
table = dict(file_json(manifest, path) or {}) if path in files else {}
for tid, value in changes.items():
e = entry(value, idx.by_tid[tid].source) if value else None
if e:
table[tid] = e
else:
table.pop(tid, None)
put_json(files, path, table)
info["modules"][module] = path
if strings:
path = info.get("strings") or f"locales/{lang}/strings.json"
table = dict(file_json(manifest, path) or {}) if path in files else {}
for src, value in strings.items():
e = entry(value, None) if value else None
if e:
table[src] = e
else:
table.pop(src, None)
put_json(files, path, table)
info["strings"] = path
if meta is not None:
path = f"locales/{lang}/meta.json"
current = dict(file_json(manifest, path) or {}) if path in files else {}
current.update(meta)
put_json(files, path, current)
info["meta"] = path
info["font"] = current.get("font")
info.setdefault("strings", None)
# stats read the locale index from game.json, so store it before counting
put_json(files, "game.json", game)
st = stats(manifest, lang)
info["lines"] = st["translated"]
info["progress"] = st["progress"]
info["strings_count"] = st["stringsTranslated"]
put_json(files, "game.json", game)
return manifest
def merge_languages(target: dict[str, Any], source: dict[str, Any], langs: list[str]
) -> dict[str, Any]:
"""Copy whole languages from one manifest into another (publishing a translation)."""
target = {**target, "files": dict(target["files"])}
files = target["files"]
game = copy.deepcopy(file_json(target, "game.json"))
src_game = file_json(source, "game.json")
for lang in langs:
info = (src_game.get("locales") or {}).get(lang)
if not info:
continue
prefix = f"locales/{lang}/"
for path in [p for p in files if p.startswith(prefix)]:
del files[path]
for path, f in source["files"].items():
if path.startswith(prefix):
files[path] = f
game.setdefault("locales", {})[lang] = copy.deepcopy(info)
put_json(files, "game.json", game)
for lang in langs:
if lang in game["locales"]:
st = stats(target, lang) # target scripts may differ from the fork's
game["locales"][lang].update(lines=st["translated"], progress=st["progress"])
put_json(files, "game.json", game)
return target
def changed_languages(fork: dict[str, Any], base: dict[str, Any] | None) -> list[str]:
"""Languages whose files differ between a fork and its base."""
fork_locales = (file_json(fork, "game.json").get("locales") or {})
if base is None:
return list(fork_locales)
out = []
for lang in fork_locales:
prefix = f"locales/{lang}/"
mine = {p: f["hash"] for p, f in fork["files"].items() if p.startswith(prefix)}
theirs = {p: f["hash"] for p, f in base["files"].items() if p.startswith(prefix)}
if mine != theirs:
out.append(lang)
return out
# --- PO / CSV --------------------------------------------------------------------------
def _po_quote(s: str) -> str:
s = s.replace("\\", "\\\\").replace('"', '\\"').replace("\t", "\\t")
if "\n" not in s:
return f'"{s}"'
parts = s.split("\n")
return '""\n' + "\n".join(f'"{p}\\n"' if i < len(parts) - 1 else f'"{p}"'
for i, p in enumerate(parts) if p or i < len(parts) - 1)
def export_po(manifest: dict[str, Any], lang: str, game_title: str) -> str:
idx = text_index(manifest)
lines, strings, _ = locale_tables(manifest, lang)
out = [f'# {game_title}', 'msgid ""', 'msgstr ""',
'"Content-Type: text/plain; charset=UTF-8\\n"', f'"Language: {lang}\\n"', ""]
for info in idx.lines:
e = lines.get(info.tid) or {}
state = line_state(e, info)
out.append(f"#. {info.who}" if info.who else "#.")
out.append(f"#: {info.module} {info.label}")
if state in ("outdated", "fuzzy", "machine"):
out.append("#, fuzzy")
out += [f"msgctxt {_po_quote(info.tid)}", f"msgid {_po_quote(info.source)}",
f"msgstr {_po_quote(e.get('t', ''))}", ""]
for src, usage in idx.strings.items():
e = strings.get(src) or {}
out += [f"#. {usage}", f'msgctxt "string"', f"msgid {_po_quote(src)}",
f"msgstr {_po_quote(e.get('t', ''))}", ""]
return "\n".join(out)
_PO_LINE = re.compile(r'^(msgctxt|msgid|msgstr)\s+(".*")\s*$|^(".*")\s*$')
def _po_unquote(s: str) -> str:
body = s[1:-1]
return re.sub(r"\\(.)", lambda m: {"n": "\n", "t": "\t"}.get(m.group(1), m.group(1)), body)
def parse_po(text: str) -> tuple[dict[str, Any], dict[str, Any]]:
"""Returns (lines by tid, strings by source) from a PO file made by export_po."""
lines: dict[str, Any] = {}
strings: dict[str, Any] = {}
cur: dict[str, str] = {}
fuzzy = False
last = None
def flush():
nonlocal cur, fuzzy
if cur.get("msgid") and "msgstr" in cur:
value = {"t": cur["msgstr"], "state": "fuzzy" if fuzzy else "translated"}
if cur.get("msgctxt") == "string":
strings[cur["msgid"]] = value
elif cur.get("msgctxt"):
lines[cur["msgctxt"]] = value
cur, fuzzy = {}, False
for raw in text.splitlines():
line = raw.strip()
if not line:
flush()
last = None
continue
if line.startswith("#,") and "fuzzy" in line:
fuzzy = True
continue
if line.startswith("#"):
continue
m = _PO_LINE.match(line)
if not m:
continue
if m.group(1):
if m.group(1) == "msgctxt" and "msgid" in cur:
flush()
last = m.group(1)
cur[last] = _po_unquote(m.group(2))
elif last:
cur[last] += _po_unquote(m.group(3))
flush()
return lines, strings
def export_csv(manifest: dict[str, Any], lang: str) -> str:
idx = text_index(manifest)
lines, strings, _ = locale_tables(manifest, lang)
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["kind", "key", "who", "source", "translation", "state"])
for info in idx.lines:
e = lines.get(info.tid) or {}
w.writerow(["line", info.tid, info.who, info.source, e.get("t", ""), line_state(e, info)])
for src, usage in idx.strings.items():
e = strings.get(src) or {}
w.writerow(["string", src, usage, src, e.get("t", ""), e.get("state", "")])
return buf.getvalue()
def parse_csv(text: str) -> tuple[dict[str, Any], dict[str, Any]]:
lines: dict[str, Any] = {}
strings: dict[str, Any] = {}
for row in csv.DictReader(io.StringIO(text.lstrip("\ufeff"))):
if not row.get("translation"):
continue
state = row.get("state") if row.get("state") in STATES else "translated"
value = {"t": row["translation"], "state": state}
if row.get("kind") == "string":
strings[row["key"]] = value
elif row.get("kind") == "line":
lines[row["key"]] = value
return lines, strings
+2
View File
@@ -87,6 +87,8 @@ async def convert(version_id: int, source: Path, filename: str) -> bool:
read_bytes(result.cover_source))
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")
+69
View File
@@ -0,0 +1,69 @@
import json
import pytest
from app import translation as tr
from app.config import get_settings
from app.converter.pipeline import convert_archive
from tests.make_sample import build
@pytest.fixture
def manifest(tmp_path, monkeypatch):
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
get_settings.cache_clear()
archive = build(tmp_path / "sample.zip")
yield convert_archive(archive, "sample.zip", tmp_path / "work", lambda _: None).manifest
get_settings.cache_clear()
def test_index_has_context(manifest):
idx = tr.text_index(manifest)
first = idx.lines[0]
assert first.label == "start" and first.scene == "images/bg room.webp"
assert idx.lines[1].who == "Эйлин" and idx.lines[1].prev == first.source
assert idx.strings["Пойдём гулять"] == "menu" and idx.strings["Эйлин"] == "name"
def test_edit_new_language_and_outdated(manifest):
idx = tr.text_index(manifest)
tid = idx.lines[0].tid
m = tr.apply_changes(manifest, "de", lines={tid: {"t": "Willkommen", "state": "approved"}},
strings={"Эйлин": {"t": "Eileen"}}, meta={"glossary": [{"src": "Эйлин", "t": "Eileen"}]})
lines, strings, meta = tr.locale_tables(m, "de")
assert lines[tid]["t"] == "Willkommen" and lines[tid]["state"] == "translated" # no approve right
assert strings["Эйлин"]["t"] == "Eileen" and meta["glossary"][0]["t"] == "Eileen"
game = tr.file_json(m, "game.json")
assert game["locales"]["de"]["renpy"] == "german" and game["locales"]["de"]["lines"] == 1
# the original line changed in a mod: the translation is kept but marked outdated
info = idx.by_tid[tid]
changed = tr.LineInfo(**{**info.__dict__, "source": "другой текст"})
assert tr.line_state(lines[tid], changed) == "outdated"
# removing an entry
m2 = tr.apply_changes(m, "de", lines={tid: None})
assert tid not in tr.locale_tables(m2, "de")[0]
with pytest.raises(ValueError):
tr.apply_changes(manifest, "ru", lines={tid: {"t": "x"}}) # source language
def test_merge_languages_keeps_target_untouched(manifest):
tid = tr.text_index(manifest).lines[0].tid
fork = tr.apply_changes(manifest, "de", lines={tid: {"t": "Hallo"}})
assert tr.changed_languages(fork, manifest) == ["de"]
merged = tr.merge_languages(manifest, fork, ["de"])
assert tr.locale_tables(merged, "de")[0][tid]["t"] == "Hallo"
assert tr.locale_tables(merged, "en")[0] == tr.locale_tables(manifest, "en")[0]
assert "de" not in (tr.file_json(manifest, "game.json").get("locales") or {})
def test_po_and_csv_roundtrip(manifest):
tid = tr.text_index(manifest).lines[0].tid
m = tr.apply_changes(manifest, "de", lines={tid: {"t": "Zeile 1\nZeile \"2\""}},
strings={"Эйлин": {"t": "Eileen"}})
lines, strings = tr.parse_po(tr.export_po(m, "de", "Test"))
assert lines[tid]["t"] == "Zeile 1\nZeile \"2\"" and strings["Эйлин"]["t"] == "Eileen"
lines, strings = tr.parse_csv(tr.export_csv(m, "de"))
assert lines[tid]["t"] == "Zeile 1\nZeile \"2\"" and strings["Эйлин"]["t"] == "Eileen"
st = tr.stats(m, "de")
assert st["translated"] == 1 and st["stringsTranslated"] == 1
assert json.dumps(st)