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