mtapi / app /renpy /extract.py
Cnass's picture
Upload 4 files
a182ff4 verified
Raw History Blame Contribute Delete
4.9 kB
"""Index the translatable text in an uploaded Ren'Py project.
Accepts either shape people actually have: a zip of nothing but scripts, or a
whole `game/` folder. Both are walked the same way and everything that is not
a script is ignored, so uploading the folder costs no more time than uploading
the scripts alone.
Two directories are always skipped:
tl/ - somebody else's finished translation. Re-translating a translation
is the classic way to end up with a game in two languages at once.
renpy/ - the engine's own source, shipped inside some distributions. Its
strings belong to Ren'Py, not to the game.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from . import rpyc, script
SCRIPT_SUFFIXES = (".rpy", ".rpyc")
# Directory names that never hold the game's own translatable script.
_SKIP_DIRS = {"tl", "renpy", "cache", "saves", "lib", "__pycache__"}
@dataclass
class Slot:
"""One place a translation gets written back.
`start`/`end` bracket the characters between the quotes in the source
file. A slot from a compiled .rpyc has no writable span - the bytes cannot
be edited safely - so it is marked unwritable and reaches the game through
a generated translation file instead.
"""
file: str
start: int = -1
end: int = -1
quote: str = '"'
writable: bool = True
kind: str = "dialogue"
@dataclass
class Unit:
"""One source string and every place it occurs."""
source: str
slots: list[Slot] = field(default_factory=list)
@property
def occurrences(self) -> int:
return len(self.slots)
def is_script(path: Path) -> bool:
return path.suffix.lower() in SCRIPT_SUFFIXES
def is_skipped(relative: Path) -> bool:
"""True when a path sits inside a directory we never read."""
return any(part.lower() in _SKIP_DIRS for part in relative.parts[:-1])
def iter_scripts(root: Path):
"""Yield every script under `root`, in a stable order.
A `.rpyc` is only read when it has no `.rpy` sibling. Where both exist,
Ren'Py itself always treats the `.rpy` as the source of truth and the
`.rpyc` as a disposable compiled cache, and this tool follows the same
rule - reading both would translate the same dialogue twice, and shipping
a `.rpy` we edited next to a `.rpyc` we cannot touch leaves a stale
compiled tree on disk that no longer matches the source next to it.
"""
all_paths = {p.relative_to(root) for p in root.rglob("*") if p.is_file()}
for path in sorted(root.rglob("*")):
if not path.is_file() or not is_script(path):
continue
relative = path.relative_to(root)
if is_skipped(relative):
continue
if path.suffix.lower() == ".rpyc" and relative.with_suffix(".rpy") in all_paths:
continue
yield path, relative
def has_scripts(root: Path) -> bool:
for _ in iter_scripts(root):
return True
return False
def extract(root: Path) -> tuple[list[Unit], list[str]]:
"""Read every script under `root`. Returns (units, unreadable_files)."""
by_source: dict[str, Unit] = {}
broken: list[str] = []
def sink(source: str, slot: Slot) -> None:
unit = by_source.get(source)
if unit is None:
unit = Unit(source=source)
by_source[source] = unit
unit.slots.append(slot)
for path, relative in iter_scripts(root):
name = relative.as_posix()
if path.suffix.lower() == ".rpy":
try:
source = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
try:
source = path.read_text(encoding="utf-8", errors="replace")
except OSError:
broken.append(name)
continue
try:
for item in script.find_translatable(source):
sink(
item.text,
Slot(
file=name,
start=item.start,
end=item.end,
quote=item.quote,
writable=True,
kind=item.kind,
),
)
except Exception: # noqa: BLE001 - one odd script must not stop the scan
broken.append(name)
continue
# Compiled script: readable, not writable.
try:
strings = rpyc.extract_strings(path)
except Exception: # noqa: BLE001 - unknown or newer .rpyc layout
broken.append(name)
continue
for text, kind in strings:
sink(text, Slot(file=name, writable=False, kind=kind))
return list(by_source.values()), broken