| """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 |
|
|
| |
| SKIP_FIELDS = {"id", "audio", "next", "options_next", "time", "type"} |
|
|
| |
| 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 |
| source: str |
| kind: str |
|
|
|
|
| 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): |
| 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: |
| 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) |
|
|