File size: 5,122 Bytes
478fb0c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
147
148
"""Translatability rules: which strings in a language-pack JSON file get translated.

The public API is `iter_translatable(data, rel_path)`, which yields `Item`s with a
key path addressing the string inside the file, the source string, and a `kind`:

  dialogue - a line spoken by the file's character (eligible for the tone pass)
  player   - a line spoken by the phone owner / player (plain translation)
  other    - UI copy, web text, notes, etc. (plain translation)

`get_at` / `set_at` address values by the same key paths and are used by the
exporter to substitute translations without touching structure.
"""

import copy
import re
from collections.abc import Iterator
from typing import Any, NamedTuple

# Field names that hold structural data, never prose.
SKIP_FIELDS = {"id", "audio", "next", "options_next", "time", "type"}

# Speaker codes in Filler Chats ("Schema B").
TYPE_PLAYER = "1"
TYPE_SYSTEM = "-1"

_LETTER_RE = re.compile(r"[A-Za-z]")
_URL_OR_FILE_RE = re.compile(r"^[\w.-]+\.[a-z][a-z0-9]{1,5}(/\S*)?$", re.IGNORECASE)


class Item(NamedTuple):
    path: tuple  # key path into the file's JSON structure
    source: str
    kind: str  # "dialogue" | "player" | "other"


def is_translatable_value(value: Any) -> bool:
    """Value-level filter applied to every candidate string."""
    if not isinstance(value, str):
        return False
    text = value.strip()
    if not text or text == "-":
        return False
    if text.startswith("system::"):
        return False
    if text.startswith(("http://", "https://")) or _URL_OR_FILE_RE.match(text):
        return False
    if not _LETTER_RE.search(text):  # numbers, emoji, punctuation only
        return False
    return True


def detect_schema(data: Any, rel_path: str) -> str:
    """Classify a file as 'conversation', 'chat_log', or 'generic'."""
    parts = rel_path.replace("\\", "/").split("/")
    if "Conversations" in parts and isinstance(data, dict):
        if any(isinstance(v, dict) and "message" in v for v in data.values()):
            return "conversation"
    if "Filler Chats" in parts:
        rows = data if isinstance(data, list) else list(data.values()) if isinstance(data, dict) else []
        if any(isinstance(r, dict) and "text" in r for r in rows):
            return "chat_log"
    return "generic"


def iter_translatable(data: Any, rel_path: str) -> Iterator[Item]:
    schema = detect_schema(data, rel_path)
    if schema == "conversation":
        yield from _iter_conversation(data)
    elif schema == "chat_log":
        yield from _iter_chat_log(data)
    else:
        yield from _iter_generic(data, ())


def _iter_conversation(data: dict) -> Iterator[Item]:
    for msg_id, node in data.items():
        if not isinstance(node, dict):
            continue
        message = node.get("message")
        if is_translatable_value(message):
            yield Item((msg_id, "message"), message, "dialogue")
        options = node.get("options")
        if isinstance(options, str):
            if is_translatable_value(options):
                yield Item((msg_id, "options"), options, "player")
        elif isinstance(options, list):
            for i, opt in enumerate(options):
                if is_translatable_value(opt):
                    yield Item((msg_id, "options", i), opt, "player")


def _iter_chat_log(data: Any) -> Iterator[Item]:
    rows = enumerate(data) if isinstance(data, list) else data.items()
    for key, row in rows:
        if not isinstance(row, dict):
            continue
        text = row.get("text")
        if not is_translatable_value(text):
            continue
        row_type = str(row.get("type", ""))
        if row_type == TYPE_PLAYER:
            kind = "player"
        elif row_type == TYPE_SYSTEM:
            kind = "other"
        else:  # "0" and any other code = a non-player chat participant
            kind = "dialogue"
        yield Item((key, "text"), text, kind)


def _iter_generic(node: Any, path: tuple) -> Iterator[Item]:
    if isinstance(node, dict):
        for key, value in node.items():
            if key in SKIP_FIELDS:
                continue
            yield from _iter_generic(value, path + (key,))
    elif isinstance(node, list):
        for i, value in enumerate(node):
            yield from _iter_generic(value, path + (i,))
    elif is_translatable_value(node):
        yield Item(path, node, "other")


def get_at(data: Any, path: tuple) -> Any:
    node = data
    for key in path:
        node = node[key]
    return node


def set_at(data: Any, path: tuple, value: Any) -> Any:
    """Return a deep copy of `data` with the value at `path` replaced."""
    return set_many(data, [(path, value)])


def set_many(data: Any, updates: list[tuple[tuple, Any]]) -> Any:
    """Return a deep copy of `data` with every (path, value) update applied."""
    result = copy.deepcopy(data)
    for path, value in updates:
        node = result
        for key in path[:-1]:
            node = node[key]
        node[path[-1]] = value
    return result


def key_to_str(path: tuple) -> str:
    return ".".join(str(p) for p in path)