TL By Entry not Category for Wolf Dawn
This commit is contained in:
parent
50c7e56778
commit
f85ea1af35
8 changed files with 511 additions and 485 deletions
|
|
@ -8,32 +8,26 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
|
|||
Step 1 Pre-process - optional dazedformat + gameupdate/ copy before translating
|
||||
Step 2 Glossary - build vocab.txt (characters / worldbuilding terms) before
|
||||
translating so the AI keeps names and voice consistent
|
||||
Step 3 Names - curate names.json (item/skill/enemy value names). Blindly
|
||||
translating all of them breaks games that reference names by
|
||||
value, so a repo-aware AI classifies which categories are safe
|
||||
to translate and leaves the rest as source. Pick Normal/Batch
|
||||
translation mode here and run Phase 0 (names -> vocab.txt).
|
||||
Step 4 Translate - run the "Wolf RPG (WolfDawn)" module over files/ in three
|
||||
ordered phases (RPGMaker's DB-first strategy; mode set in Step 3):
|
||||
Phase 0 Names - also runnable from Step 3; harvests safe
|
||||
name translations into vocab.txt
|
||||
Step 3 Names - translate names.json (Phase 0). WolfDawn safe/refs entries
|
||||
are translated per-name; verify names are skipped. Harvests
|
||||
short name terms into vocab.txt.
|
||||
Step 4 Translate - WolfDawn module over files/ in two phases (after Step 3):
|
||||
Phase 1 Database - item/skill/state descriptions and system
|
||||
messages (DataBase/CDataBase.project)
|
||||
Phase 2 Maps / events - .mps maps, CommonEvent, Game.dat,
|
||||
Evtext; speaker handling and text-wrap
|
||||
settings live under this phase
|
||||
Step 5 Inject - inject translations + curated names back into the Data/ binaries
|
||||
Step 5 Inject - inject translations + translated names back into the Data/ binaries
|
||||
and refresh the git-tracked wolf_json/ with the translated
|
||||
JSON; quick-inject picks just a few translated files for
|
||||
fast in-game / git iteration, or inject everything at once
|
||||
Step 6 Package - run from a loose Data/ folder, or repack Data.wolf
|
||||
Step 7 Saves - fix baked strings in existing .sav files (optional)
|
||||
|
||||
names.json is staged into files/ but is NOT translated in the bulk phases - many
|
||||
WOLF "value names" double as logic keys (referenced by value in event code), so
|
||||
translating them all corrupts the game. Step 3 marks the safe subset (by category),
|
||||
Phase 0 translates only those and harvests them into vocab.txt, and Step 5 injects
|
||||
the result.
|
||||
names.json is staged into files/ but is NOT translated in the bulk phases - WolfDawn
|
||||
tags each name safe / refs / verify and Phase 0 translates only safe+refs entries
|
||||
(per-name, not per-category), harvests them into vocab.txt, and Step 5 injects the
|
||||
result.
|
||||
|
||||
The extracted JSON is staged in ``<game_root>/wolf_json/`` (a manifest maps each
|
||||
file back to its base binary), then imported into ``files/`` for the shared
|
||||
|
|
@ -110,23 +104,6 @@ PHASE_NAMES_KINDS = {"names"}
|
|||
PHASE_DB_KINDS = {"db"}
|
||||
PHASE_MAPS_EVENTS_KINDS = {"map", "common", "gamedat", "txt", "txt-dir"}
|
||||
|
||||
# WolfDawn's inject commands print e.g. "applied 91 translation(s) (0 untranslated,
|
||||
# 0 drifted); wrote <path>". We parse the counts to distinguish a real inject from a
|
||||
# silent no-op (exit 0 but 0 applied because the base was already translated).
|
||||
_INJECT_COUNTS_RE = re.compile(
|
||||
r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def _parse_inject_counts(stdout: str):
|
||||
"""Return (applied, drifted) ints from wolf inject output, or (None, None)."""
|
||||
if not stdout:
|
||||
return None, None
|
||||
m = _INJECT_COUNTS_RE.search(stdout)
|
||||
if not m:
|
||||
return None, None
|
||||
return int(m.group(1)), int(m.group(2))
|
||||
|
||||
# Glossary-discovery prompt for Copilot / Cursor, tailored to WolfDawn's extracted
|
||||
# JSON (source/text pairs staged in files/). Produces the same two-section format
|
||||
# the shared vocab.txt expects, so the AI output pastes straight into the editor.
|
||||
|
|
@ -151,7 +128,7 @@ _WOLF_GLOSSARY_PROMPT = (
|
|||
" - Game.dat.json — game/system strings (title, terms).\n"
|
||||
" - <Map>.mps.json — per-map events: the main story dialogue (can be large).\n"
|
||||
" - Evtext.json — external event text, when present.\n"
|
||||
" - names.json — item/skill/enemy value names (curated separately in Step 3; do NOT list them).\n"
|
||||
" - names.json — item/skill/enemy value names (translated separately in Step 3; do NOT list them).\n"
|
||||
"</data_format>\n"
|
||||
"\n"
|
||||
"<file_strategy>\n"
|
||||
|
|
@ -182,8 +159,8 @@ _WOLF_GLOSSARY_PROMPT = (
|
|||
"# Worldbuilding Terms — rules:\n"
|
||||
"- Include: faction/organisation names, locations mentioned in dialogue but not on maps, "
|
||||
"unique magic systems, lore titles, recurring in-universe concepts.\n"
|
||||
"- Exclude: skill names, item names, weapon/armour names (curated separately via "
|
||||
"names.json in Step 3). Skip generic RPG words. Do not repeat character names here.\n"
|
||||
"- Exclude: skill names, item names, weapon/armour names (translated via names.json in "
|
||||
"Step 3). Skip generic RPG words. Do not repeat character names here.\n"
|
||||
"</rules>\n"
|
||||
"\n"
|
||||
"<output_format>\n"
|
||||
|
|
@ -214,62 +191,6 @@ _WOLF_GLOSSARY_PROMPT = (
|
|||
)
|
||||
|
||||
|
||||
# Names-safety prompt for a repo-aware AI (Cursor / Copilot with the extracted
|
||||
# files/ JSON open). WolfDawn groups every value name in names.json under a WOLF
|
||||
# database category (the 'note' field). Some categories are pure on-screen text,
|
||||
# others are logic keys (variable / file / event names) that break the game if
|
||||
# translated. The AI classifies by CATEGORY and returns the list of safe note
|
||||
# values as a JSON array; the tool then translates only those categories.
|
||||
_WOLF_NAMES_PROMPT = (
|
||||
"You are an expert Japanese WOLF RPG Editor translator and reverse-engineer. Accuracy "
|
||||
"matters more than coverage: translating the wrong names breaks the game, so be conservative.\n"
|
||||
"\n"
|
||||
"<background>\n"
|
||||
"names.json (a 'kind':'names' object with a 'names' list) is a project-wide list of every "
|
||||
"\"value name\" WolfDawn found referenced across the WOLF game. Each entry has a Japanese "
|
||||
"'source', a 'text', an 'occurrences' count, and a 'note' - the WOLF database CATEGORY the "
|
||||
"name came from (e.g. 武器 = weapons, 技能 = skills, システム変数名 = system variable names, "
|
||||
"SEリスト = sound-effect list, マップ設定 = map settings).\n"
|
||||
"Translating everything WILL break the game: many categories are logic keys - the engine "
|
||||
"compares them by value, looks entries up by exact name, builds file/CG/BGM/SE paths from "
|
||||
"them, or uses them as variable / switch / event / map names. Only categories that are "
|
||||
"purely on-screen display text are safe to translate.\n"
|
||||
"</background>\n"
|
||||
"\n"
|
||||
"--- attach the extracted JSON in files/ here (especially names.json) before continuing ---\n"
|
||||
"\n"
|
||||
"<task>\n"
|
||||
"Group the names.json entries by their 'note' value and classify each DISTINCT note "
|
||||
"category as SAFE or UNSAFE. Return the list of SAFE categories only. Do NOT translate "
|
||||
"anything yourself and do NOT edit names.json - the tool does the translation, filtered to "
|
||||
"the categories you approve.\n"
|
||||
"</task>\n"
|
||||
"\n"
|
||||
"<how_to_decide>\n"
|
||||
"For each note category, look at its example 'source' values and how they are used in the "
|
||||
"other files/ JSON (grep the strings across maps / CommonEvent / databases):\n"
|
||||
"- SAFE -> the category is text shown to the player and nothing depends on its exact value:\n"
|
||||
" item / weapon / armour / skill names, enemy names, state names, class/job names,\n"
|
||||
" in-game terms (用語), battle commands, attribute names, actor/party display names.\n"
|
||||
"- UNSAFE -> the category is (or might be) used as an identifier / key:\n"
|
||||
" variable / switch / string-variable names, SE / BGM / BGS lists, picture numbers,\n"
|
||||
" character / face / parallax / window image names, map settings, event names,\n"
|
||||
" anything looked up or compared by value, or built into a filename or path.\n"
|
||||
"When a category is ambiguous, or its values look like identifiers (ASCII, digits, "
|
||||
"underscores, paths, bracket tags like [SE]), classify it UNSAFE. Prefer leaving a category "
|
||||
"out over risking a break.\n"
|
||||
"</how_to_decide>\n"
|
||||
"\n"
|
||||
"<output_format>\n"
|
||||
"Return ONLY a JSON array of the SAFE note strings, copied EXACTLY as they appear in the "
|
||||
"'note' fields (same characters, including any brackets or symbols), inside ONE fenced code "
|
||||
"block and nothing else. Example:\n"
|
||||
"```json\n"
|
||||
"[\"武器\", \"防具\", \"技能\", \"アイテム\", \"用語設定\"]\n"
|
||||
"```\n"
|
||||
"</output_format>\n"
|
||||
)
|
||||
|
||||
# Speaker-format prompt for a repo-aware AI. WolfDawn already detects and tags who
|
||||
# speaks on each line, so the only decision left is whether its LOW-confidence
|
||||
# first-line guesses are really speaker names for this game. The AI inspects the
|
||||
|
|
@ -1539,8 +1460,8 @@ class WolfWorkflowTab(QWidget):
|
|||
layout.addLayout(vrow)
|
||||
|
||||
note = self._desc(
|
||||
"You don't need to add item / skill / enemy value names here: they're curated in "
|
||||
"Step 3 and the safe ones are harvested into vocab.txt automatically during Phase 0. "
|
||||
"You don't need to add item / skill / enemy value names here: Phase 0 translates "
|
||||
"WolfDawn safe/refs entries from names.json and harvests them into vocab.txt "
|
||||
"Focus this glossary on characters and worldbuilding terms."
|
||||
)
|
||||
layout.addWidget(note)
|
||||
|
|
@ -1572,33 +1493,19 @@ class WolfWorkflowTab(QWidget):
|
|||
layout.addWidget(self._desc(
|
||||
"Sends the extracted files in files/ to the Translation tab using the Wolf RPG "
|
||||
"(WolfDawn) module. Only the 'text' fields are filled in; 'source' is preserved so "
|
||||
"injection can verify each line. Run the phases in order: Phase 0 translates the safe "
|
||||
"names and writes them into vocab.txt, so Phases 1 and 2 translate descriptions and "
|
||||
"in-engine text with consistent item / skill / character names (RPGMaker's DB-first strategy)."
|
||||
"injection can verify each line. Run Step 3 (names) first so vocab.txt is seeded, "
|
||||
"then Phase 1 (database text) and Phase 2 (maps / events) in order."
|
||||
))
|
||||
|
||||
self._add_phase_buttons(layout)
|
||||
|
||||
def _add_phase_buttons(self, layout: QVBoxLayout):
|
||||
"""Three ordered translation phases selected by manifest kind."""
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Phase 0 · Names → vocab.txt"))
|
||||
layout.addWidget(self._desc(
|
||||
"Translates only the safe name categories you approved in Step 3 and harvests them into "
|
||||
"vocab.txt (grouped by category, e.g. \"Weapon · 武器\"). Curate safe categories and pick "
|
||||
"the translation mode in Step 3 first, or nothing is translated. Also runnable from Step 3."
|
||||
))
|
||||
p0 = self._register(_make_btn("▶ Phase 0 · Translate names", "#00a86b"))
|
||||
p0.clicked.connect(
|
||||
lambda: self._navigate_to_translation(kinds=PHASE_NAMES_KINDS, auto_start=True)
|
||||
)
|
||||
layout.addWidget(p0)
|
||||
|
||||
"""Two translation phases (names are handled in Step 3)."""
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Phase 1 · Database text"))
|
||||
layout.addWidget(self._desc(
|
||||
"Item / skill / state descriptions and system messages from DataBase.project and "
|
||||
"CDataBase.project. Run after Phase 0 so names stay consistent."
|
||||
"CDataBase.project. Run after Step 3 (names) so vocab.txt stays consistent."
|
||||
))
|
||||
p1 = self._register(_make_btn("▶ Phase 1 · Translate database text", "#00a86b"))
|
||||
p1.clicked.connect(
|
||||
|
|
@ -1610,9 +1517,9 @@ class WolfWorkflowTab(QWidget):
|
|||
layout.addWidget(self._subheading("Phase 2 · Maps / events"))
|
||||
layout.addWidget(self._desc(
|
||||
"Map scripts (.mps), common events (CommonEvent.dat), Game.dat, and Evtext - story "
|
||||
"dialogue, UI/objective strings, and other event text. Run last so it benefits from "
|
||||
"the names and terms translated in the earlier phases. Speaker handling and text wrap "
|
||||
"below apply to the dialogue-like lines in this phase."
|
||||
"dialogue, UI/objective strings, and other event text. Run after Phase 1 so it "
|
||||
"benefits from the names and terms already in vocab.txt. Speaker handling and text "
|
||||
"wrap below apply to the dialogue-like lines in this phase."
|
||||
))
|
||||
self._add_speaker_options(layout)
|
||||
self._add_wrap_options(layout)
|
||||
|
|
@ -1877,84 +1784,31 @@ class WolfWorkflowTab(QWidget):
|
|||
# ── Step 2: Names ──────────────────────────────────────────────────────────
|
||||
|
||||
def _build_step3_names(self, layout: QVBoxLayout):
|
||||
layout.addWidget(_make_section_label("Step 3 · Curate Name Values (names.json)"))
|
||||
layout.addWidget(_make_section_label("Step 3 · Translate Name Values (names.json)"))
|
||||
layout.addWidget(self._desc(
|
||||
"names.json lists every item / skill / enemy / variable value name WolfDawn found. "
|
||||
"Translating all of them WILL break the game: many double as logic keys (compared by "
|
||||
"value, used as variable / file / event names). WolfDawn tags each name with its WOLF "
|
||||
"database category (a 'note'), so instead of judging thousands of names you choose "
|
||||
"which categories are safe on-screen text. The translator only touches those; by "
|
||||
"default none are selected, so nothing changes until you opt categories in."
|
||||
"names.json is WolfDawn's project-wide name glossary. Each entry has a safety badge: "
|
||||
"safe (display-only), refs (referenced by name but rewritten on inject), or verify "
|
||||
"(also in indirect literals - skipped). Phase 0 translates every safe and refs entry "
|
||||
"individually, regardless of category, and harvests them into vocab.txt."
|
||||
))
|
||||
|
||||
layout.addWidget(self._subheading("1 · Ask a repo-aware AI which categories are safe"))
|
||||
layout.addWidget(self._desc(
|
||||
"Copy the prompt into Cursor or Copilot Chat with the extracted files/ JSON open. It "
|
||||
"groups names by category, checks how each is used, and returns a JSON list of the "
|
||||
"safe categories in a code block. Conservative by design - ambiguous categories are "
|
||||
"left out."
|
||||
))
|
||||
prompt_btn = _make_btn("📋 Copy names-safety prompt for Copilot / Cursor", "#5a3a7a")
|
||||
prompt_btn.clicked.connect(self._copy_wolf_names_prompt)
|
||||
layout.addWidget(prompt_btn)
|
||||
self.names_summary_label = QLabel("Open this step after extraction to see name counts.")
|
||||
self.names_summary_label.setWordWrap(True)
|
||||
self.names_summary_label.setStyleSheet(
|
||||
"color:#9cdcfe;font-size:13px;padding:8px 10px;"
|
||||
"background-color:#1a2430;border:1px solid #2a4a6a;border-radius:4px;"
|
||||
)
|
||||
layout.addWidget(self.names_summary_label)
|
||||
|
||||
refresh_btn = _make_btn("↻ Refresh name counts", "#555")
|
||||
refresh_btn.clicked.connect(self._refresh_names_summary)
|
||||
layout.addWidget(refresh_btn)
|
||||
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("2 · Choose safe categories"))
|
||||
layout.addWidget(self._subheading("Translate names (Phase 0)"))
|
||||
layout.addWidget(self._desc(
|
||||
"Paste the AI's JSON list below and click Apply to tick the matching categories, or "
|
||||
"tick them by hand. Each row shows the category, how many names it has, and an "
|
||||
"example. Save writes your choice to data/wolf_safe_notes.json."
|
||||
))
|
||||
|
||||
self.names_paste = QTextEdit()
|
||||
self.names_paste.setMaximumHeight(70)
|
||||
self.names_paste.setFont(QFont("Consolas", 9))
|
||||
self.names_paste.setPlaceholderText('Paste the AI list here, e.g. ["武器", "技能", "アイテム"]')
|
||||
self.names_paste.setStyleSheet(
|
||||
"QTextEdit{background-color:#252526;color:#d4d4d4;border:1px solid #3c3c3c;"
|
||||
"border-radius:4px;padding:6px;selection-background-color:#264f78;}"
|
||||
)
|
||||
layout.addWidget(self.names_paste)
|
||||
|
||||
prow = QHBoxLayout()
|
||||
apply_btn = _make_btn("Apply pasted list", "#007acc")
|
||||
apply_btn.clicked.connect(self._apply_pasted_notes)
|
||||
prow.addWidget(apply_btn)
|
||||
prow.addStretch()
|
||||
layout.addLayout(prow)
|
||||
|
||||
self.notes_list = QListWidget()
|
||||
self.notes_list.setMinimumHeight(200)
|
||||
self.notes_list.setStyleSheet(
|
||||
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
|
||||
"color:#cccccc;font-size:12px;padding:2px;}"
|
||||
"QListWidget::item{padding:3px 4px;}"
|
||||
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
|
||||
)
|
||||
layout.addWidget(self.notes_list, 1)
|
||||
|
||||
nrow = QHBoxLayout()
|
||||
save_btn = _make_btn("💾 Save safe categories", "#3a7a3a")
|
||||
save_btn.clicked.connect(self._save_safe_notes)
|
||||
nrow.addWidget(save_btn)
|
||||
reload_btn = _make_btn("↻ Refresh", "#555")
|
||||
reload_btn.clicked.connect(self._reload_notes_list)
|
||||
nrow.addWidget(reload_btn)
|
||||
none_btn = _make_btn("Select none", "#3a3a3a")
|
||||
none_btn.clicked.connect(lambda: self._set_note_checks(False))
|
||||
nrow.addWidget(none_btn)
|
||||
nrow.addStretch()
|
||||
layout.addLayout(nrow)
|
||||
self._reload_notes_list()
|
||||
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("3 · Translate the safe names (Phase 0)"))
|
||||
layout.addWidget(self._desc(
|
||||
"After saving, translate only the approved categories. The WolfDawn module skips every "
|
||||
"other name (leaving it identical to the source) and harvests the translated names into "
|
||||
"vocab.txt (grouped by category) so the later Translate phases stay consistent. This is "
|
||||
"the first translation step - pick Normal or Batch below, then run Phase 0 here or from "
|
||||
"Step 4 (the same mode applies to all phases)."
|
||||
"Pick Normal or Batch below, then run Phase 0. Only safe and refs entries are sent to "
|
||||
"the model; verify names stay identical to source so inject skips them."
|
||||
))
|
||||
self._add_tl_mode_selector(layout)
|
||||
tl_btn = self._register(_make_btn("Translate safe names now (Phase 0)", "#00a86b"))
|
||||
|
|
@ -1962,13 +1816,32 @@ class WolfWorkflowTab(QWidget):
|
|||
lambda: self._navigate_to_translation(kinds=PHASE_NAMES_KINDS, auto_start=True)
|
||||
)
|
||||
layout.addWidget(tl_btn)
|
||||
self._refresh_names_summary()
|
||||
|
||||
def _copy_wolf_names_prompt(self):
|
||||
def _load_names_document(self) -> tuple[dict | None, Path | None]:
|
||||
"""Read names.json from files/ or wolf_json/."""
|
||||
src = self._names_json_path()
|
||||
if src is None:
|
||||
return None, None
|
||||
try:
|
||||
QApplication.clipboard().setText(_WOLF_NAMES_PROMPT)
|
||||
self._log("📋 Names-safety prompt copied. Paste it into Cursor/Copilot with files/ open.")
|
||||
with open(src, "r", encoding="utf-8-sig") as f:
|
||||
return json.load(f), src
|
||||
except Exception as exc:
|
||||
self._log(f"❌ Could not copy names prompt: {exc}")
|
||||
self._log(f"❌ Could not read {NAMES_JSON}: {exc}")
|
||||
return None, src
|
||||
|
||||
def _refresh_names_summary(self):
|
||||
if not hasattr(self, "names_summary_label"):
|
||||
return
|
||||
data, src = self._load_names_document()
|
||||
if not isinstance(data, dict):
|
||||
self.names_summary_label.setText(
|
||||
"No names.json found - run Step 0 (Extract text) first."
|
||||
)
|
||||
return
|
||||
summary = wolf_names.format_name_safety_summary(data)
|
||||
where = src.parent.name if src else WORK_DIR_NAME
|
||||
self.names_summary_label.setText(f"{summary}\nSource: {where}/{NAMES_JSON}")
|
||||
|
||||
def _names_json_path(self) -> Path | None:
|
||||
"""Locate names.json for reading its note categories (files/ then wolf_json/)."""
|
||||
|
|
@ -1978,124 +1851,14 @@ class WolfWorkflowTab(QWidget):
|
|||
return p
|
||||
return None
|
||||
|
||||
def _distinct_notes(self):
|
||||
"""Return [(note, count, sample_source), ...] from names.json, or None."""
|
||||
src = self._names_json_path()
|
||||
if src is None:
|
||||
return None
|
||||
try:
|
||||
with open(src, "r", encoding="utf-8-sig") as f:
|
||||
data = json.load(f)
|
||||
except Exception as exc:
|
||||
self._log(f"❌ Could not read {NAMES_JSON}: {exc}")
|
||||
return None
|
||||
counts: dict[str, int] = {}
|
||||
samples: dict[str, str] = {}
|
||||
for entry in data.get("names", []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
note = str(entry.get("note", ""))
|
||||
counts[note] = counts.get(note, 0) + 1
|
||||
if note not in samples:
|
||||
samples[note] = str(entry.get("source", ""))
|
||||
# Most populous categories first - the safe display ones tend to be larger.
|
||||
ordered = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))
|
||||
return [(note, cnt, samples.get(note, "")) for note, cnt in ordered]
|
||||
|
||||
def _reload_notes_list(self):
|
||||
if not hasattr(self, "notes_list"):
|
||||
return
|
||||
self.notes_list.clear()
|
||||
notes = self._distinct_notes()
|
||||
if notes is None:
|
||||
item = QListWidgetItem("No names.json found — run Step 0 (Extract text) first.")
|
||||
item.setFlags(Qt.ItemIsEnabled)
|
||||
self.notes_list.addItem(item)
|
||||
return
|
||||
safe = set(wolf_names.load_safe_notes())
|
||||
for note, cnt, sample in notes:
|
||||
sample = sample.replace("\n", " ").replace("\r", " ")
|
||||
if len(sample) > 40:
|
||||
sample = sample[:40] + "…"
|
||||
label = f"{note or '(no category)'} · {cnt} name(s) · e.g. {sample}"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.UserRole, note)
|
||||
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
||||
item.setCheckState(Qt.Checked if note in safe else Qt.Unchecked)
|
||||
self.notes_list.addItem(item)
|
||||
|
||||
def _set_note_checks(self, checked: bool):
|
||||
if not hasattr(self, "notes_list"):
|
||||
return
|
||||
state = Qt.Checked if checked else Qt.Unchecked
|
||||
for i in range(self.notes_list.count()):
|
||||
it = self.notes_list.item(i)
|
||||
if it.flags() & Qt.ItemIsUserCheckable:
|
||||
it.setCheckState(state)
|
||||
|
||||
def _apply_pasted_notes(self):
|
||||
text = self.names_paste.toPlainText().strip()
|
||||
if text.startswith("```"):
|
||||
lines = text.splitlines()
|
||||
if lines and lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
if lines and lines[-1].strip().startswith("```"):
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines).strip()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
if not isinstance(parsed, list):
|
||||
raise ValueError("expected a JSON array of note strings")
|
||||
wanted = {str(n) for n in parsed}
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(
|
||||
self, "Invalid list",
|
||||
f"Paste the AI's JSON array of category names.\n{exc}",
|
||||
)
|
||||
return
|
||||
matched = 0
|
||||
unmatched = set(wanted)
|
||||
for i in range(self.notes_list.count()):
|
||||
it = self.notes_list.item(i)
|
||||
if not (it.flags() & Qt.ItemIsUserCheckable):
|
||||
continue
|
||||
note = it.data(Qt.UserRole)
|
||||
if note in wanted:
|
||||
it.setCheckState(Qt.Checked)
|
||||
matched += 1
|
||||
unmatched.discard(note)
|
||||
else:
|
||||
it.setCheckState(Qt.Unchecked)
|
||||
msg = f"Ticked {matched} categor(y/ies) from the pasted list."
|
||||
if unmatched:
|
||||
msg += f" {len(unmatched)} not found in this game: {', '.join(sorted(unmatched))}"
|
||||
self._log(msg)
|
||||
|
||||
def _save_safe_notes(self):
|
||||
if not hasattr(self, "notes_list"):
|
||||
return
|
||||
safe = []
|
||||
for i in range(self.notes_list.count()):
|
||||
it = self.notes_list.item(i)
|
||||
if (it.flags() & Qt.ItemIsUserCheckable) and it.checkState() == Qt.Checked:
|
||||
safe.append(it.data(Qt.UserRole))
|
||||
try:
|
||||
wolf_names.save_safe_notes(safe)
|
||||
self._log(
|
||||
f"✅ Saved {len(safe)} safe categor(y/ies) to data/wolf_safe_notes.json. "
|
||||
"Translate the safe names below, then inject in Step 5."
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log(f"❌ Could not save safe categories: {exc}")
|
||||
|
||||
# ── Step 4: Inject ─────────────────────────────────────────────────────────
|
||||
|
||||
def _build_step4_inject(self, layout: QVBoxLayout):
|
||||
layout.addWidget(_make_section_label("Step 5 · Inject Translations"))
|
||||
layout.addWidget(self._desc(
|
||||
"Writes the translated text back into the game's Data/ binaries with WolfDawn, "
|
||||
"byte-exact. The curated name values from Step 3 are applied across Data/ (only the "
|
||||
"entries you translated change). Lines whose inline codes changed are skipped and "
|
||||
"byte-exact. Translated safe/refs name values from names.json are applied across Data/ "
|
||||
"(only the entries you translated change). Lines whose inline codes changed are skipped and "
|
||||
"reported (unless you allow drift). Full inject and any inject that includes "
|
||||
f"{NAMES_JSON} reset live Data/ from {WORK_DIR_NAME}/originals/ first."
|
||||
))
|
||||
|
|
@ -2126,8 +1889,9 @@ class WolfWorkflowTab(QWidget):
|
|||
"For iterating: after translating a few files, tick just those here and inject them "
|
||||
"straight into the game's Data/ so you can review the git diff in the game project and "
|
||||
"test in-game. Only files that already have a translation in translated/ are listed. "
|
||||
f"{NAMES_JSON} appears here once you've curated and saved it in Step 3; tick it to "
|
||||
"apply the safe name values across Data/."
|
||||
f"{NAMES_JSON} appears here once Phase 0 has translated it (checked by default). "
|
||||
"Name injection resets live Data/ from wolf_json/originals/ first, then applies "
|
||||
"every safe/refs name across all binaries."
|
||||
))
|
||||
|
||||
self.inject_list = QListWidget()
|
||||
|
|
@ -2160,7 +1924,7 @@ class WolfWorkflowTab(QWidget):
|
|||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Full inject"))
|
||||
layout.addWidget(self._desc(
|
||||
"Injects every extracted document and applies the curated name values (Step 3) "
|
||||
"Injects every extracted document and applies translated name values from names.json "
|
||||
"across Data/. Use this once translation is complete before packaging in Step 6."
|
||||
))
|
||||
inject_btn = self._register(_make_btn("Inject all translations", "#00a86b"))
|
||||
|
|
@ -2180,9 +1944,9 @@ class WolfWorkflowTab(QWidget):
|
|||
self._current_step_index = idx
|
||||
if previous == 0 and idx != 0:
|
||||
self._auto_import_if_needed()
|
||||
# Index 3 == Names: refresh the note-category checklist from names.json.
|
||||
# Index 3 == Names: refresh the per-name safety summary.
|
||||
if idx == 3:
|
||||
self._reload_notes_list()
|
||||
self._refresh_names_summary()
|
||||
# Index 5 == Inject: keep the quick-inject picker in sync with translated/.
|
||||
elif idx == 5:
|
||||
self._refresh_inject_list()
|
||||
|
|
@ -2207,7 +1971,8 @@ class WolfWorkflowTab(QWidget):
|
|||
item = QListWidgetItem(json_name)
|
||||
item.setData(Qt.UserRole, json_name)
|
||||
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
default_checked = json_name == NAMES_JSON
|
||||
item.setCheckState(Qt.Checked if default_checked else Qt.Unchecked)
|
||||
self.inject_list.addItem(item)
|
||||
listed += 1
|
||||
if listed == 0:
|
||||
|
|
@ -2269,12 +2034,12 @@ class WolfWorkflowTab(QWidget):
|
|||
"""Inject translations into the game's Data/ binaries.
|
||||
|
||||
only_json:
|
||||
None — full inject: every document + curated name values across Data/.
|
||||
set(names)— quick inject: only those JSON files; the curated name values
|
||||
None — full inject: every document + translated name values across Data/.
|
||||
set(names)— quick inject: only those JSON files; name values from names.json
|
||||
are applied across Data/ only when NAMES_JSON is included.
|
||||
|
||||
Name values are applied only when translated/names.json exists (i.e. the
|
||||
user curated it in Step 3); otherwise names are left untouched.
|
||||
Name values are applied only when translated/names.json exists (i.e. Phase 0
|
||||
has run); otherwise names are left untouched.
|
||||
"""
|
||||
if not self._require_manifest():
|
||||
return
|
||||
|
|
@ -2328,6 +2093,8 @@ class WolfWorkflowTab(QWidget):
|
|||
log(f"Resetting live Data/ from {WORK_DIR_NAME}/originals/ …")
|
||||
self._restore_live_from_originals(entries, data_dir_path, log)
|
||||
|
||||
names_restored = only_json is None or will_names
|
||||
|
||||
if only_json is None:
|
||||
strings_targets = {
|
||||
e["json"] for e in entries if e.get("kind") != "names"
|
||||
|
|
@ -2366,10 +2133,18 @@ class WolfWorkflowTab(QWidget):
|
|||
str(src), base, out,
|
||||
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
|
||||
)
|
||||
a, d = _parse_inject_counts(res.stdout)
|
||||
if res.ok and not (a == 0 and (d or 0) > 0):
|
||||
a, d = wolfdawn.parse_strings_inject_counts(res.stdout)
|
||||
strings_ok = wolfdawn.inject_had_applied(a) or (
|
||||
res.ok and not (a == 0 and (d or 0) > 0)
|
||||
)
|
||||
if strings_ok:
|
||||
applied += 1
|
||||
_sync_json(entry["json"], src, log)
|
||||
if not res.ok and wolfdawn.inject_had_applied(a):
|
||||
log(
|
||||
f" ⚠ {entry['json']}: wolf exited {res.returncode} "
|
||||
f"after applying {a} change(s) — see warnings above"
|
||||
)
|
||||
elif res.ok:
|
||||
# Exit 0 but nothing applied and lines drifted = stale/injected base.
|
||||
failed += 1
|
||||
|
|
@ -2382,21 +2157,43 @@ class WolfWorkflowTab(QWidget):
|
|||
failed += 1
|
||||
log(f" ⚠ inject exit {res.returncode} for {entry['json']}")
|
||||
|
||||
# Curated name values: apply across the whole data dir, but only when a
|
||||
# curated translated/names.json exists (Step 3). For a quick inject, only
|
||||
# do this if the user explicitly ticked the names file, since it rewrites
|
||||
# many binaries and would add noise to the git diff otherwise.
|
||||
# Name values: apply across the whole data dir when translated/names.json
|
||||
# exists. For a quick inject, only include NAMES_JSON when you want names
|
||||
# applied (it rewrites many binaries).
|
||||
if will_names and names_entry and names_src is not None:
|
||||
log("Applying curated name values across Data/ …")
|
||||
if not names_restored:
|
||||
log(
|
||||
f"Resetting live Data/ from {WORK_DIR_NAME}/originals/ "
|
||||
"before name injection …"
|
||||
)
|
||||
self._restore_live_from_originals(entries, data_dir_path, log)
|
||||
log("Applying translated name values across Data/ …")
|
||||
res = wolfdawn.names_inject(
|
||||
str(names_src), data_dir,
|
||||
allow_code_drift=allow_drift, en_punct=en_punct, log_fn=log,
|
||||
)
|
||||
a, d = _parse_inject_counts(res.stdout)
|
||||
if res.ok and not (a == 0 and (d or 0) > 0):
|
||||
out = (res.stdout or "") + (res.stderr or "")
|
||||
a, d = wolfdawn.parse_names_inject_counts(out)
|
||||
# Keep wolf_json/names.json aligned with translated/ even when the
|
||||
# binary pass is a no-op (stale base) or exits 2 after partial guards.
|
||||
_sync_json(names_entry["json"], names_src, log)
|
||||
if wolfdawn.inject_had_applied(a):
|
||||
applied += 1
|
||||
_sync_json(names_entry["json"], names_src, log)
|
||||
elif res.ok:
|
||||
if not res.ok:
|
||||
log(
|
||||
f" ⚠ names: wolf exited {res.returncode} after applying "
|
||||
f"{a} name change(s) — see warnings above"
|
||||
)
|
||||
elif res.ok and a == 0:
|
||||
failed += 1
|
||||
log(
|
||||
f" ⚠ names: 0 applied — the live Data/ binaries no longer contain "
|
||||
"the Japanese 'source' strings in names.json (often because a prior "
|
||||
"inject already translated them without resetting from originals). "
|
||||
"Use “Inject all translations”, or tick names.json in quick inject "
|
||||
f"(which resets from {WORK_DIR_NAME}/originals/ first)."
|
||||
)
|
||||
elif res.ok and (d or 0) > 0:
|
||||
failed += 1
|
||||
log(
|
||||
f" ⚠ names: 0 applied, {d} skipped as drift — sources in "
|
||||
|
|
|
|||
|
|
@ -17,16 +17,13 @@ Only entries whose ``source`` contains target-language (Japanese by default)
|
|||
text are sent to the model; everything else keeps ``text == source`` so inject
|
||||
is a no-op for it.
|
||||
|
||||
names.json safety: WolfDawn's ``names-extract`` groups every value name under a
|
||||
``note`` category, and many categories are logic keys (variable / file / event
|
||||
names) that break the game if translated. So for ``kind == "names"`` documents,
|
||||
only entries whose ``note`` is in the opt-in safe list (``SAFE_NOTES``, from
|
||||
``data/wolf_safe_notes.json`` via :mod:`util.wolf_names`) are translated; the rest
|
||||
are left as source. The list is empty by default, so nothing is translated until
|
||||
the user opts categories in from the workflow. After translating names.json, the
|
||||
safe entries are harvested into ``vocab.txt`` (grouped by ``note``, with bilingual
|
||||
headers) so later phases (DB descriptions, dialogue) keep item / skill / term
|
||||
names consistent - the WOLF equivalent of RPGMaker's DB-first glossary seeding.
|
||||
names.json safety: WolfDawn tags every value name with a static ``safety`` badge
|
||||
(``safe``, ``refs``, or ``verify``). For ``kind == "names"`` documents, only
|
||||
entries whose badge is ``safe`` or ``refs`` are translated; ``verify`` names and
|
||||
legacy entries without a badge keep ``text == source``. After translating
|
||||
names.json, translatable entries are harvested into ``vocab.txt`` (grouped by
|
||||
``note``, with bilingual headers) so later phases keep item / skill / term names
|
||||
consistent.
|
||||
|
||||
Text wrapping: translated dialogue is re-wrapped to a character width (like the
|
||||
RPGMaker module, via :mod:`util.dazedwrap`) so English fits WOLF's message box.
|
||||
|
|
@ -90,12 +87,7 @@ LANGREGEX = r"[一-龠ぁ-ゔァ-ヴーa-zA-Z0-9\uFF61-\uFF9F]+"
|
|||
# configurable from the workflow (data/wolf_speakers.json).
|
||||
SPEAKER_CONFIG = wolf_speakers.load_config()
|
||||
|
||||
# names.json safety: WolfDawn tags every value name with a ``note`` category. Many
|
||||
# categories are logic keys (variable/file/event names) that break the game if
|
||||
# translated, so only entries whose ``note`` is in this opt-in list are sent to
|
||||
# the model; the rest keep text == source. Empty by default = translate nothing.
|
||||
# Configured from the workflow (data/wolf_safe_notes.json).
|
||||
SAFE_NOTES = wolf_names.load_safe_notes()
|
||||
# names.json: translate per-entry safe/refs badges only (see util.wolf_names).
|
||||
|
||||
# Text wrapping: rewrap translated dialogue to a character width the same way the
|
||||
# RPGMaker module does (util.dazedwrap), so English lines fit WOLF's message box.
|
||||
|
|
@ -135,12 +127,11 @@ TRANSLATION_CONFIG = TranslationConfig(
|
|||
|
||||
def handleWolfDawn(filename, estimate):
|
||||
"""Entry point used by the CLI/GUI dispatchers. Returns a summary string or 'Fail'."""
|
||||
global ESTIMATE, TOKENS, FILENAME, SAFE_NOTES, SPEAKER_CONFIG, VOCAB
|
||||
global ESTIMATE, TOKENS, FILENAME, SPEAKER_CONFIG, VOCAB
|
||||
ESTIMATE = estimate
|
||||
FILENAME = filename
|
||||
# Re-read workflow-configured settings so edits made this session take effect
|
||||
# even when translation runs in-process (the module import is cached).
|
||||
SAFE_NOTES = wolf_names.load_safe_notes()
|
||||
SPEAKER_CONFIG = wolf_speakers.load_config()
|
||||
# Reload the glossary so a later phase (DB text / dialogue) picks up names
|
||||
# that an earlier Phase 0 (names) harvested into vocab.txt.
|
||||
|
|
@ -251,13 +242,13 @@ def parseDocument(data, filename):
|
|||
src = e.get("source")
|
||||
if not (isinstance(src, str) and re.search(LANGREGEX, src)):
|
||||
return False
|
||||
# names.json: only translate categories the user marked safe (by note).
|
||||
if is_names and not wolf_names.is_note_safe(e.get("note", ""), SAFE_NOTES):
|
||||
# names.json: only translate WolfDawn safe/refs entries (per-name badge).
|
||||
if is_names and not wolf_names.is_name_translatable(e):
|
||||
return False
|
||||
return True
|
||||
|
||||
# Only translate entries that actually contain target-language text (and, for
|
||||
# names.json, sit in a safe note category); the rest keep text == source so
|
||||
# names.json, carry a translatable safety badge); the rest keep text == source so
|
||||
# WolfDawn treats them as untouched on inject.
|
||||
translatable = [e for e in entries if _translatable(e)]
|
||||
|
||||
|
|
@ -321,26 +312,33 @@ def parseDocument(data, filename):
|
|||
|
||||
|
||||
def _harvest_names_to_vocab(data):
|
||||
"""Write translated safe names.json entries into vocab.txt, grouped by note.
|
||||
"""Write short translated name labels into vocab.txt, grouped by note.
|
||||
|
||||
Each note category becomes a bilingual ``# English · 日本語`` section (English
|
||||
sourced from the staged DB labels, else a static map, else JP-only). Only
|
||||
safe notes with an actual translation are written; the base vocab section is
|
||||
preserved by :func:`util.vocab.update_vocab_section`.
|
||||
Profile blurbs and other content-shaped names are translated in names.json but
|
||||
skipped here. Categories with no harvestable terms remove any stale section.
|
||||
"""
|
||||
try:
|
||||
db_labels = wolf_names.derive_db_labels("files")
|
||||
by_note: dict[str, list] = {}
|
||||
touched_notes: set[str] = set()
|
||||
for entry in data.get("names") or []:
|
||||
note = entry.get("note", "")
|
||||
if not wolf_names.is_note_safe(note, SAFE_NOTES):
|
||||
if not wolf_names.is_name_translatable(entry):
|
||||
continue
|
||||
note = str(entry.get("note", ""))
|
||||
touched_notes.add(note)
|
||||
if not wolf_names.is_vocab_harvest_candidate(entry):
|
||||
continue
|
||||
src, dst = entry.get("source"), entry.get("text")
|
||||
if not isinstance(src, str) or not isinstance(dst, str):
|
||||
continue
|
||||
by_note.setdefault(note, []).append((src, dst))
|
||||
for note, pairs in by_note.items():
|
||||
wolf_vocab.update_vocab_section(wolf_names.note_header(note, db_labels), pairs)
|
||||
for note in touched_notes:
|
||||
header = wolf_names.note_header(note, db_labels)
|
||||
pairs = by_note.get(note, [])
|
||||
if pairs:
|
||||
wolf_vocab.update_vocab_section(header, pairs)
|
||||
else:
|
||||
wolf_vocab.remove_vocab_section(header)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
|
|
|
|||
42
tests/test_wolf_inject_counts.py
Normal file
42
tests/test_wolf_inject_counts.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Unit tests for WolfDawn inject stdout parsers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from util import wolfdawn # noqa: E402
|
||||
|
||||
|
||||
class WolfInjectCountsTests(unittest.TestCase):
|
||||
def test_parse_names_inject_counts(self):
|
||||
out = (
|
||||
"applied 2336 name change(s) (0 drifted/unmatched); "
|
||||
"wrote 78 file(s) in place\n"
|
||||
"WARNING: 2 line(s) left UNTRANSLATED by a safety guard\n"
|
||||
)
|
||||
applied, drifted = wolfdawn.parse_names_inject_counts(out)
|
||||
self.assertEqual(applied, 2336)
|
||||
self.assertEqual(drifted, 0)
|
||||
self.assertTrue(wolfdawn.inject_had_applied(applied))
|
||||
|
||||
def test_parse_strings_inject_counts(self):
|
||||
out = "applied 91 translation(s) (3 drifted); wrote Map001.mps\n"
|
||||
applied, drifted = wolfdawn.parse_strings_inject_counts(out)
|
||||
self.assertEqual(applied, 91)
|
||||
self.assertEqual(drifted, 3)
|
||||
|
||||
def test_inject_had_applied_rejects_zero(self):
|
||||
self.assertFalse(wolfdawn.inject_had_applied(0))
|
||||
self.assertFalse(wolfdawn.inject_had_applied(None))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Unit tests for the WOLF names safe-note config in util/wolf_names.py."""
|
||||
"""Unit tests for WolfDawn names.json helpers in util/wolf_names.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
|
@ -16,41 +17,62 @@ sys.path.insert(0, str(ROOT))
|
|||
from util import wolf_names as wn # noqa: E402
|
||||
|
||||
|
||||
class TestSafeNotesIO(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._orig = wn.SAFE_NOTES_PATH
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
wn.SAFE_NOTES_PATH = Path(self._tmp.name) / "wolf_safe_notes.json"
|
||||
class TestNameTranslatable(unittest.TestCase):
|
||||
def test_safe_and_refs_are_translatable(self):
|
||||
self.assertTrue(wn.is_name_translatable({"safety": "safe"}))
|
||||
self.assertTrue(wn.is_name_translatable({"safety": "refs"}))
|
||||
|
||||
def tearDown(self):
|
||||
wn.SAFE_NOTES_PATH = self._orig
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_default_is_empty(self):
|
||||
self.assertEqual(wn.load_safe_notes(), [])
|
||||
|
||||
def test_round_trip_preserves_order_and_unicode(self):
|
||||
wn.save_safe_notes(["武器", "技能", "アイテム"])
|
||||
self.assertEqual(wn.load_safe_notes(), ["武器", "技能", "アイテム"])
|
||||
|
||||
def test_save_dedupes(self):
|
||||
wn.save_safe_notes(["武器", "武器", "技能"])
|
||||
self.assertEqual(wn.load_safe_notes(), ["武器", "技能"])
|
||||
|
||||
def test_ignores_non_list_payload(self):
|
||||
wn.SAFE_NOTES_PATH.write_text('{"武器": true}', encoding="utf-8")
|
||||
self.assertEqual(wn.load_safe_notes(), [])
|
||||
def test_verify_and_missing_are_not_translatable(self):
|
||||
self.assertFalse(wn.is_name_translatable({"safety": "verify"}))
|
||||
self.assertFalse(wn.is_name_translatable({}))
|
||||
self.assertFalse(wn.is_name_translatable({"safety": ""}))
|
||||
|
||||
|
||||
class TestIsNoteSafe(unittest.TestCase):
|
||||
def test_membership(self):
|
||||
safe = ["武器", "技能"]
|
||||
self.assertTrue(wn.is_note_safe("武器", safe))
|
||||
self.assertFalse(wn.is_note_safe("通常変数名", safe))
|
||||
class TestVocabHarvestCandidate(unittest.TestCase):
|
||||
def test_short_weapon_name_harvests(self):
|
||||
entry = {"source": "ダガー", "note": "武器", "safety": "safe"}
|
||||
self.assertTrue(wn.is_vocab_harvest_candidate(entry))
|
||||
|
||||
def test_empty_list_is_never_safe(self):
|
||||
self.assertFalse(wn.is_note_safe("武器", []))
|
||||
self.assertFalse(wn.is_note_safe("", []))
|
||||
def test_multiline_profile_skipped(self):
|
||||
entry = {
|
||||
"source": "セルリアと申します。\nよろしくお願いいたします。",
|
||||
"note": "├■プロフィール",
|
||||
"safety": "safe",
|
||||
}
|
||||
self.assertFalse(wn.is_vocab_harvest_candidate(entry))
|
||||
|
||||
def test_profile_note_skipped_even_when_single_line(self):
|
||||
entry = {"source": "ローザだ。", "note": "├■プロフィール", "safety": "safe"}
|
||||
self.assertFalse(wn.is_vocab_harvest_candidate(entry))
|
||||
|
||||
def test_resistance_label_still_harvests(self):
|
||||
entry = {"source": "物理50%軽減", "note": "┣ 属性耐性", "safety": "safe"}
|
||||
self.assertTrue(wn.is_vocab_harvest_candidate(entry))
|
||||
|
||||
|
||||
class TestCountNameSafety(unittest.TestCase):
|
||||
DOC = {
|
||||
"kind": "names",
|
||||
"names": [
|
||||
{"source": "a", "safety": "safe"},
|
||||
{"source": "b", "safety": "refs"},
|
||||
{"source": "c", "safety": "verify"},
|
||||
{"source": "d"},
|
||||
],
|
||||
}
|
||||
|
||||
def test_counts_badges(self):
|
||||
counts = wn.count_name_safety(self.DOC)
|
||||
self.assertEqual(counts["safe"], 1)
|
||||
self.assertEqual(counts["refs"], 1)
|
||||
self.assertEqual(counts["verify"], 1)
|
||||
self.assertEqual(counts["unknown"], 1)
|
||||
self.assertEqual(counts["translatable"], 2)
|
||||
|
||||
def test_summary_mentions_translatable_count(self):
|
||||
summary = wn.format_name_safety_summary(self.DOC)
|
||||
self.assertIn("2 of 4", summary)
|
||||
self.assertIn("verify", summary)
|
||||
|
||||
|
||||
class TestNoteHeader(unittest.TestCase):
|
||||
|
|
@ -74,15 +96,12 @@ class TestDeriveDbLabels(unittest.TestCase):
|
|||
self._tmp.cleanup()
|
||||
|
||||
def test_parses_bilingual_typenames_from_db_files(self):
|
||||
import json
|
||||
|
||||
(self.dir / "DataBase.project.json").write_text(
|
||||
json.dumps({
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{"typeName": "Weapon · 武器", "lines": []},
|
||||
{"typeName": "Skill · 技能", "lines": []},
|
||||
{"typeName": "■MOBセリフ", "lines": []}, # no separator -> skipped
|
||||
],
|
||||
}),
|
||||
encoding="utf-8",
|
||||
|
|
|
|||
|
|
@ -38,11 +38,10 @@ class _WolfTranslateHarness:
|
|||
|
||||
def __init__(self):
|
||||
self.captured = []
|
||||
# Captured (category, pairs) from the names -> vocab.txt harvest, so tests
|
||||
# can assert on it without writing to the real data/vocab.txt.
|
||||
self.vocab_writes = []
|
||||
self.vocab_remove_writes = []
|
||||
|
||||
def run(self, data, filename="doc.json", estimate=False, safe_notes=None):
|
||||
def run(self, data, filename="doc.json", estimate=False):
|
||||
def translate(text, history, history_ctx=None):
|
||||
self.captured.append(copy.deepcopy(text))
|
||||
return _mock_translate(text, history, history_ctx)
|
||||
|
|
@ -53,7 +52,6 @@ class _WolfTranslateHarness:
|
|||
orig_t = wd.translateAI
|
||||
orig_estimate = wd.ESTIMATE
|
||||
orig_wrap = wd.WRAP
|
||||
orig_notes = wd.SAFE_NOTES
|
||||
orig_update = wd.wolf_vocab.update_vocab_section
|
||||
orig_labels = wd.wolf_names.derive_db_labels
|
||||
wd.translateAI = translate
|
||||
|
|
@ -62,8 +60,6 @@ class _WolfTranslateHarness:
|
|||
# Never touch the real glossary / DB files during tests.
|
||||
wd.wolf_vocab.update_vocab_section = capture_vocab
|
||||
wd.wolf_names.derive_db_labels = lambda _p: {}
|
||||
if safe_notes is not None:
|
||||
wd.SAFE_NOTES = safe_notes
|
||||
try:
|
||||
data_copy = copy.deepcopy(data)
|
||||
result = wd.parseDocument(data_copy, filename)
|
||||
|
|
@ -72,7 +68,6 @@ class _WolfTranslateHarness:
|
|||
wd.translateAI = orig_t
|
||||
wd.ESTIMATE = orig_estimate
|
||||
wd.WRAP = orig_wrap
|
||||
wd.SAFE_NOTES = orig_notes
|
||||
wd.wolf_vocab.update_vocab_section = orig_update
|
||||
wd.wolf_names.derive_db_labels = orig_labels
|
||||
|
||||
|
|
@ -110,10 +105,25 @@ GAMEDAT_DOC = {
|
|||
|
||||
NAMES_DOC = {
|
||||
"kind": "names",
|
||||
"count": 2,
|
||||
"count": 3,
|
||||
"names": [
|
||||
{"source": "剣", "text": "剣", "occurrences": 2, "note": "武器"},
|
||||
{"source": "スイッチ状態", "text": "スイッチ状態", "occurrences": 1, "note": "通常変数名"},
|
||||
{"source": "剣", "text": "剣", "occurrences": 2, "note": "武器", "safety": "safe"},
|
||||
{"source": "槍", "text": "槍", "occurrences": 1, "note": "武器", "safety": "refs"},
|
||||
{
|
||||
"source": "スイッチ状態",
|
||||
"text": "スイッチ状態",
|
||||
"occurrences": 1,
|
||||
"note": "通常変数名",
|
||||
"safety": "verify",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
LEGACY_NAMES_DOC = {
|
||||
"kind": "names",
|
||||
"count": 1,
|
||||
"names": [
|
||||
{"source": "剣", "text": "剣", "occurrences": 1, "note": "武器"},
|
||||
],
|
||||
}
|
||||
|
||||
|
|
@ -159,42 +169,69 @@ class TestTranslationWriteback(unittest.TestCase):
|
|||
self.assertIsNone(err)
|
||||
self.assertEqual(data["lines"][0]["text"], "EN_ゲームタイトル")
|
||||
|
||||
def test_names_translate_only_safe_notes(self):
|
||||
# Only the entry whose note is opted-in gets translated.
|
||||
(data, _t, err), _c = _WolfTranslateHarness().run(
|
||||
NAMES_DOC, "names.json", safe_notes=["武器"]
|
||||
def test_names_translate_only_safe_and_refs(self):
|
||||
(data, _t, err), captured = _WolfTranslateHarness().run(
|
||||
NAMES_DOC, "names.json"
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(data["names"][0]["text"], "EN_剣")
|
||||
self.assertEqual(data["names"][0]["source"], "剣")
|
||||
# The unsafe category (variable name) is left identical to the source.
|
||||
self.assertEqual(data["names"][1]["text"], "スイッチ状態")
|
||||
self.assertEqual(data["names"][1]["source"], "スイッチ状態")
|
||||
self.assertEqual(data["names"][1]["text"], "EN_槍")
|
||||
self.assertEqual(data["names"][2]["text"], "スイッチ状態")
|
||||
self.assertEqual(captured, [["剣", "槍"]])
|
||||
|
||||
def test_names_default_translates_nothing(self):
|
||||
# Empty safe list = safe default: no name is translated.
|
||||
def test_names_without_safety_badges_translate_nothing(self):
|
||||
(data, _t, err), captured = _WolfTranslateHarness().run(
|
||||
NAMES_DOC, "names.json", safe_notes=[]
|
||||
LEGACY_NAMES_DOC, "names.json"
|
||||
)
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(captured, [])
|
||||
for entry in data["names"]:
|
||||
self.assertEqual(entry["text"], entry["source"])
|
||||
self.assertEqual(data["names"][0]["text"], "剣")
|
||||
|
||||
def test_names_harvest_to_vocab(self):
|
||||
# Phase 0 seeds vocab.txt: only translated safe-note pairs are harvested,
|
||||
# under a bilingual header (English from the static NOTE_EN fallback).
|
||||
harness = _WolfTranslateHarness()
|
||||
(_data, _t, err), _c = harness.run(NAMES_DOC, "names.json", safe_notes=["武器"])
|
||||
(_data, _t, err), _c = harness.run(NAMES_DOC, "names.json")
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("剣", "EN_剣")])])
|
||||
self.assertEqual(
|
||||
harness.vocab_writes,
|
||||
[
|
||||
("Weapon · 武器", [("剣", "EN_剣"), ("槍", "EN_槍")]),
|
||||
],
|
||||
)
|
||||
|
||||
def test_names_harvest_skipped_in_estimate(self):
|
||||
# Estimate mode must not seed the glossary.
|
||||
harness = _WolfTranslateHarness()
|
||||
harness.run(NAMES_DOC, "names.json", estimate=True, safe_notes=["武器"])
|
||||
harness.run(NAMES_DOC, "names.json", estimate=True)
|
||||
self.assertEqual(harness.vocab_writes, [])
|
||||
|
||||
def test_names_harvest_skips_profile_blurbs(self):
|
||||
doc = {
|
||||
"kind": "names",
|
||||
"names": [
|
||||
{"source": "ダガー", "text": "EN_ダガー", "note": "武器", "safety": "safe"},
|
||||
{
|
||||
"source": "セルリアと申します。\nよろしくお願いいたします。",
|
||||
"text": "EN_profile",
|
||||
"note": "├■プロフィール",
|
||||
"safety": "safe",
|
||||
},
|
||||
],
|
||||
}
|
||||
harness = _WolfTranslateHarness()
|
||||
harness.vocab_remove_writes = []
|
||||
orig_remove = wd.wolf_vocab.remove_vocab_section
|
||||
|
||||
def capture_remove(category):
|
||||
harness.vocab_remove_writes.append(category)
|
||||
|
||||
wd.wolf_vocab.remove_vocab_section = capture_remove
|
||||
try:
|
||||
(_data, _t, err), _c = harness.run(doc, "names.json")
|
||||
finally:
|
||||
wd.wolf_vocab.remove_vocab_section = orig_remove
|
||||
self.assertIsNone(err)
|
||||
self.assertEqual(harness.vocab_writes, [("Weapon · 武器", [("ダガー", "EN_ダガー")])])
|
||||
self.assertEqual(harness.vocab_remove_writes, ["├■プロフィール"])
|
||||
|
||||
def test_txtdir_translates(self):
|
||||
(data, _t, err), _c = _WolfTranslateHarness().run(TXTDIR_DOC, "Evtext.json")
|
||||
self.assertIsNone(err)
|
||||
|
|
|
|||
|
|
@ -127,3 +127,39 @@ def update_vocab_section(category: str, pairs) -> None:
|
|||
)
|
||||
tmp_path.write_text(combined, encoding="utf-8")
|
||||
os.replace(tmp_path, VOCAB_PATH)
|
||||
|
||||
|
||||
def remove_vocab_section(category: str) -> None:
|
||||
"""Remove a ``# {category}`` section from the game-specific vocab, if present."""
|
||||
with _VOCAB_LOCK:
|
||||
if not VOCAB_PATH.is_file():
|
||||
return
|
||||
existing = VOCAB_PATH.read_text(encoding="utf-8")
|
||||
|
||||
idx = existing.find(BASE_SEPARATOR)
|
||||
if idx != -1:
|
||||
game_part = existing[:idx]
|
||||
base_part = existing[idx:]
|
||||
else:
|
||||
game_part = existing
|
||||
base_part = ""
|
||||
|
||||
pattern = re.compile(
|
||||
rf"^[\t ]*#+\s*{re.escape(category)}\s*$\r?\n.*?(?=^[\t ]*#|\Z)",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
new_game = pattern.sub("", game_part, count=1)
|
||||
if new_game == game_part:
|
||||
return
|
||||
new_game = re.sub(r"\n{3,}", "\n\n", new_game).rstrip("\n")
|
||||
if base_part:
|
||||
combined = new_game + "\n\n" + base_part if new_game else base_part
|
||||
else:
|
||||
combined = new_game + "\n" if new_game else ""
|
||||
if combined == existing:
|
||||
return
|
||||
tmp_path = VOCAB_PATH.with_suffix(
|
||||
VOCAB_PATH.suffix + f".{os.getpid()}.{threading.get_ident()}.tmp"
|
||||
)
|
||||
tmp_path.write_text(combined, encoding="utf-8")
|
||||
os.replace(tmp_path, VOCAB_PATH)
|
||||
|
|
|
|||
|
|
@ -1,38 +1,46 @@
|
|||
"""Safe-to-translate ``note`` categories for the WOLF (WolfDawn) name glossary.
|
||||
"""WolfDawn ``names.json`` helpers for the WOLF translation workflow.
|
||||
|
||||
WolfDawn's ``names-extract`` produces ``names.json``: a project-wide list of every
|
||||
"value name" the game references (item / skill / enemy names, but also variable
|
||||
names, file/SE/BGM keys, event names, map settings, ...). Each entry carries a
|
||||
``note`` field naming the WOLF database category it came from, e.g. ``武器``
|
||||
(weapons), ``技能`` (skills), ``システム変数名`` (system variable names).
|
||||
WolfDawn's ``names-extract`` produces ``names.json``: a project-wide glossary of
|
||||
every value name the game references. Each entry has a ``source`` / ``text`` pair,
|
||||
a ``note`` field (the WOLF database category it came from), and a static
|
||||
``safety`` badge from WolfDawn's command-stream analysis:
|
||||
|
||||
Translating *all* of them corrupts the game, because many categories are logic
|
||||
keys (compared by value, used to build filenames, stored in variables). Only the
|
||||
categories that are pure on-screen text are safe to translate.
|
||||
* ``safe`` - display-only; no command references the string by name.
|
||||
* ``refs`` - referenced by name, but WolfDawn rewrites every literal on inject.
|
||||
* ``verify`` - also appears in indirect literals; left untranslated by default.
|
||||
|
||||
Rather than hand-editing 2000+ entries, the workflow classifies by ``note``: a
|
||||
repo-aware AI decides which *categories* are display-only, and that short list is
|
||||
saved here. The WolfDawn translation module (:mod:`modules.wolfdawn`) then only
|
||||
translates ``names.json`` entries whose ``note`` is in the safe list, leaving
|
||||
everything else identical to the source (so injection is a no-op for it).
|
||||
:mod:`modules.wolfdawn` translates only entries whose badge is ``safe`` or
|
||||
``refs``. ``verify`` and legacy entries without a badge keep ``text == source``
|
||||
so injection is a no-op for them.
|
||||
|
||||
The default is an empty list: nothing is translated until the user opts a
|
||||
category in, which keeps the safe-by-default behaviour the workflow relies on.
|
||||
Phase 0 still harvests translated **short name-like** entries into ``vocab.txt``,
|
||||
grouped by ``note``. Multi-line profile blurbs, dialogue, and other content-shaped
|
||||
names are translated in ``names.json`` but omitted from the glossary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from util.paths import DATA_DIR
|
||||
SAFETY_SAFE = "safe"
|
||||
SAFETY_REFS = "refs"
|
||||
SAFETY_VERIFY = "verify"
|
||||
_TRANSLATABLE_SAFETY = frozenset({SAFETY_SAFE, SAFETY_REFS})
|
||||
|
||||
SAFE_NOTES_PATH = DATA_DIR / "wolf_safe_notes.json"
|
||||
# names.json ``note`` values that are content fields, not short display names.
|
||||
_VOCAB_SKIP_NOTE_RE = re.compile(
|
||||
r"プロフィール|説明|セリフ|台詞|会話|自己紹介|挨拶|モノローグ|独白",
|
||||
)
|
||||
|
||||
# Short row labels and item/skill names fit; multi-line blurbs do not.
|
||||
_VOCAB_HARVEST_MAX_LEN = 72
|
||||
|
||||
# WolfDawn labels standard database types bilingually in a document's group
|
||||
# ``typeName`` (e.g. ``"Weapon · 武器"``). names.json ``note`` values are the JP
|
||||
# half only, so this static map provides an English label for the common,
|
||||
# safe-to-translate categories when the live DB labels are unavailable. Games
|
||||
# with custom categories fall back to a JP-only header.
|
||||
# half only, so this static map provides an English label for the common categories
|
||||
# when the live DB labels are unavailable.
|
||||
NOTE_EN: dict[str, str] = {
|
||||
"武器": "Weapon",
|
||||
"防具": "Armor",
|
||||
|
|
@ -48,53 +56,107 @@ NOTE_EN: dict[str, str] = {
|
|||
"敵キャラ個体データ": "Enemy Data",
|
||||
}
|
||||
|
||||
# WolfDawn joins the English and Japanese halves of a database label with this
|
||||
# separator, e.g. ``"Weapon · 武器"``.
|
||||
_LABEL_SEP = " · "
|
||||
|
||||
|
||||
def load_safe_notes() -> list[str]:
|
||||
"""Return the saved list of ``note`` categories that are safe to translate."""
|
||||
try:
|
||||
if SAFE_NOTES_PATH.is_file():
|
||||
data = json.loads(SAFE_NOTES_PATH.read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return [str(n) for n in data]
|
||||
except Exception:
|
||||
pass
|
||||
return []
|
||||
def parse_names_entries(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
names = data.get("names", [])
|
||||
if not isinstance(names, list):
|
||||
return []
|
||||
return [entry for entry in names if isinstance(entry, dict)]
|
||||
|
||||
|
||||
def save_safe_notes(notes: list[str]) -> None:
|
||||
"""Persist the safe ``note`` categories (de-duplicated, order preserved)."""
|
||||
seen: set[str] = set()
|
||||
ordered: list[str] = []
|
||||
for note in notes:
|
||||
note = str(note)
|
||||
if note not in seen:
|
||||
seen.add(note)
|
||||
ordered.append(note)
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SAFE_NOTES_PATH.write_text(
|
||||
json.dumps(ordered, ensure_ascii=False, indent=4), encoding="utf-8"
|
||||
def has_safety_metadata(data: dict[str, Any]) -> bool:
|
||||
"""True when *data* includes WolfDawn per-name ``safety`` badges."""
|
||||
return any("safety" in entry for entry in parse_names_entries(data))
|
||||
|
||||
|
||||
def entry_safety(entry: dict[str, Any]) -> str:
|
||||
"""Return the normalised ``safety`` badge for one names.json entry."""
|
||||
return str(entry.get("safety", "")).strip().lower()
|
||||
|
||||
|
||||
def is_name_translatable(entry: dict[str, Any]) -> bool:
|
||||
"""True when WolfDawn marks *entry* as safe or refs (per-name, not per-category)."""
|
||||
return entry_safety(entry) in _TRANSLATABLE_SAFETY
|
||||
|
||||
|
||||
def is_vocab_harvest_candidate(entry: dict[str, Any]) -> bool:
|
||||
"""True when a translated names.json entry should seed ``vocab.txt``.
|
||||
|
||||
Translation uses :func:`is_name_translatable`; this is stricter. Profile text,
|
||||
multi-line blurbs, and dialogue-shaped strings are still translated in
|
||||
``names.json`` but are not glossary terms.
|
||||
"""
|
||||
if not is_name_translatable(entry):
|
||||
return False
|
||||
src = str(entry.get("source", ""))
|
||||
if not src.strip():
|
||||
return False
|
||||
if "\n" in src or "\r" in src:
|
||||
return False
|
||||
if len(src) > _VOCAB_HARVEST_MAX_LEN:
|
||||
return False
|
||||
note = str(entry.get("note", ""))
|
||||
if _VOCAB_SKIP_NOTE_RE.search(note):
|
||||
return False
|
||||
# Two or more sentence endings usually means dialogue, not a row label.
|
||||
if len(re.findall(r"[。!?]", src)) >= 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def count_name_safety(data: dict[str, Any]) -> dict[str, int]:
|
||||
"""Return per-badge counts for a names.json document."""
|
||||
counts = {
|
||||
"total": 0,
|
||||
"safe": 0,
|
||||
"refs": 0,
|
||||
"verify": 0,
|
||||
"unknown": 0,
|
||||
"translatable": 0,
|
||||
}
|
||||
for entry in parse_names_entries(data):
|
||||
counts["total"] += 1
|
||||
safety = entry_safety(entry)
|
||||
if safety == SAFETY_SAFE:
|
||||
counts["safe"] += 1
|
||||
elif safety == SAFETY_REFS:
|
||||
counts["refs"] += 1
|
||||
elif safety == SAFETY_VERIFY:
|
||||
counts["verify"] += 1
|
||||
else:
|
||||
counts["unknown"] += 1
|
||||
counts["translatable"] = counts["safe"] + counts["refs"]
|
||||
return counts
|
||||
|
||||
|
||||
def format_name_safety_summary(data: dict[str, Any]) -> str:
|
||||
"""One-line summary for the workflow UI."""
|
||||
counts = count_name_safety(data)
|
||||
if not counts["total"]:
|
||||
return "No names found in names.json."
|
||||
if not has_safety_metadata(data):
|
||||
return (
|
||||
f"{counts['total']} name(s), but no WolfDawn safety badges - re-run names-extract "
|
||||
"with a current WolfDawn build before Phase 0."
|
||||
)
|
||||
parts = []
|
||||
if counts["safe"]:
|
||||
parts.append(f"{counts['safe']} safe")
|
||||
if counts["refs"]:
|
||||
parts.append(f"{counts['refs']} refs")
|
||||
if counts["verify"]:
|
||||
parts.append(f"{counts['verify']} verify")
|
||||
badge_text = ", ".join(parts) if parts else "no badges"
|
||||
return (
|
||||
f"{counts['translatable']} of {counts['total']} name(s) will translate "
|
||||
f"({badge_text}). Verify names are skipped."
|
||||
)
|
||||
|
||||
|
||||
def is_note_safe(note: str, safe_notes) -> bool:
|
||||
"""True if *note* is in the *safe_notes* collection."""
|
||||
if not safe_notes:
|
||||
return False
|
||||
return note in safe_notes
|
||||
|
||||
|
||||
def derive_db_labels(files_dir) -> dict[str, str]:
|
||||
"""Build a ``{japanese_note: english}`` map from staged WolfDawn ``db`` files.
|
||||
|
||||
WolfDawn tags each database group with a bilingual ``typeName`` (e.g.
|
||||
``"Weapon · 武器"``). Splitting on the separator recovers the game-accurate
|
||||
English label for a names.json ``note`` (the JP half), so harvested vocab
|
||||
sections can be headed bilingually without hardcoding categories per game.
|
||||
"""
|
||||
"""Build a ``{japanese_note: english}`` map from staged WolfDawn ``db`` files."""
|
||||
from pathlib import Path
|
||||
|
||||
labels: dict[str, str] = {}
|
||||
|
|
@ -104,12 +166,12 @@ def derive_db_labels(files_dir) -> dict[str, str]:
|
|||
return labels
|
||||
for path in sorted(base.glob("*.project.json")):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
doc = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
continue
|
||||
if data.get("kind") != "db":
|
||||
if doc.get("kind") != "db":
|
||||
continue
|
||||
for group in data.get("groups") or []:
|
||||
for group in doc.get("groups") or []:
|
||||
type_name = group.get("typeName") or ""
|
||||
if _LABEL_SEP in type_name:
|
||||
english, _, japanese = type_name.partition(_LABEL_SEP)
|
||||
|
|
@ -122,12 +184,7 @@ def derive_db_labels(files_dir) -> dict[str, str]:
|
|||
|
||||
|
||||
def note_header(note: str, db_labels: dict[str, str] | None = None) -> str:
|
||||
"""Return a bilingual vocab section header for a names.json ``note``.
|
||||
|
||||
Prefers the live DB label (:func:`derive_db_labels`), then the static
|
||||
:data:`NOTE_EN` map, and falls back to the JP note alone. Formats matching
|
||||
WolfDawn's own labels, e.g. ``"Weapon · 武器"``.
|
||||
"""
|
||||
"""Return a bilingual vocab section header for a names.json ``note``."""
|
||||
english = (db_labels or {}).get(note) or NOTE_EN.get(note)
|
||||
if english and english != note:
|
||||
return f"{english}{_LABEL_SEP}{note}"
|
||||
|
|
|
|||
|
|
@ -42,11 +42,51 @@ __all__ = [
|
|||
"names_extract",
|
||||
"strings_inject",
|
||||
"names_inject",
|
||||
"parse_strings_inject_counts",
|
||||
"parse_names_inject_counts",
|
||||
"inject_had_applied",
|
||||
"pack",
|
||||
"save_update",
|
||||
"names_check",
|
||||
]
|
||||
|
||||
# strings-inject: "applied N translation(s) (M drifted)"; names-inject uses "name change(s)".
|
||||
_INJECT_COUNTS_RE = re.compile(
|
||||
r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
_NAMES_INJECT_COUNTS_RE = re.compile(
|
||||
r"applied\s+(\d+)\s+name change.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
|
||||
)
|
||||
|
||||
|
||||
def parse_strings_inject_counts(stdout: str) -> tuple[int | None, int | None]:
|
||||
"""Return (applied, drifted) from wolf strings-inject output, or (None, None)."""
|
||||
if not stdout:
|
||||
return None, None
|
||||
m = _INJECT_COUNTS_RE.search(stdout)
|
||||
if not m:
|
||||
return None, None
|
||||
return int(m.group(1)), int(m.group(2))
|
||||
|
||||
|
||||
def parse_names_inject_counts(stdout: str) -> tuple[int | None, int | None]:
|
||||
"""Return (applied, drifted) from wolf names-inject output, or (None, None)."""
|
||||
if not stdout:
|
||||
return None, None
|
||||
m = _NAMES_INJECT_COUNTS_RE.search(stdout)
|
||||
if not m:
|
||||
return None, None
|
||||
return int(m.group(1)), int(m.group(2))
|
||||
|
||||
|
||||
def inject_had_applied(applied: int | None) -> bool:
|
||||
"""True when WolfDawn reported at least one applied change.
|
||||
|
||||
WolfDawn may exit 2 when a few lines fail safety guards but still writes the
|
||||
rest; treat a positive applied count as success for inject bookkeeping.
|
||||
"""
|
||||
return applied is not None and applied > 0
|
||||
|
||||
# Committed, prebuilt binaries live here (per-platform) so end users don't need a
|
||||
# Rust toolchain. When one is missing we download it from the WolfDawn release.
|
||||
_PACKAGE_DIR = Path(__file__).resolve().parent
|
||||
|
|
|
|||
Loading…
Reference in a new issue