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)
|