feat: _() strings and screen texts become translatable strings; search looks in all states; stutter hint
CI / frontend (push) Failing after 14s
CI / backend (push) Failing after 44s

show text _("...") and literal screen texts are listed in Names and menu; typing in search switches the filter from To do to All; the translator is told how stutters are spelled.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 21:09:55 +03:00
1 parent 262db2687e
commit cc71050362
5 files changed
+73 -1

No files matched your search

+37
View File
@@ -62,6 +62,39 @@ class TextIndex:
modules: list[tuple[str, int]] = field(default_factory=list)
_WORD = re.compile(r"[^\W\d_]")
_SCREEN_TEXT = {"text", "textbutton", "label"}
def marked_strings(value: Any) -> list[str]:
"""Texts that Ren'Py translates by their source: ``_("...")`` and the literal text of screen widgets.
``show text _("...")``, ``$ x = _("...")`` and screen ``text "..."`` carry no dialogue id, so they
are keyed by the text itself, like menu items. Strings without a letter (numbers, markup) are skipped.
"""
out: list[str] = []
stack = [value]
while stack:
item = stack.pop()
if isinstance(item, list):
stack.extend(reversed(item))
continue
if not isinstance(item, dict):
continue
func = item.get("f")
if (item.get("k") == "call" and isinstance(func, dict) and func.get("k") == "name"
and func.get("id") in ("_", "__") and len(item.get("a") or []) == 1):
arg = item["a"][0]
if isinstance(arg, dict) and arg.get("k") == "const" and isinstance(arg.get("v"), str):
out.append(arg["v"])
if item.get("w") in _SCREEN_TEXT and item.get("args"):
arg = item["args"][0]
if isinstance(arg, dict) and arg.get("k") == "const" and isinstance(arg.get("v"), str):
out.append(arg["v"])
stack.extend(reversed(list(item.values())))
return [text for text in out if text.strip() and _WORD.search(text)]
def text_index(manifest: dict[str, Any]) -> TextIndex:
game_hash = manifest["files"]["game.json"]["hash"]
game = blob_json(game_hash)
@@ -109,7 +142,11 @@ def _build_index(game_hash: str, scripts: tuple[tuple[str, str], ...]) -> TextIn
idx.lines.append(info)
idx.by_tid[info.tid] = info
count += 1
for text in marked_strings(module["labels"]):
idx.strings.setdefault(text, "text")
idx.modules.append((path, count))
for text in marked_strings(game.get("screens") or {}):
idx.strings.setdefault(text, "text")
return idx
+23
View File
@@ -96,3 +96,26 @@ def test_entries_page_locates_a_line_in_the_filtered_list(manifest):
assert tr.entries_page(m, "ru", state="todo", locate=first.tid)["located"] == 0
assert tr.entries_page(m, "ru", locate="")["located"] is None
assert tr.entries_page(m, "ru", kind="strings", locate="Эйлин")["located"] is not None
def _mark(text):
return {"k": "call", "f": {"k": "name", "id": "_"}, "a": [{"k": "const", "v": text}]}
def test_marked_strings_and_screen_texts_become_translatable_strings():
body = [
{"op": "show", "img": {"textExpr": _mark("Do you want a short tutorial?")}},
{"op": "python", "code": [{"k": "assign", "value": _mark("Hi, [name]!")}]},
{"op": "show", "img": {"textExpr": _mark("")}}, # empty: nothing to translate
{"op": "show", "img": {"textExpr": _mark("42")}}, # no letters: not text
{"op": "show", "img": {"textExpr": {"k": "call", "f": {"k": "name", "id": "other"},
"a": [{"k": "const", "v": "Not marked"}]}}},
]
screens = {"menu": {"body": [
{"w": "text", "args": [{"k": "const", "v": "Preferences"}], "props": {}, "body": []},
{"w": "textbutton", "args": [{"k": "const", "v": "Quit"}], "props": {}, "body": []},
{"w": "text", "args": [{"k": "name", "id": "title"}], "props": {}, "body": []}, # variable
{"w": "add", "args": [{"k": "const", "v": "bg.png"}], "props": {}, "body": []}, # not text
]}}
assert tr.marked_strings([{"name": "start", "body": body}]) == ["Do you want a short tutorial?", "Hi, [name]!"]
assert tr.marked_strings(screens) == ["Preferences", "Quit"]
+5
View File
@@ -239,6 +239,11 @@ translate russian strings:
переводит одну строку с тем же контекстом и глоссарием и помечает её
«Машинный». Редактор ищет переводчик сам, только если он уже работал в этом
браузере, чтобы Chrome не спрашивал про локальную сеть без причины;
* вкладка «Имена и меню» собирает не только имена, пункты меню и `show text "…"`,
но и строки `_("…")` из скриптов и постоянный текст экранов (`text`, `textbutton`,
`label`): у них нет идентификатора реплики, поэтому они переводятся по тексту
оригинала, как в Ren'Py. Список строится из уже конвертированной версии, повторный
импорт игры не нужен;
* декоративные теги Ren'Py (`{size=…}`, `{i}`, `{b}`, `{color=…}` и т. п.) внутри
слов (`d{size=38}e{/size}ep`) после перевода на место не вернуть, поэтому такие
строки уходят модели без этих тегов, а результат помечается «Неточно». Строки с
+2
View File
@@ -309,6 +309,8 @@ function EntriesView({ versionId, lang, tab, ov, onStats, session, fills, fillTi
const t = setTimeout(() => {
setQ(query);
setPage(0);
// looking for a phrase means looking everywhere, not only among what still needs work
if (query.trim()) setState((current) => (current === "todo" ? "all" : current));
}, 300);
return () => clearTimeout(t);
}, [query]);
+6 -1
View File
@@ -44,6 +44,11 @@ function loadPrefs(): Prefs {
}
}
/** Visual novels spell stutters with hyphens and repeated letters; models misread the broken word. */
const STUTTER_HINT = "The source may contain stuttering written with hyphens and repeated letters, "
+ "like \"c-cer-cervix\" or \"m-my\". First work out the intended word, translate that word correctly "
+ "and keep the stutter in the translation (for example \"ш-шейка матки\"). Never translate the broken spelling literally.";
export type Connection = Link | "idle" | "checking";
export interface Session {
@@ -151,7 +156,7 @@ export function useTranslatorSession(versionId: string | undefined, version: Ver
context: [`Game: ${title}.`, prefs.about && g?.description,
prefs.about && g?.tags?.length ? `Genres and tags: ${g.tags.join(", ")}.` : ""]
.filter(Boolean).join(" ").slice(0, 4000),
instructions: instructions.trim(),
instructions: [instructions.trim(), STUTTER_HINT].filter(Boolean).join(" "),
glossary: Object.fromEntries((meta.glossary ?? []).filter((t) => t.src.trim() && t.t.trim())
.map((t) => [t.src.trim(), t.t.trim()])),
provider: prefs.provider || "lmstudio",