610 lines
21 KiB
Python
610 lines
21 KiB
Python
"""WolfDawn ``names.json`` helpers for the WOLF translation workflow.
|
|
|
|
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:
|
|
|
|
* ``safe`` - display-only; no command references the string by name.
|
|
* ``refs`` - referenced by name (picture codes, switch labels, etc.); skipped.
|
|
* ``verify`` - also appears in indirect literals; left untranslated by default.
|
|
|
|
:mod:`modules.wolfdawn` translates only entries whose badge is ``safe``.
|
|
``refs``, ``verify``, and legacy entries without a badge keep ``text == source``
|
|
so injection is a no-op for them.
|
|
|
|
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 dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Union
|
|
|
|
PathLike = Union[str, Path]
|
|
|
|
SAFETY_SAFE = "safe"
|
|
SAFETY_REFS = "refs"
|
|
SAFETY_VERIFY = "verify"
|
|
_TRANSLATABLE_SAFETY = frozenset({SAFETY_SAFE})
|
|
|
|
# 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 categories
|
|
# when the live DB labels are unavailable.
|
|
NOTE_EN: dict[str, str] = {
|
|
"武器": "Weapon",
|
|
"防具": "Armor",
|
|
"技能": "Skill",
|
|
"アイテム": "Item",
|
|
"状態設定": "State Setting",
|
|
"用語設定": "Term Setting",
|
|
"戦闘コマンド": "Battle Command",
|
|
"システム設定": "System Setting",
|
|
"属性名の設定": "Attribute",
|
|
"主人公ステータス": "Hero Status",
|
|
"敵グループ": "Enemy Group",
|
|
"敵キャラ個体データ": "Enemy Data",
|
|
}
|
|
|
|
_LABEL_SEP = " · "
|
|
|
|
|
|
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 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 (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"]
|
|
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}). Refs and verify names are skipped."
|
|
)
|
|
|
|
|
|
def derive_db_labels(files_dir) -> dict[str, str]:
|
|
"""Build a ``{japanese_note: english}`` map from staged WolfDawn ``db`` files."""
|
|
from pathlib import Path
|
|
|
|
labels: dict[str, str] = {}
|
|
try:
|
|
base = Path(files_dir)
|
|
if not base.is_dir():
|
|
return labels
|
|
for path in sorted(base.glob("*.project.json")):
|
|
try:
|
|
doc = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
except Exception:
|
|
continue
|
|
if doc.get("kind") != "db":
|
|
continue
|
|
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)
|
|
english, japanese = english.strip(), japanese.strip()
|
|
if english and japanese and japanese not in labels:
|
|
labels[japanese] = english
|
|
except Exception:
|
|
pass
|
|
return labels
|
|
|
|
|
|
def note_header(note: str, db_labels: dict[str, str] | None = None) -> str:
|
|
"""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}"
|
|
return note
|
|
|
|
|
|
def format_note_category_summary(
|
|
data: dict[str, Any],
|
|
*,
|
|
top_n: int = 8,
|
|
db_labels: dict[str, str] | None = None,
|
|
) -> str:
|
|
"""Multi-line breakdown of top ``note`` categories for the Names workflow step."""
|
|
from collections import Counter
|
|
|
|
counts: Counter[str] = Counter()
|
|
for entry in parse_names_entries(data):
|
|
note = str(entry.get("note", "")).strip()
|
|
if note:
|
|
counts[note] += 1
|
|
if not counts:
|
|
return "No note categories found."
|
|
lines = [f"Top categories in this game ({len(counts)} unique):"]
|
|
for note, count in counts.most_common(top_n):
|
|
header = note_header(note, db_labels)
|
|
lines.append(f" {count:4d} {header}")
|
|
remaining = len(counts) - min(top_n, len(counts))
|
|
if remaining > 0:
|
|
lines.append(f" … and {remaining} more categor{'y' if remaining == 1 else 'ies'}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def collect_name_notes(data: dict[str, Any]) -> list[str]:
|
|
"""Return sorted unique ``note`` values from a names.json document."""
|
|
notes: set[str] = set()
|
|
for entry in parse_names_entries(data):
|
|
note = str(entry.get("note", "")).strip()
|
|
if note:
|
|
notes.add(note)
|
|
return sorted(notes)
|
|
|
|
|
|
def apply_names_wrap(
|
|
names_path: PathLike,
|
|
*,
|
|
note: str | None = None,
|
|
width: int | None = None,
|
|
lines: int | None = None,
|
|
dry_run: bool = False,
|
|
log_fn=None,
|
|
) -> tuple[bool, str]:
|
|
"""Run ``wolf names-wrap`` on one names.json file.
|
|
|
|
When *note* is set, only that category (``names[].note``) is processed.
|
|
Omit *width* / *lines* to measure slot geometry from JP text (recommended).
|
|
|
|
Returns ``(ok, summary)``. *ok* is False only when the CLI exits with a hard
|
|
error; overflow warnings still count as success.
|
|
"""
|
|
from util import wolfdawn
|
|
|
|
emit = log_fn or (lambda _msg: None)
|
|
path = Path(names_path)
|
|
if not path.is_file():
|
|
return False, f"names.json not found: {path}"
|
|
|
|
res = wolfdawn.names_wrap(
|
|
path,
|
|
note=note,
|
|
width=width,
|
|
lines=lines,
|
|
dry_run=dry_run,
|
|
log_fn=None,
|
|
)
|
|
for line in (res.stdout or "").splitlines():
|
|
line = line.strip()
|
|
if line:
|
|
emit(line)
|
|
for line in (res.stderr or "").splitlines():
|
|
line = line.strip()
|
|
if line:
|
|
emit(line)
|
|
|
|
if not res.ok and res.returncode not in (0, 2):
|
|
return False, f"names-wrap exited {res.returncode}"
|
|
|
|
count = wolfdawn.parse_names_wrap_counts(res.stdout, res.stderr)
|
|
shrunk = wolfdawn.parse_names_wrap_shrink_count(res.stdout, res.stderr)
|
|
scope = f"category {note}" if note else "names.json"
|
|
parts: list[str] = []
|
|
if dry_run:
|
|
if count is not None:
|
|
parts.append(
|
|
f"dry run: {count} entr{'y' if count == 1 else 'ies'} in {scope} "
|
|
"would be re-wrapped"
|
|
)
|
|
else:
|
|
parts.append(f"dry run complete for {scope}")
|
|
elif count is not None:
|
|
parts.append(
|
|
f"re-wrapped {count} entr{'y' if count == 1 else 'ies'} in {scope}"
|
|
)
|
|
else:
|
|
parts.append(f"names-wrap complete for {scope}")
|
|
if width is not None and int(width) > 0:
|
|
parts.append(f"width={int(width)}")
|
|
if lines is not None and int(lines) > 0:
|
|
parts.append(f"lines={int(lines)}")
|
|
if shrunk:
|
|
parts.append(f"{shrunk} shrunk with \\f[N]")
|
|
return True, "; ".join(parts)
|
|
|
|
|
|
def roles_json_path_for_names(names_path: PathLike) -> Path:
|
|
"""Return ``wolfdawn-roles.json`` beside *names_path* (WolfDawn looks there)."""
|
|
return Path(names_path).resolve().parent / "wolfdawn-roles.json"
|
|
|
|
|
|
def load_name_wrap_roles(roles_path: PathLike) -> dict[str, Any]:
|
|
"""Load ``wolfdawn-roles.json``, or an empty document."""
|
|
path = Path(roles_path)
|
|
if not path.is_file():
|
|
return {}
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
return data if isinstance(data, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def get_name_wrap_role(roles: dict[str, Any], note: str) -> dict[str, Any] | None:
|
|
"""Return the ``nameWraps`` entry matching *note*, if any."""
|
|
wraps = roles.get("nameWraps")
|
|
if not isinstance(wraps, list):
|
|
return None
|
|
for entry in wraps:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
entry_note = str(entry.get("note") or "")
|
|
if entry_note == note or (note and note in entry_note) or (entry_note and entry_note in note):
|
|
return entry
|
|
return None
|
|
|
|
|
|
def upsert_name_wrap_role(
|
|
roles_path: PathLike,
|
|
note: str,
|
|
*,
|
|
width: int | None = None,
|
|
max_lines: int | None = None,
|
|
font: int | None = None,
|
|
min_font: int | None = None,
|
|
) -> Path:
|
|
"""Create/update a ``nameWraps`` rule for *note* in ``wolfdawn-roles.json``.
|
|
|
|
WolfDawn reads this file from the same directory as ``names.json``.
|
|
"""
|
|
path = Path(roles_path)
|
|
data = load_name_wrap_roles(path)
|
|
wraps = data.get("nameWraps")
|
|
if not isinstance(wraps, list):
|
|
wraps = []
|
|
data["nameWraps"] = wraps
|
|
|
|
entry = None
|
|
for item in wraps:
|
|
if isinstance(item, dict) and str(item.get("note") or "") == note:
|
|
entry = item
|
|
break
|
|
if entry is None:
|
|
entry = {"note": note}
|
|
wraps.append(entry)
|
|
|
|
if width is not None and int(width) > 0:
|
|
entry["width"] = int(width)
|
|
if max_lines is not None and int(max_lines) > 0:
|
|
entry["maxLines"] = int(max_lines)
|
|
if font is not None and int(font) > 0:
|
|
entry["font"] = int(font)
|
|
if min_font is not None and int(min_font) > 0:
|
|
entry["minFont"] = int(min_font)
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
return path
|
|
|
|
|
|
# ── Name-consistency reconcile (names.json wins) ─────────────────────────────
|
|
|
|
|
|
@dataclass
|
|
class NameReconcileChange:
|
|
"""One extract line rewritten to match the chosen canonical translation."""
|
|
|
|
json_file: str
|
|
source: str
|
|
old_text: str
|
|
new_text: str
|
|
reason: str # glossary | majority
|
|
|
|
|
|
@dataclass
|
|
class NameReconcileReport:
|
|
changes: list[NameReconcileChange] = field(default_factory=list)
|
|
skipped_identity_glossary: list[str] = field(default_factory=list)
|
|
unresolved: list[str] = field(default_factory=list)
|
|
files_written: list[str] = field(default_factory=list)
|
|
|
|
@property
|
|
def changed(self) -> int:
|
|
return len(self.changes)
|
|
|
|
|
|
def glossary_map(names_doc: dict[str, Any]) -> dict[str, str]:
|
|
"""``source -> text`` from names.json (last entry wins if duplicates)."""
|
|
out: dict[str, str] = {}
|
|
for entry in parse_names_entries(names_doc):
|
|
src = entry.get("source")
|
|
txt = entry.get("text")
|
|
if isinstance(src, str) and src and isinstance(txt, str):
|
|
out[src] = txt
|
|
return out
|
|
|
|
|
|
def _walk_source_text_leaves(obj: Any, visit) -> None:
|
|
"""Call ``visit(leaf_dict)`` for every object with string source+text."""
|
|
if isinstance(obj, dict):
|
|
src, txt = obj.get("source"), obj.get("text")
|
|
if isinstance(src, str) and isinstance(txt, str):
|
|
visit(obj)
|
|
for value in obj.values():
|
|
_walk_source_text_leaves(value, visit)
|
|
elif isinstance(obj, list):
|
|
for item in obj:
|
|
_walk_source_text_leaves(item, visit)
|
|
|
|
|
|
def _collect_extract_variants(
|
|
extract_docs: dict[str, dict[str, Any]],
|
|
glossary_sources: set[str],
|
|
) -> dict[str, dict[str, int]]:
|
|
"""source -> {text: count} for non-identity glossary-name lines in extracts."""
|
|
from collections import Counter
|
|
|
|
variants: dict[str, Counter[str]] = {}
|
|
for doc in extract_docs.values():
|
|
def visit(leaf: dict[str, Any], _doc=doc) -> None:
|
|
src = leaf["source"]
|
|
txt = leaf["text"]
|
|
if src not in glossary_sources or txt == src:
|
|
return
|
|
variants.setdefault(src, Counter())[txt] += 1
|
|
|
|
_walk_source_text_leaves(doc, visit)
|
|
return {src: dict(counts) for src, counts in variants.items()}
|
|
|
|
|
|
def choose_canonical(
|
|
source: str,
|
|
glossary_text: str,
|
|
extract_variants: dict[str, int],
|
|
) -> tuple[str | None, str]:
|
|
"""Pick the canonical English for *source*.
|
|
|
|
Priority:
|
|
1. Translated ``names.json`` entry (``glossary_text != source``)
|
|
2. Majority among extract variants (tie → lexicographically first)
|
|
3. None if glossary is still JP and there are no extract variants
|
|
"""
|
|
if glossary_text != source:
|
|
return glossary_text, "glossary"
|
|
if not extract_variants:
|
|
return None, "identity_glossary"
|
|
best_count = max(extract_variants.values())
|
|
candidates = sorted(t for t, n in extract_variants.items() if n == best_count)
|
|
return candidates[0], "majority"
|
|
|
|
|
|
def reconcile_names_to_glossary(
|
|
names_doc: dict[str, Any],
|
|
extract_docs: dict[str, dict[str, Any]],
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> NameReconcileReport:
|
|
"""Rewrite extract lines so each glossary name uses one canonical translation.
|
|
|
|
*names_doc* is not modified. Only exact full-string ``source`` matches are
|
|
touched (same rule as WolfDawn ``names-check``).
|
|
"""
|
|
report = NameReconcileReport()
|
|
gloss = glossary_map(names_doc)
|
|
if not gloss:
|
|
return report
|
|
|
|
variants = _collect_extract_variants(extract_docs, set(gloss))
|
|
canonical: dict[str, tuple[str, str]] = {}
|
|
for source, gtext in gloss.items():
|
|
extract_texts = variants.get(source, {})
|
|
chosen, reason = choose_canonical(source, gtext, extract_texts)
|
|
if chosen is None:
|
|
continue
|
|
if reason == "majority" and len(extract_texts) < 2 and gtext == source:
|
|
# Glossary still JP and extracts already agree - nothing to reconcile.
|
|
continue
|
|
if reason == "glossary":
|
|
# Always apply glossary EN onto extracts (identity or wrong EN).
|
|
canonical[source] = (chosen, reason)
|
|
elif len(extract_texts) >= 2:
|
|
# Divergent extracts, glossary still JP - unify to majority.
|
|
canonical[source] = (chosen, reason)
|
|
# else: glossary JP, single extract variant - leave alone
|
|
|
|
for json_name, doc in extract_docs.items():
|
|
changed_here = 0
|
|
|
|
def visit(leaf: dict[str, Any], _name=json_name) -> None:
|
|
nonlocal changed_here
|
|
src = leaf["source"]
|
|
if src not in canonical:
|
|
return
|
|
chosen, reason = canonical[src]
|
|
old = leaf["text"]
|
|
if old == chosen:
|
|
return
|
|
# When unifying via majority (glossary still JP), do not invent EN onto
|
|
# identity lines - only collapse divergent translations.
|
|
if reason == "majority" and old == src:
|
|
return
|
|
report.changes.append(
|
|
NameReconcileChange(
|
|
json_file=_name,
|
|
source=src,
|
|
old_text=old,
|
|
new_text=chosen,
|
|
reason=reason,
|
|
)
|
|
)
|
|
if not dry_run:
|
|
leaf["text"] = chosen
|
|
changed_here += 1
|
|
|
|
_walk_source_text_leaves(doc, visit)
|
|
if changed_here and not dry_run:
|
|
report.files_written.append(json_name)
|
|
|
|
return report
|
|
|
|
|
|
def format_reconcile_summary(report: NameReconcileReport) -> str:
|
|
"""Short human summary for the GUI / log."""
|
|
if not report.changes:
|
|
return "Name reconcile: nothing to change."
|
|
parts = [f"Name reconcile: {report.changed} line(s) updated"]
|
|
by_reason: dict[str, int] = {}
|
|
for c in report.changes:
|
|
by_reason[c.reason] = by_reason.get(c.reason, 0) + 1
|
|
if by_reason:
|
|
detail = ", ".join(f"{n} via {r}" for r, n in sorted(by_reason.items()))
|
|
parts.append(f"({detail})")
|
|
if report.files_written:
|
|
parts.append(f"in {len(report.files_written)} file(s)")
|
|
majority_sources = sorted({c.source for c in report.changes if c.reason == "majority"})
|
|
if majority_sources:
|
|
parts.append(
|
|
f"; {len(majority_sources)} name(s) used majority because names.json "
|
|
"is still Japanese (refs/verify) - translate them in names.json to lock the winner"
|
|
)
|
|
return " ".join(parts)
|
|
|
|
|
|
def reconcile_translated_dir(
|
|
translated_dir: PathLike,
|
|
*,
|
|
dry_run: bool = False,
|
|
) -> NameReconcileReport:
|
|
"""Load ``translated/names.json`` + other JSON extracts and reconcile in place."""
|
|
base = Path(translated_dir)
|
|
names_path = base / "names.json"
|
|
if not names_path.is_file():
|
|
report = NameReconcileReport()
|
|
report.unresolved.append("names.json missing")
|
|
return report
|
|
|
|
names_doc = json.loads(names_path.read_text(encoding="utf-8-sig"))
|
|
if not isinstance(names_doc, dict):
|
|
report = NameReconcileReport()
|
|
report.unresolved.append("names.json invalid")
|
|
return report
|
|
|
|
extract_docs: dict[str, dict[str, Any]] = {}
|
|
for path in sorted(base.glob("*.json")):
|
|
if path.name in ("names.json", "wolfdawn-roles.json", "wrap_profile.json"):
|
|
continue
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
except Exception:
|
|
continue
|
|
if isinstance(data, dict) and data.get("kind") in (
|
|
"db",
|
|
"map",
|
|
"common",
|
|
"gamedat",
|
|
"txt",
|
|
"txt-dir",
|
|
):
|
|
extract_docs[path.name] = data
|
|
|
|
report = reconcile_names_to_glossary(names_doc, extract_docs, dry_run=dry_run)
|
|
if not dry_run:
|
|
for name in report.files_written:
|
|
path = base / name
|
|
path.write_text(
|
|
json.dumps(extract_docs[name], ensure_ascii=False, indent=4) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
return report
|