File size: 4,903 Bytes
71edbba a182ff4 71edbba a182ff4 71edbba | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | """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
|