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