407 lines
19 KiB
Python
407 lines
19 KiB
Python
"""Mod editor, revision history, three-way merge and rebase."""
|
|
import base64
|
|
import binascii
|
|
import copy
|
|
import math
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Annotated, Any, Literal
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from starlette.concurrency import run_in_threadpool
|
|
|
|
from app import mods
|
|
from app.blobs import put_file
|
|
from app.config import get_settings
|
|
from app.converter import assets
|
|
from app.deps import DB, has_perm, require
|
|
from app.models import GameVersion, Revision, User
|
|
from app.routers.versions import can_manage, get_version, lock_version
|
|
from app.translation import file_json, put_json
|
|
|
|
router = APIRouter(prefix="/api/versions", tags=["mods"])
|
|
Player = Annotated[User, Depends(require("games.play"))]
|
|
Editor = Annotated[User, Depends(require("games.play", "versions.fork"))]
|
|
|
|
|
|
def editable(v, user):
|
|
if v.status != "ready" or not v.manifest:
|
|
raise HTTPException(409, "Версия ещё не готова")
|
|
if v.kind != "mod" or v.visibility != "draft" or not can_manage(user, v):
|
|
raise HTTPException(403, "Редактировать можно свой черновик мода")
|
|
|
|
|
|
def expected(v, revision):
|
|
if v.revision_id != revision:
|
|
raise HTTPException(409, "Версия изменилась. Загрузите её заново; локальные правки сохранены в редакторе")
|
|
|
|
|
|
def update_manifest(v, manifest):
|
|
v.manifest = manifest
|
|
v.size_bytes = sum(f["size"] for f in manifest["files"].values())
|
|
|
|
|
|
@router.get("/{version_id}/editor")
|
|
async def read_editor(version_id: int, user: Player, db: DB):
|
|
v = await get_version(db, user, version_id)
|
|
if not v.manifest:
|
|
raise HTTPException(409, "Версия ещё не готова")
|
|
base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None
|
|
old = base.manifest["files"] if base else {}
|
|
changes = [p for p in sorted(set(old) | set(v.manifest["files"]))
|
|
if old.get(p) != v.manifest["files"].get(p)] if base else []
|
|
diff = await run_in_threadpool(lambda: mods.differences(
|
|
mods.comparable(mods.prepare(mods.document(base.manifest))),
|
|
mods.comparable(mods.prepare(mods.document(v.manifest))))) if base else []
|
|
return {"revision": v.revision_id, "baseRevision": v.base_revision_id, "changes": changes,
|
|
"diff": diff,
|
|
"document": await run_in_threadpool(lambda: mods.prepare(mods.document(v.manifest))),
|
|
"files": v.manifest["files"], "editable": has_perm(user, "versions.fork") and
|
|
v.kind == "mod" and v.visibility == "draft" and can_manage(user, v)}
|
|
|
|
|
|
class EditIn(BaseModel):
|
|
revision: str
|
|
document: dict[str, Any]
|
|
|
|
|
|
@router.put("/{version_id}/editor")
|
|
async def save_editor(version_id: int, body: EditIn, user: Editor, db: DB):
|
|
await get_version(db, user, version_id)
|
|
v = await lock_version(db, version_id)
|
|
editable(v, user)
|
|
expected(v, body.revision)
|
|
try:
|
|
manifest = await run_in_threadpool(mods.store, body.document, v.manifest)
|
|
except (ValueError, KeyError, TypeError, RecursionError) as e:
|
|
raise HTTPException(422, f"Некорректный PGVN: {e}") from None
|
|
update_manifest(v, manifest)
|
|
await db.commit()
|
|
return {"revision": v.revision_id, "files": v.manifest["files"]}
|
|
|
|
|
|
@router.put("/{version_id}/asset")
|
|
async def replace_asset(version_id: int, path: str, revision: str, filename: str,
|
|
request: Request, user: Editor, db: DB, lossless: bool = False,
|
|
scale: float | None = None):
|
|
await get_version(db, user, version_id)
|
|
v = await lock_version(db, version_id)
|
|
editable(v, user)
|
|
expected(v, revision)
|
|
if scale is not None and (not math.isfinite(scale) or not 0 < scale <= 1024 or assets.kind_of(Path(path)) != "image"):
|
|
raise HTTPException(422, "Некорректный масштаб изображения")
|
|
if lossless and (assets.kind_of(Path(path)) != "image" or Path(filename).suffix.lower() not in (".png", ".webp")):
|
|
raise HTTPException(422, "Замена без потерь принимает только PNG/WebP")
|
|
if path not in v.manifest["files"] or path == "game.json" or path.startswith(("script/", "locales/")):
|
|
raise HTTPException(422, "Выберите существующий медиафайл")
|
|
suffix = Path(filename).suffix.lower()
|
|
if assets.kind_of(Path(path)) not in ("image", "audio", "video", "font") or assets.kind_of(Path(filename)) != assets.kind_of(Path(path)):
|
|
raise HTTPException(422, "Тип загруженного файла не совпадает с заменяемым")
|
|
get_settings().work_dir.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=get_settings().work_dir) as folder:
|
|
root = Path(folder)
|
|
source = root / ("upload" + suffix)
|
|
count = 0
|
|
with source.open("wb") as out:
|
|
async for chunk in request.stream():
|
|
count += len(chunk)
|
|
if count > 512 * 1024 * 1024:
|
|
raise HTTPException(413, "Максимум 512 МБ")
|
|
out.write(chunk)
|
|
if not count:
|
|
raise HTTPException(422, "Пустой файл")
|
|
def convert():
|
|
if lossless:
|
|
output, ext = assets.lossless_image(source, root / "lossless")
|
|
return put_file(output, ext, move=True)
|
|
# Do not accept unparsed webm passthrough for editor uploads.
|
|
if suffix == ".webm":
|
|
output = assets._ffmpeg(source, root / "verified.webm",
|
|
["-c:v", "libvpx-vp9", "-crf", "34", "-b:v", "0", "-deadline", "realtime", "-cpu-used", "8", "-c:a", "libopus"])
|
|
return put_file(output, ".webm", move=True)
|
|
if assets.kind_of(source) == "font":
|
|
signatures = {".ttf": b"\x00\x01\x00\x00", ".otf": b"OTTO", ".woff": b"wOFF", ".woff2": b"wOF2", ".ttc": b"ttcf"}
|
|
with source.open("rb") as stream:
|
|
if stream.read(4) != signatures.get(suffix):
|
|
raise ValueError("Некорректный шрифт")
|
|
output, ext = assets.convert(source, root)
|
|
return put_file(output, ext, move=True)
|
|
try:
|
|
blob_id, size = await run_in_threadpool(convert)
|
|
except Exception as e:
|
|
raise HTTPException(422, f"Не удалось обработать файл: {e}") from None
|
|
manifest = copy.deepcopy(v.manifest)
|
|
manifest["files"][path] = {"hash": blob_id, "size": size}
|
|
await run_in_threadpool(set_asset_scale, manifest, {path: scale})
|
|
update_manifest(v, manifest)
|
|
await db.commit()
|
|
return {"revision": v.revision_id, "files": v.manifest["files"]}
|
|
|
|
|
|
def set_asset_scale(manifest, scales):
|
|
game = copy.deepcopy(file_json(manifest, "game.json"))
|
|
values = game.setdefault("assetScale", {})
|
|
for path, scale in scales.items():
|
|
if scale is None or scale == 1:
|
|
values.pop(path, None)
|
|
else:
|
|
values[path] = scale
|
|
put_json(manifest["files"], "game.json", game)
|
|
|
|
|
|
class RestoreAssetIn(BaseModel):
|
|
revision: str
|
|
path: str
|
|
|
|
|
|
@router.post("/{version_id}/asset/restore")
|
|
async def restore_asset(version_id: int, body: RestoreAssetIn, user: Editor, db: DB):
|
|
await get_version(db, user, version_id)
|
|
v = await lock_version(db, version_id)
|
|
editable(v, user)
|
|
expected(v, body.revision)
|
|
base = await db.get(Revision, v.base_revision_id) if v.base_revision_id else None
|
|
if not base or base.game_id != v.game_id or body.path not in base.manifest["files"] or assets.kind_of(Path(body.path)) != "image":
|
|
raise HTTPException(422, "В базовой ревизии нет этого изображения")
|
|
manifest = copy.deepcopy(v.manifest)
|
|
manifest["files"][body.path] = copy.deepcopy(base.manifest["files"][body.path])
|
|
scale = file_json(base.manifest, "game.json").get("assetScale", {}).get(body.path)
|
|
await run_in_threadpool(set_asset_scale, manifest, {body.path: scale})
|
|
update_manifest(v, manifest)
|
|
await db.commit()
|
|
return {"revision": v.revision_id, "files": v.manifest["files"]}
|
|
|
|
|
|
class AssetIn(BaseModel):
|
|
path: str
|
|
filename: str
|
|
data: str # base64 PNG/WebP, without the data URL prefix
|
|
scale: float = Field(gt=0, le=1024, allow_inf_nan=False)
|
|
|
|
|
|
class AssetsIn(BaseModel):
|
|
revision: str
|
|
assets: list[AssetIn] = Field(min_length=1, max_length=32)
|
|
|
|
|
|
@router.post("/{version_id}/assets")
|
|
async def replace_assets(version_id: int, request: Request, user: Editor, db: DB):
|
|
"""One atomic image-only replacement and revision for a bounded batch."""
|
|
await get_version(db, user, version_id)
|
|
v = await lock_version(db, version_id)
|
|
editable(v, user)
|
|
raw = bytearray()
|
|
async for chunk in request.stream():
|
|
raw.extend(chunk)
|
|
if len(raw) > 128 * 1024 * 1024:
|
|
raise HTTPException(413, "Максимум 128 МБ на пакет")
|
|
try:
|
|
body = AssetsIn.model_validate_json(raw)
|
|
except ValueError:
|
|
raise HTTPException(422, "Некорректный пакет изображений") from None
|
|
expected(v, body.revision)
|
|
if len({a.path for a in body.assets}) != len(body.assets):
|
|
raise HTTPException(422, "Повторяющиеся пути в пакете")
|
|
for a in body.assets:
|
|
if a.path not in v.manifest["files"] or assets.kind_of(Path(a.path)) != "image" or Path(a.filename).suffix.lower() not in (".png", ".webp"):
|
|
raise HTTPException(422, "Выберите существующее изображение и PNG/WebP")
|
|
def build():
|
|
manifest = copy.deepcopy(v.manifest)
|
|
get_settings().work_dir.mkdir(parents=True, exist_ok=True)
|
|
with tempfile.TemporaryDirectory(dir=get_settings().work_dir) as folder:
|
|
root = Path(folder)
|
|
for i, item in enumerate(body.assets):
|
|
source = root / f"source{i}{Path(item.filename).suffix.lower()}"
|
|
source.write_bytes(base64.b64decode(item.data, validate=True))
|
|
output, ext = assets.lossless_image(source, root / f"out{i}")
|
|
blob_id, size = put_file(output, ext, move=True)
|
|
manifest["files"][item.path] = {"hash": blob_id, "size": size}
|
|
set_asset_scale(manifest, {a.path: a.scale for a in body.assets})
|
|
return manifest
|
|
try:
|
|
manifest = await run_in_threadpool(build)
|
|
except (ValueError, OSError, binascii.Error) as e:
|
|
raise HTTPException(422, f"Не удалось обработать пакет: {e}") from None
|
|
update_manifest(v, manifest)
|
|
await db.commit()
|
|
return {"revision": v.revision_id, "files": v.manifest["files"]}
|
|
|
|
|
|
async def revision_graph(db, game_id):
|
|
return {r.id: r for r in (await db.scalars(select(Revision).where(Revision.game_id == game_id))).all()}
|
|
|
|
|
|
def ancestors(graph, rid):
|
|
found, queue = {}, [(rid, 0)]
|
|
while queue:
|
|
key, depth = queue.pop(0)
|
|
if key in found or key not in graph:
|
|
continue
|
|
found[key] = depth
|
|
queue.extend((p, depth + 1) for p in graph[key].parents)
|
|
return found
|
|
|
|
|
|
@router.get("/{version_id}/revisions")
|
|
async def history(version_id: int, user: Player, db: DB):
|
|
v = await get_version(db, user, version_id)
|
|
graph = await revision_graph(db, v.game_id)
|
|
reachable = ancestors(graph, v.revision_id)
|
|
return [{"id": key, "parents": graph[key].parents, "created_at": graph[key].created_at,
|
|
"user_id": graph[key].user_id} for key in sorted(reachable, key=lambda k: graph[k].created_at, reverse=True)]
|
|
|
|
|
|
class RestoreIn(BaseModel):
|
|
revision: str
|
|
restore: str
|
|
|
|
|
|
@router.post("/{version_id}/revisions/restore")
|
|
async def restore(version_id: int, body: RestoreIn, user: Editor, db: DB):
|
|
await get_version(db, user, version_id)
|
|
v = await lock_version(db, version_id)
|
|
editable(v, user)
|
|
expected(v, body.revision)
|
|
graph = await revision_graph(db, v.game_id)
|
|
if body.restore not in ancestors(graph, v.revision_id):
|
|
raise HTTPException(404, "Ревизия не найдена")
|
|
update_manifest(v, copy.deepcopy(graph[body.restore].manifest))
|
|
await db.commit()
|
|
return {"revision": v.revision_id}
|
|
|
|
|
|
class MergeIn(BaseModel):
|
|
target_id: int
|
|
source_revision: str
|
|
target_revision: str
|
|
mode: Literal["merge", "rebase"] = "merge"
|
|
choices: dict[str, Literal["left", "right"]] = Field(default_factory=dict)
|
|
mapping: dict[str, str] = Field(default_factory=dict)
|
|
name: str = Field(default="Объединённый мод", min_length=1, max_length=255)
|
|
apply: bool = False
|
|
|
|
|
|
def merge_tree(manifest):
|
|
doc = mods.comparable(mods.prepare(mods.document(manifest)))
|
|
for info in doc["game"].get("locales", {}).values():
|
|
for key in ("progress", "lines", "strings_count"):
|
|
info.pop(key, None)
|
|
extras = {}
|
|
for p, entry in manifest["files"].items():
|
|
if p == "game.json" or p in doc["scripts"]:
|
|
continue
|
|
extras[p] = {"json": copy.deepcopy(file_json(manifest, p))} if p.startswith("locales/") and p.endswith(".json") else entry
|
|
return {"document": doc, "files": extras}
|
|
|
|
|
|
@router.post("/{version_id}/merge")
|
|
async def merge(version_id: int, body: MergeIn, user: Editor, db: DB):
|
|
source = await get_version(db, user, version_id)
|
|
target = await get_version(db, user, body.target_id)
|
|
if source.game_id != target.game_id or source.id == target.id or source.kind != "mod":
|
|
raise HTTPException(422, "Нужен мод и другая версия той же игры")
|
|
if source.status != "ready" or target.status != "ready":
|
|
raise HTTPException(409, "Версии ещё не готовы")
|
|
if body.apply:
|
|
# Stable lock ordering avoids deadlocks in simultaneous opposite merges.
|
|
locked = {key: await lock_version(db, key) for key in sorted((source.id, target.id))}
|
|
source, target = locked[source.id], locked[target.id]
|
|
expected(source, body.source_revision)
|
|
expected(target, body.target_revision)
|
|
graph = await revision_graph(db, source.game_id)
|
|
if body.mode == "rebase":
|
|
base_id = source.base_revision_id
|
|
else:
|
|
a, b = ancestors(graph, source.revision_id), ancestors(graph, target.revision_id)
|
|
common = set(a) & set(b)
|
|
base_id = min(common, key=lambda k: (max(a[k], b[k]), a[k] + b[k], k)) if common else None
|
|
if not base_id or base_id not in graph:
|
|
raise HTTPException(409, "Нет достоверной базовой ревизии. Для старого мода создайте новый форк и перенесите правки вручную")
|
|
def build():
|
|
base = merge_tree(graph[base_id].manifest)
|
|
left, right = merge_tree(source.manifest), merge_tree(target.manifest)
|
|
unmatched = []
|
|
aliases, target_tids = {}, {}
|
|
if body.mode == "rebase":
|
|
before = right["document"]
|
|
right["document"], unmatched = mods.align(base["document"], before, body.mapping)
|
|
def identities(raw, aligned):
|
|
if isinstance(raw, dict) and isinstance(aligned, dict):
|
|
if raw.get("id") and aligned.get("id"):
|
|
aliases[aligned["id"]] = raw["id"]
|
|
if raw.get("tid") and aligned.get("tid"):
|
|
target_tids[aligned["tid"]] = raw["tid"]
|
|
for key in raw.keys() & aligned.keys():
|
|
identities(raw[key], aligned[key])
|
|
elif isinstance(raw, list) and isinstance(aligned, list):
|
|
for a, b in zip(raw, aligned):
|
|
identities(a, b)
|
|
identities(before, right["document"])
|
|
from app.converter.locales import walk
|
|
tids = {}
|
|
for path, module in before["scripts"].items():
|
|
for old_label, new_label in zip(module["labels"], right["document"]["scripts"][path]["labels"]):
|
|
for old_node, new_node in zip(walk(old_label["body"]), walk(new_label["body"])):
|
|
if old_node.get("tid") and new_node.get("tid"):
|
|
tids[old_node["tid"]] = new_node["tid"]
|
|
for entry in right["files"].values():
|
|
if set(entry) == {"json"} and isinstance(entry["json"], dict):
|
|
entry["json"] = {tids.get(k, k): v for k, v in entry["json"].items()}
|
|
conflicts = []
|
|
result = mods.merge_value(base, left, right, "", conflicts, body.choices)
|
|
if body.mode == "rebase" and body.apply and not conflicts:
|
|
# Compare in old-base identity, then adopt the new original's canonical
|
|
# IDs. This makes the frozen target a valid base for the NEXT rebase.
|
|
def canonical(value):
|
|
if isinstance(value, dict):
|
|
for key, v in value.items():
|
|
if key == "id" and isinstance(v, str):
|
|
value[key] = aliases.get(v, v)
|
|
elif key == "tid" and isinstance(v, str):
|
|
value[key] = target_tids.get(v, v)
|
|
else:
|
|
canonical(v)
|
|
elif isinstance(value, list):
|
|
for v in value:
|
|
canonical(v)
|
|
canonical(result["document"])
|
|
for entry in result["files"].values():
|
|
if set(entry) == {"json"} and isinstance(entry["json"], dict):
|
|
entry["json"] = {target_tids.get(k, k): v for k, v in entry["json"].items()}
|
|
return result, conflicts, unmatched
|
|
try:
|
|
result, conflicts, unmatched = await run_in_threadpool(build)
|
|
except (ValueError, TypeError, KeyError) as e:
|
|
raise HTTPException(422, str(e)) from None
|
|
if not body.apply:
|
|
return {"baseRevision": base_id, "conflicts": conflicts, "unmatched": unmatched,
|
|
"document": result["document"]}
|
|
if conflicts:
|
|
raise HTTPException(409, "Остались нерешённые конфликты")
|
|
def write():
|
|
manifest = {"format": "pgvn/1", "files": {}}
|
|
# Keep script entries until store() replaces them and validates references.
|
|
for p, f in target.manifest["files"].items():
|
|
if p == "game.json" or p in result["document"]["scripts"]:
|
|
manifest["files"][p] = f
|
|
for p, entry in result["files"].items():
|
|
if set(entry) == {"json"}:
|
|
put_json(manifest["files"], p, entry["json"])
|
|
else:
|
|
manifest["files"][p] = entry
|
|
return mods.store(result["document"], manifest)
|
|
try:
|
|
manifest = await run_in_threadpool(write)
|
|
except (ValueError, KeyError, TypeError) as e:
|
|
raise HTTPException(422, f"Результат не прошёл проверку: {e}") from None
|
|
fork = GameVersion(game_id=source.game_id, parent_id=target.id, owner_id=user.id,
|
|
name=body.name, kind="mod", visibility="draft", status="ready", manifest=manifest,
|
|
base_revision_id=target.revision_id if body.mode == "rebase" else base_id,
|
|
size_bytes=sum(f["size"] for f in manifest["files"].values()))
|
|
fork._revision_parents = [source.revision_id, target.revision_id]
|
|
db.add(fork)
|
|
await db.commit()
|
|
return {"id": fork.id, "revision": fork.revision_id}
|