Compare commits
3 commits
eca44a15bd
...
9be5d24fcd
| Author | SHA1 | Date | |
|---|---|---|---|
| 9be5d24fcd | |||
| a733cff6b7 | |||
| 519b09fad2 |
9 changed files with 1503 additions and 130 deletions
|
|
@ -14,20 +14,20 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI
|
|||
Step 4 Database - discover content layout; translate foundation DB sheets
|
||||
(items, skills, descriptions) before narrative custom sheets
|
||||
Step 5 Maps/Events - .mps maps, CommonEvent, Game.dat, Evtext; speaker handling
|
||||
Step 6 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 7 Fix wrap - search translated JSON by in-game text; Relayout
|
||||
Step 6 Precheck - dry-run selected JSON for safety-guard skips; edit those
|
||||
lines before writing binaries
|
||||
Step 7 Inject - inject translations into Data/ binaries and refresh
|
||||
wolf_json/; quick-inject picks a few files or all
|
||||
Step 8 Fix wrap - search translated JSON by in-game text; Relayout
|
||||
(names-wrap / cell geometry) or Manual (width + font);
|
||||
inject the edited file
|
||||
Step 8 Package - run from a loose Data/ folder, or repack Data.wolf
|
||||
Step 9 Saves - fix baked strings in existing .sav files (optional)
|
||||
Step 9 Package - run from a loose Data/ folder, or repack Data.wolf
|
||||
Step 10 Saves - fix baked strings in existing .sav files (optional)
|
||||
|
||||
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 entries
|
||||
(per-name, not per-category), harvests them into vocab.txt, and Step 6 injects the
|
||||
result. All text wrapping lives in Step 7.
|
||||
(per-name, not per-category), harvests them into vocab.txt, and Step 7 injects the
|
||||
result. All text wrapping lives in Step 8.
|
||||
|
||||
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
|
||||
|
|
@ -68,6 +68,7 @@ from PyQt5.QtWidgets import (
|
|||
QStyle,
|
||||
QTabWidget,
|
||||
QTextEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
|
@ -560,22 +561,54 @@ class WolfWorkflowTab(QWidget):
|
|||
)
|
||||
|
||||
self._step_tabs = QTabWidget()
|
||||
self._step_tabs.setDocumentMode(True)
|
||||
self._step_tabs.tabBar().setVisible(False)
|
||||
self._step_tabs.setStyleSheet("""
|
||||
QTabWidget::pane { border: none; background-color: #1e1e1e; }
|
||||
QTabWidget::tab-bar { alignment: left; }
|
||||
QTabBar { background-color: #252526; }
|
||||
QTabBar::tab {
|
||||
background-color: #252526; color: #7a7a7a;
|
||||
padding: 9px 18px; border: none;
|
||||
border-right: 1px solid #3a3a3a; font-size: 12px; min-width: 90px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background-color: #1e1e1e; color: #e0e0e0;
|
||||
font-weight: bold; border-top: 2px solid #007acc;
|
||||
}
|
||||
QTabBar::tab:hover:!selected { background-color: #2d2d30; color: #cccccc; }
|
||||
QTabWidget::pane { border: none; background-color: #1e1e1e; top: 0; }
|
||||
QTabBar { height: 0; max-height: 0; }
|
||||
""")
|
||||
|
||||
# Compact always-visible strip - avoids the finicky overflow scroll arrows.
|
||||
self._step_labels: list[str] = []
|
||||
self._step_done: set[int] = set()
|
||||
self._step_buttons: list[QToolButton] = []
|
||||
self._step_strip = QWidget()
|
||||
self._step_strip.setObjectName("wolfStepStrip")
|
||||
self._step_strip.setStyleSheet("""
|
||||
QWidget#wolfStepStrip {
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
QToolButton {
|
||||
background-color: transparent;
|
||||
color: #8a8a8a;
|
||||
border: none;
|
||||
border-right: 1px solid #333333;
|
||||
padding: 6px 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QToolButton:hover {
|
||||
background-color: #2d2d30;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
QToolButton:checked {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border-top: 2px solid #007acc;
|
||||
padding-top: 6px;
|
||||
}
|
||||
QToolButton[done="true"] {
|
||||
color: #6a9955;
|
||||
}
|
||||
QToolButton[done="true"]:checked {
|
||||
color: #ffffff;
|
||||
}
|
||||
""")
|
||||
strip_layout = QHBoxLayout(self._step_strip)
|
||||
strip_layout.setContentsMargins(0, 0, 0, 0)
|
||||
strip_layout.setSpacing(0)
|
||||
|
||||
# Names curation comes before Translate so Phase 0 can seed vocab.txt.
|
||||
_tab_defs = [
|
||||
("0 Project", self._build_step0),
|
||||
|
|
@ -584,11 +617,13 @@ class WolfWorkflowTab(QWidget):
|
|||
("3 Names", self._build_step3_names),
|
||||
("4 Database", self._build_step4_database),
|
||||
("5 Maps/Events", self._build_step5_maps_events),
|
||||
("6 Inject", self._build_step4_inject),
|
||||
("7 Fix wrap", self._build_step7_relayout),
|
||||
("8 Package", self._build_step5_package),
|
||||
("9 Saves", self._build_step6_saves),
|
||||
("6 Precheck", self._build_step6_precheck),
|
||||
("7 Inject", self._build_step4_inject),
|
||||
("8 Fix wrap", self._build_step7_relayout),
|
||||
("9 Package", self._build_step5_package),
|
||||
("10 Saves", self._build_step6_saves),
|
||||
]
|
||||
self._step_labels = [label for label, _ in _tab_defs]
|
||||
|
||||
for tab_label, builder in _tab_defs:
|
||||
page = QWidget()
|
||||
|
|
@ -622,7 +657,7 @@ class WolfWorkflowTab(QWidget):
|
|||
back_btn = _make_btn("← Back", "#3a3a3a")
|
||||
back_btn.setFixedWidth(100)
|
||||
back_btn.clicked.connect(
|
||||
lambda _c, i=tab_idx: self._step_tabs.setCurrentIndex(i - 1)
|
||||
lambda _c, i=tab_idx: self._goto_step(i - 1)
|
||||
)
|
||||
nav_layout.addWidget(back_btn)
|
||||
nav_layout.addStretch()
|
||||
|
|
@ -630,19 +665,36 @@ class WolfWorkflowTab(QWidget):
|
|||
next_btn = _make_btn("Next →", "#007acc")
|
||||
next_btn.setFixedWidth(100)
|
||||
next_btn.clicked.connect(
|
||||
lambda _c, i=tab_idx, lbl=tab_label: (
|
||||
self._step_tabs.setTabText(i, "✓ " + lbl),
|
||||
self._step_tabs.setCurrentIndex(i + 1),
|
||||
)
|
||||
lambda _c, i=tab_idx: self._advance_step(i)
|
||||
)
|
||||
nav_layout.addWidget(next_btn)
|
||||
|
||||
page_layout.addWidget(nav)
|
||||
self._step_tabs.addTab(page, tab_label)
|
||||
|
||||
self._step_tabs.currentChanged.connect(self._on_step_changed)
|
||||
btn = QToolButton()
|
||||
btn.setText(self._step_strip_label(tab_idx, done=False))
|
||||
btn.setCheckable(True)
|
||||
btn.setAutoExclusive(True)
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
||||
btn.setToolTip(tab_label)
|
||||
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
btn.setMinimumHeight(40)
|
||||
btn.clicked.connect(lambda _c=False, i=tab_idx: self._goto_step(i))
|
||||
strip_layout.addWidget(btn, 1)
|
||||
self._step_buttons.append(btn)
|
||||
|
||||
splitter.addWidget(self._step_tabs)
|
||||
self._step_tabs.currentChanged.connect(self._on_step_changed)
|
||||
if self._step_buttons:
|
||||
self._step_buttons[0].setChecked(True)
|
||||
|
||||
steps_host = QWidget()
|
||||
steps_host_layout = QVBoxLayout(steps_host)
|
||||
steps_host_layout.setContentsMargins(0, 0, 0, 0)
|
||||
steps_host_layout.setSpacing(0)
|
||||
steps_host_layout.addWidget(self._step_strip)
|
||||
steps_host_layout.addWidget(self._step_tabs, 1)
|
||||
splitter.addWidget(steps_host)
|
||||
|
||||
# Right: log panel
|
||||
log_panel = QWidget()
|
||||
|
|
@ -2375,14 +2427,103 @@ class WolfWorkflowTab(QWidget):
|
|||
return p
|
||||
return None
|
||||
|
||||
# ── Step 4: Inject ─────────────────────────────────────────────────────────
|
||||
# ── Step 6: Precheck ───────────────────────────────────────────────────────
|
||||
|
||||
def _build_step6_precheck(self, layout: QVBoxLayout):
|
||||
layout.addWidget(_make_section_label("Step 6 · Inject Precheck"))
|
||||
layout.addWidget(self._desc(
|
||||
"Dry-run selected translated JSON before writing binaries. Lists only real "
|
||||
"safety skips: control-code mismatch, or text not Shift-JIS encodable. "
|
||||
"Fix those lines here, then continue to Step 7 (Inject)."
|
||||
))
|
||||
|
||||
layout.addWidget(self._subheading("Translated files"))
|
||||
self.precheck_list = QListWidget()
|
||||
self.precheck_list.setMaximumHeight(200)
|
||||
self.precheck_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.precheck_list)
|
||||
|
||||
list_row = QHBoxLayout()
|
||||
refresh_btn = self._register(_make_btn("↻ Refresh", "#3a3a3a"))
|
||||
refresh_btn.clicked.connect(self._refresh_precheck_list)
|
||||
sel_all_btn = self._register(_make_btn("Select all", "#3a3a3a"))
|
||||
sel_all_btn.clicked.connect(lambda: self._set_checklist_checks(self.precheck_list, True))
|
||||
sel_none_btn = self._register(_make_btn("Select none", "#3a3a3a"))
|
||||
sel_none_btn.clicked.connect(lambda: self._set_checklist_checks(self.precheck_list, False))
|
||||
list_row.addWidget(refresh_btn)
|
||||
list_row.addWidget(sel_all_btn)
|
||||
list_row.addWidget(sel_none_btn)
|
||||
list_row.addStretch()
|
||||
layout.addLayout(list_row)
|
||||
|
||||
precheck_row = QHBoxLayout()
|
||||
precheck_btn = self._register(_make_btn("Precheck selected", "#007acc"))
|
||||
precheck_btn.clicked.connect(self._precheck_inject_selected)
|
||||
precheck_all_btn = self._register(_make_btn("Precheck all", "#007acc"))
|
||||
precheck_all_btn.clicked.connect(self._precheck_inject_all)
|
||||
precheck_row.addWidget(precheck_btn)
|
||||
precheck_row.addWidget(precheck_all_btn)
|
||||
precheck_row.addStretch()
|
||||
layout.addLayout(precheck_row)
|
||||
|
||||
self._inject_precheck_label = QLabel("Run precheck to list safety-guard skips.")
|
||||
self._inject_precheck_label.setWordWrap(True)
|
||||
self._inject_precheck_label.setStyleSheet("color:#9cdcfe;font-size:12px;")
|
||||
layout.addWidget(self._inject_precheck_label)
|
||||
|
||||
self._inject_issue_list = QListWidget()
|
||||
self._inject_issue_list.setMaximumHeight(200)
|
||||
self._inject_issue_list.setWordWrap(True)
|
||||
self._inject_issue_list.setTextElideMode(Qt.ElideNone)
|
||||
self._inject_issue_list.setUniformItemSizes(False)
|
||||
self._inject_issue_list.setStyleSheet(
|
||||
"QListWidget{background-color:#252526;border:1px solid #3a3a3a;border-radius:4px;"
|
||||
"color:#cccccc;font-size:12px;padding:2px;}"
|
||||
"QListWidget::item{padding:4px;}"
|
||||
"QListWidget::item:selected{background:#264f78;color:#ffffff;}"
|
||||
)
|
||||
self._inject_issue_list.currentItemChanged.connect(self._on_inject_issue_selected)
|
||||
layout.addWidget(self._inject_issue_list)
|
||||
|
||||
self._inject_issue_meta = QLabel("")
|
||||
self._inject_issue_meta.setWordWrap(True)
|
||||
self._inject_issue_meta.setStyleSheet("color:#808080;font-size:11px;")
|
||||
layout.addWidget(self._inject_issue_meta)
|
||||
|
||||
self._inject_issue_edit = QTextEdit()
|
||||
self._inject_issue_edit.setPlaceholderText(
|
||||
"Select a precheck row to edit its translated text, then Save line."
|
||||
)
|
||||
self._inject_issue_edit.setMaximumHeight(120)
|
||||
self._inject_issue_edit.setStyleSheet(
|
||||
"QTextEdit{background-color:#1e1e1e;color:#d4d4d4;border:1px solid #3c3c3c;"
|
||||
"border-radius:4px;padding:6px;font-size:12px;}"
|
||||
)
|
||||
layout.addWidget(self._inject_issue_edit)
|
||||
|
||||
edit_row = QHBoxLayout()
|
||||
save_line_btn = self._register(_make_btn("Save line", "#3a3a3a"))
|
||||
save_line_btn.clicked.connect(self._save_inject_issue_line)
|
||||
edit_row.addWidget(save_line_btn)
|
||||
edit_row.addStretch()
|
||||
layout.addLayout(edit_row)
|
||||
|
||||
self._inject_precheck_issues: list = []
|
||||
self._refresh_precheck_list()
|
||||
|
||||
# ── Step 7: Inject ─────────────────────────────────────────────────────────
|
||||
|
||||
def _build_step4_inject(self, layout: QVBoxLayout):
|
||||
layout.addWidget(_make_section_label("Step 6 · Inject Translations"))
|
||||
layout.addWidget(_make_section_label("Step 7 · Inject Translations"))
|
||||
layout.addWidget(self._desc(
|
||||
"Tick translated JSON files below, then inject them into the game's Data/ "
|
||||
"binaries. Each file is reported individually — failures are shown in a "
|
||||
"dialog and in the log."
|
||||
"binaries. Run Step 6 (Precheck) first if you want to catch safety-guard "
|
||||
"skips before writing. Each file is reported individually."
|
||||
))
|
||||
|
||||
self.en_punct_cb = QCheckBox("Convert Japanese punctuation to ASCII (--en-punct)")
|
||||
|
|
@ -2403,9 +2544,20 @@ class WolfWorkflowTab(QWidget):
|
|||
)
|
||||
layout.addWidget(self.drift_cb)
|
||||
|
||||
check_row = QHBoxLayout()
|
||||
check_btn = self._register(_make_btn("Check name consistency", "#3a3a3a"))
|
||||
check_btn.clicked.connect(self._names_check)
|
||||
layout.addWidget(check_btn)
|
||||
reconcile_btn = self._register(_make_btn("Reconcile names → glossary", "#007acc"))
|
||||
reconcile_btn.setToolTip(
|
||||
"Rewrite extract lines so each names.json source uses one English value. "
|
||||
"Translated names.json entries win; if the glossary is still Japanese "
|
||||
"(refs/verify), divergent extracts are unified to the majority spelling."
|
||||
)
|
||||
reconcile_btn.clicked.connect(self._names_reconcile)
|
||||
check_row.addWidget(check_btn)
|
||||
check_row.addWidget(reconcile_btn)
|
||||
check_row.addStretch()
|
||||
layout.addLayout(check_row)
|
||||
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._subheading("Translated files"))
|
||||
|
|
@ -2424,9 +2576,9 @@ class WolfWorkflowTab(QWidget):
|
|||
refresh_btn = self._register(_make_btn("↻ Refresh", "#3a3a3a"))
|
||||
refresh_btn.clicked.connect(self._refresh_inject_list)
|
||||
sel_all_btn = self._register(_make_btn("Select all", "#3a3a3a"))
|
||||
sel_all_btn.clicked.connect(lambda: self._set_inject_checks(True))
|
||||
sel_all_btn.clicked.connect(lambda: self._set_checklist_checks(self.inject_list, True))
|
||||
sel_none_btn = self._register(_make_btn("Select none", "#3a3a3a"))
|
||||
sel_none_btn.clicked.connect(lambda: self._set_inject_checks(False))
|
||||
sel_none_btn.clicked.connect(lambda: self._set_checklist_checks(self.inject_list, False))
|
||||
list_row.addWidget(refresh_btn)
|
||||
list_row.addWidget(sel_all_btn)
|
||||
list_row.addWidget(sel_none_btn)
|
||||
|
|
@ -2445,14 +2597,14 @@ class WolfWorkflowTab(QWidget):
|
|||
|
||||
layout.addWidget(_make_hr())
|
||||
layout.addWidget(self._desc(
|
||||
"English text is often longer than Japanese. Step 7 (Fix wrapping): paste "
|
||||
"English text is often longer than Japanese. Step 8 (Fix wrapping): paste "
|
||||
"overflowing in-game text to find the sheet, wrap, and re-inject."
|
||||
))
|
||||
self._refresh_inject_list()
|
||||
|
||||
def _build_step7_relayout(self, layout: QVBoxLayout):
|
||||
"""Search-first fix wrapping: find sheet by in-game text, wrap, re-inject."""
|
||||
layout.addWidget(_make_section_label("Step 7 · Fix wrapping"))
|
||||
layout.addWidget(_make_section_label("Step 8 · Fix wrapping"))
|
||||
layout.addWidget(self._desc(
|
||||
"Paste overflowing in-game text to find it in translated JSON. "
|
||||
"Relayout uses cell width + max lines (names.json → wolf names-wrap). "
|
||||
|
|
@ -3071,7 +3223,7 @@ class WolfWorkflowTab(QWidget):
|
|||
else "Line already fits (manual)."
|
||||
)
|
||||
self._wrap_status_label.setText(
|
||||
msg + f" Inject {self._current_wrap_path.name} from Step 6."
|
||||
msg + f" Inject {self._current_wrap_path.name} from Step 7."
|
||||
)
|
||||
self._log(f"{'✅' if changed else 'ℹ'} {msg}")
|
||||
self._update_wrap_preview()
|
||||
|
|
@ -3112,7 +3264,7 @@ class WolfWorkflowTab(QWidget):
|
|||
if line is not None:
|
||||
self._current_wrap_text = str(line.get("text") or "")
|
||||
msg = f"Wrapped line at width {width}." if changed else "Line already fits at this width."
|
||||
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 6.")
|
||||
self._wrap_status_label.setText(msg + f" Inject {self._current_wrap_path.name} from Step 7.")
|
||||
self._log(f"{'✅' if changed else 'ℹ'} {msg}")
|
||||
self._update_wrap_preview()
|
||||
|
||||
|
|
@ -3279,7 +3431,7 @@ class WolfWorkflowTab(QWidget):
|
|||
self._reload_wrap_hit_editor(hit, self._active_wrap_width())
|
||||
if not quiet and hasattr(self, "_wrap_status_label"):
|
||||
self._wrap_status_label.setText(
|
||||
summary + " Re-inject names.json from Step 6."
|
||||
summary + " Re-inject names.json from Step 7."
|
||||
)
|
||||
|
||||
self._run_task(task, on_done=_after)
|
||||
|
|
@ -3348,8 +3500,8 @@ class WolfWorkflowTab(QWidget):
|
|||
if not self._require_manifest():
|
||||
return
|
||||
manifest = self._read_manifest()
|
||||
en_punct = self.en_punct_cb.isChecked()
|
||||
allow_drift = self.drift_cb.isChecked()
|
||||
en_punct = self._inject_flag_en_punct()
|
||||
allow_drift = self._inject_flag_allow_drift()
|
||||
game_json_dir = self._work_dir()
|
||||
|
||||
def task(log, progress=None):
|
||||
|
|
@ -3401,9 +3553,61 @@ class WolfWorkflowTab(QWidget):
|
|||
inject_state: dict = {}
|
||||
self._run_task(task, on_done=_after_inject)
|
||||
|
||||
def _step_strip_label(self, idx: int, *, done: bool) -> str:
|
||||
"""Compact strip text: number + short name, optional checkmark for done."""
|
||||
short_names = (
|
||||
"Project",
|
||||
"Prep",
|
||||
"Glossary",
|
||||
"Names",
|
||||
"Database",
|
||||
"Maps",
|
||||
"Precheck",
|
||||
"Inject",
|
||||
"Wrap",
|
||||
"Package",
|
||||
"Saves",
|
||||
)
|
||||
name = short_names[idx] if 0 <= idx < len(short_names) else str(idx)
|
||||
mark = "✓" if done else ""
|
||||
return f"{mark}{idx}\n{name}"
|
||||
|
||||
def _refresh_step_strip(self, current: int | None = None):
|
||||
if current is None:
|
||||
current = self._step_tabs.currentIndex() if hasattr(self, "_step_tabs") else 0
|
||||
for i, btn in enumerate(getattr(self, "_step_buttons", [])):
|
||||
done = i in self._step_done
|
||||
btn.setText(self._step_strip_label(i, done=done))
|
||||
btn.setProperty("done", "true" if done else "false")
|
||||
btn.style().unpolish(btn)
|
||||
btn.style().polish(btn)
|
||||
if i == current and not btn.isChecked():
|
||||
btn.blockSignals(True)
|
||||
btn.setChecked(True)
|
||||
btn.blockSignals(False)
|
||||
elif i != current and btn.isChecked():
|
||||
btn.blockSignals(True)
|
||||
btn.setChecked(False)
|
||||
btn.blockSignals(False)
|
||||
|
||||
def _goto_step(self, idx: int):
|
||||
if not hasattr(self, "_step_tabs"):
|
||||
return
|
||||
idx = max(0, min(idx, self._step_tabs.count() - 1))
|
||||
if self._step_tabs.currentIndex() != idx:
|
||||
self._step_tabs.setCurrentIndex(idx)
|
||||
else:
|
||||
self._refresh_step_strip(idx)
|
||||
|
||||
def _advance_step(self, from_idx: int):
|
||||
"""Mark *from_idx* done and move to the next step."""
|
||||
self._step_done.add(from_idx)
|
||||
self._goto_step(from_idx + 1)
|
||||
|
||||
def _on_step_changed(self, idx: int):
|
||||
previous = self._current_step_index
|
||||
self._current_step_index = idx
|
||||
self._refresh_step_strip(idx)
|
||||
if previous == 0 and idx != 0:
|
||||
self._auto_import_if_needed()
|
||||
# Index 3 == Names: refresh the per-name safety summary.
|
||||
|
|
@ -3412,51 +3616,209 @@ class WolfWorkflowTab(QWidget):
|
|||
# Index 4 == Database: refresh discovery report and sheet list.
|
||||
elif idx == 4:
|
||||
self._refresh_db_discovery()
|
||||
# Index 6 == Inject: keep the file list in sync with translated/.
|
||||
# Index 6 == Precheck: keep the file list in sync with translated/.
|
||||
elif idx == 6:
|
||||
self._refresh_precheck_list()
|
||||
# Index 7 == Inject: keep the file list in sync with translated/.
|
||||
elif idx == 7:
|
||||
self._refresh_inject_list()
|
||||
|
||||
def _refresh_inject_list(self):
|
||||
if not hasattr(self, "inject_list"):
|
||||
return
|
||||
self.inject_list.clear()
|
||||
def _fill_injectable_checklist(self, list_widget: QListWidget):
|
||||
"""Populate a checkable file list from translated/ + manifest."""
|
||||
list_widget.clear()
|
||||
if not self._read_manifest():
|
||||
item = QListWidgetItem("Run Step 0 (extract) first — no manifest found.")
|
||||
item = QListWidgetItem("Run Step 0 (extract) first - no manifest found.")
|
||||
item.setFlags(Qt.ItemIsEnabled)
|
||||
self.inject_list.addItem(item)
|
||||
list_widget.addItem(item)
|
||||
return
|
||||
files = self._injectable_filenames()
|
||||
if not files:
|
||||
item = QListWidgetItem("No injectable files in translated/ yet.")
|
||||
item.setFlags(Qt.ItemIsEnabled)
|
||||
self.inject_list.addItem(item)
|
||||
list_widget.addItem(item)
|
||||
return
|
||||
for json_name in files:
|
||||
item = QListWidgetItem(json_name)
|
||||
item.setData(Qt.UserRole, json_name)
|
||||
item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
self.inject_list.addItem(item)
|
||||
list_widget.addItem(item)
|
||||
|
||||
def _selected_inject_files(self) -> set[str]:
|
||||
def _refresh_precheck_list(self):
|
||||
if not hasattr(self, "precheck_list"):
|
||||
return
|
||||
self._fill_injectable_checklist(self.precheck_list)
|
||||
|
||||
def _refresh_inject_list(self):
|
||||
if not hasattr(self, "inject_list"):
|
||||
return
|
||||
self._fill_injectable_checklist(self.inject_list)
|
||||
|
||||
def _selected_checklist_files(self, list_widget: QListWidget) -> set[str]:
|
||||
selected: set[str] = set()
|
||||
for i in range(self.inject_list.count()):
|
||||
it = self.inject_list.item(i)
|
||||
for i in range(list_widget.count()):
|
||||
it = list_widget.item(i)
|
||||
if (it.flags() & Qt.ItemIsUserCheckable) and it.checkState() == Qt.Checked:
|
||||
name = it.data(Qt.UserRole)
|
||||
if name:
|
||||
selected.add(name)
|
||||
return selected
|
||||
|
||||
def _set_inject_checks(self, checked: bool):
|
||||
if not hasattr(self, "inject_list"):
|
||||
return
|
||||
def _selected_precheck_files(self) -> set[str]:
|
||||
return self._selected_checklist_files(self.precheck_list)
|
||||
|
||||
def _selected_inject_files(self) -> set[str]:
|
||||
return self._selected_checklist_files(self.inject_list)
|
||||
|
||||
def _set_checklist_checks(self, list_widget: QListWidget, checked: bool):
|
||||
state = Qt.Checked if checked else Qt.Unchecked
|
||||
for i in range(self.inject_list.count()):
|
||||
it = self.inject_list.item(i)
|
||||
for i in range(list_widget.count()):
|
||||
it = list_widget.item(i)
|
||||
if it.flags() & Qt.ItemIsUserCheckable:
|
||||
it.setCheckState(state)
|
||||
|
||||
def _precheck_inject_selected(self):
|
||||
if not self._require_manifest():
|
||||
return
|
||||
selected = self._selected_precheck_files()
|
||||
if not selected:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Nothing selected",
|
||||
"Tick one or more files, then click Precheck selected.",
|
||||
)
|
||||
return
|
||||
self._run_inject_precheck(selected)
|
||||
|
||||
def _precheck_inject_all(self):
|
||||
if not self._require_manifest():
|
||||
return
|
||||
files = self._injectable_filenames()
|
||||
if not files:
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Nothing to precheck",
|
||||
"No injectable files found in translated/.",
|
||||
)
|
||||
return
|
||||
self._run_inject_precheck(set(files))
|
||||
|
||||
def _inject_flag_en_punct(self) -> bool:
|
||||
if hasattr(self, "en_punct_cb"):
|
||||
return self.en_punct_cb.isChecked()
|
||||
return self._setting("en_punct", "true") == "true"
|
||||
|
||||
def _inject_flag_allow_drift(self) -> bool:
|
||||
if hasattr(self, "drift_cb"):
|
||||
return self.drift_cb.isChecked()
|
||||
return self._setting("allow_code_drift", "false") == "true"
|
||||
|
||||
def _run_inject_precheck(self, selected: set[str]):
|
||||
manifest = self._read_manifest()
|
||||
en_punct = self._inject_flag_en_punct()
|
||||
allow_drift = self._inject_flag_allow_drift()
|
||||
state: dict = {}
|
||||
|
||||
def task(log, progress=None):
|
||||
from util.wolfdawn import inject_precheck as wolf_pre
|
||||
|
||||
entries = manifest["entries"]
|
||||
data_dir_path = Path(manifest["data_dir"])
|
||||
game_root = Path(manifest.get("root") or self._game_root)
|
||||
originals_dir = game_root / WORK_DIR_NAME / "originals"
|
||||
self._ensure_originals(manifest, log, progress, quiet=True)
|
||||
report = wolf_pre.precheck_selected(
|
||||
sorted(selected),
|
||||
manifest_entries=entries,
|
||||
data_dir=data_dir_path,
|
||||
originals_dir=originals_dir,
|
||||
translated_dir=self._tool_root() / "translated",
|
||||
allow_code_drift=allow_drift,
|
||||
en_punct=en_punct,
|
||||
log_fn=log,
|
||||
)
|
||||
state["report"] = report
|
||||
return True, wolf_pre.format_precheck_summary(report)
|
||||
|
||||
def _after(ok: bool, msg: str):
|
||||
from util.wolfdawn import inject_precheck as wolf_pre
|
||||
|
||||
report = state.get("report")
|
||||
if not report:
|
||||
self._inject_precheck_label.setText(msg or "Precheck failed.")
|
||||
return
|
||||
self._populate_inject_precheck(report)
|
||||
self._inject_precheck_label.setText(wolf_pre.format_precheck_summary(report))
|
||||
if report.safety_issues:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Inject precheck",
|
||||
wolf_pre.format_precheck_summary(report)
|
||||
+ "\n\nSelect a safety row below, fix the text, Save line, then re-run precheck.",
|
||||
)
|
||||
else:
|
||||
QMessageBox.information(self, "Inject precheck", msg)
|
||||
|
||||
self._run_task(task, on_done=_after)
|
||||
|
||||
def _populate_inject_precheck(self, report):
|
||||
from util.wolfdawn import inject_precheck as wolf_pre
|
||||
|
||||
self._inject_issue_list.clear()
|
||||
self._inject_issue_edit.clear()
|
||||
self._inject_issue_meta.setText("")
|
||||
issues = wolf_pre.issues_for_ui(report)
|
||||
self._inject_precheck_issues = issues
|
||||
for issue in issues:
|
||||
item = QListWidgetItem(issue.summary())
|
||||
item.setData(Qt.UserRole, issue)
|
||||
item.setForeground(QColor("#f48771"))
|
||||
self._inject_issue_list.addItem(item)
|
||||
fm = self._inject_issue_list.fontMetrics()
|
||||
item.setSizeHint(
|
||||
QSize(
|
||||
self._inject_issue_list.viewport().width(),
|
||||
max(fm.height() * 3 + 10, 48),
|
||||
)
|
||||
)
|
||||
|
||||
def _on_inject_issue_selected(self, current, _previous):
|
||||
if current is None:
|
||||
self._inject_issue_edit.clear()
|
||||
self._inject_issue_meta.setText("")
|
||||
return
|
||||
issue = current.data(Qt.UserRole)
|
||||
if issue is None:
|
||||
return
|
||||
meta_parts = [issue.json_file, issue.locator, issue.message]
|
||||
self._inject_issue_meta.setText(" · ".join(p for p in meta_parts if p))
|
||||
self._inject_issue_edit.setPlainText(issue.text or issue.source or "")
|
||||
|
||||
def _save_inject_issue_line(self):
|
||||
item = self._inject_issue_list.currentItem()
|
||||
if item is None:
|
||||
QMessageBox.information(self, "Save line", "Select a precheck row first.")
|
||||
return
|
||||
issue = item.data(Qt.UserRole)
|
||||
if issue is None:
|
||||
return
|
||||
from util.wolfdawn import inject_precheck as wolf_pre
|
||||
|
||||
new_text = self._inject_issue_edit.toPlainText()
|
||||
ok, err = wolf_pre.apply_issue_text(
|
||||
self._tool_root() / "translated", issue, new_text
|
||||
)
|
||||
if not ok:
|
||||
QMessageBox.warning(self, "Save line", err or "Could not save.")
|
||||
return
|
||||
item.setText(issue.summary())
|
||||
self._log(f"Saved precheck edit: {issue.json_file} · {issue.locator}")
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"Save line",
|
||||
"Saved. Re-run Precheck to confirm the safety skip is gone.",
|
||||
)
|
||||
|
||||
def _inject_selected(self):
|
||||
if not self._require_manifest():
|
||||
return
|
||||
|
|
@ -3503,10 +3865,44 @@ class WolfWorkflowTab(QWidget):
|
|||
|
||||
self._run_task(task)
|
||||
|
||||
def _names_reconcile(self):
|
||||
"""Force extract JSON to one English per glossary name (names.json wins)."""
|
||||
translated = self._tool_root() / "translated"
|
||||
if not (translated / "names.json").is_file():
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Reconcile names",
|
||||
"translated/names.json not found. Finish Step 3 first.",
|
||||
)
|
||||
return
|
||||
|
||||
def task(log, progress=None):
|
||||
from util.wolfdawn import names as wolf_names
|
||||
|
||||
report = wolf_names.reconcile_translated_dir(translated, dry_run=False)
|
||||
summary = wolf_names.format_reconcile_summary(report)
|
||||
log(summary)
|
||||
for change in report.changes[:40]:
|
||||
log(
|
||||
f" {change.json_file}: {change.source!r} "
|
||||
f"{change.old_text!r} → {change.new_text!r} ({change.reason})"
|
||||
)
|
||||
if len(report.changes) > 40:
|
||||
log(f" … and {len(report.changes) - 40} more")
|
||||
return True, summary
|
||||
|
||||
def _after(ok: bool, msg: str):
|
||||
if ok:
|
||||
QMessageBox.information(self, "Reconcile names", msg)
|
||||
else:
|
||||
QMessageBox.warning(self, "Reconcile names", msg or "Reconcile failed.")
|
||||
|
||||
self._run_task(task, on_done=_after)
|
||||
|
||||
# ── Step 5: Package ────────────────────────────────────────────────────────
|
||||
|
||||
def _build_step5_package(self, layout: QVBoxLayout):
|
||||
layout.addWidget(_make_section_label("Step 8 · Package the Translated Game"))
|
||||
layout.addWidget(_make_section_label("Step 9 · Package the Translated Game"))
|
||||
layout.addWidget(self._desc(
|
||||
"Choose how the translated build runs. A loose Data/ folder is simplest for "
|
||||
"playtesting; repacking rebuilds a single Data.wolf archive for distribution."
|
||||
|
|
@ -3581,10 +3977,10 @@ class WolfWorkflowTab(QWidget):
|
|||
|
||||
self._run_task(task)
|
||||
|
||||
# ── Step 6: Saves ──────────────────────────────────────────────────────────
|
||||
# ── Step 10: Saves ─────────────────────────────────────────────────────────
|
||||
|
||||
def _build_step6_saves(self, layout: QVBoxLayout):
|
||||
layout.addWidget(_make_section_label("Step 9 · Update Existing Saves (optional)"))
|
||||
layout.addWidget(_make_section_label("Step 10 · Update Existing Saves (optional)"))
|
||||
layout.addWidget(self._desc(
|
||||
"WOLF .sav files bake in the game title and some strings at save time. This rewrites "
|
||||
"them so old Japanese saves load cleanly in the translated build. It is only needed "
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ from PyQt5.QtWidgets import (
|
|||
QStyle,
|
||||
QTabWidget,
|
||||
QTextEdit,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QAbstractItemView,
|
||||
|
|
@ -567,37 +568,53 @@ class WorkflowTab(QWidget):
|
|||
)
|
||||
|
||||
self._step_tabs = QTabWidget()
|
||||
self._step_tabs.setDocumentMode(True)
|
||||
self._step_tabs.tabBar().setVisible(False)
|
||||
self._step_tabs.setStyleSheet("""
|
||||
QTabWidget::pane {
|
||||
border: none;
|
||||
background-color: #1e1e1e;
|
||||
}
|
||||
QTabWidget::tab-bar {
|
||||
alignment: left;
|
||||
}
|
||||
QTabBar {
|
||||
QTabWidget::pane { border: none; background-color: #1e1e1e; top: 0; }
|
||||
QTabBar { height: 0; max-height: 0; }
|
||||
""")
|
||||
|
||||
# Compact always-visible strip - avoids the finicky overflow scroll arrows.
|
||||
self._step_labels: list[str] = []
|
||||
self._step_done: set[int] = set()
|
||||
self._step_buttons: list[QToolButton] = []
|
||||
self._step_strip = QWidget()
|
||||
self._step_strip.setObjectName("mvStepStrip")
|
||||
self._step_strip.setStyleSheet("""
|
||||
QWidget#mvStepStrip {
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background-color: #252526;
|
||||
color: #7a7a7a;
|
||||
padding: 9px 18px;
|
||||
QToolButton {
|
||||
background-color: transparent;
|
||||
color: #8a8a8a;
|
||||
border: none;
|
||||
border-right: 1px solid #3a3a3a;
|
||||
font-size: 12px;
|
||||
min-width: 90px;
|
||||
border-right: 1px solid #333333;
|
||||
padding: 6px 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background-color: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
font-weight: bold;
|
||||
border-top: 2px solid #007acc;
|
||||
}
|
||||
QTabBar::tab:hover:!selected {
|
||||
QToolButton:hover {
|
||||
background-color: #2d2d30;
|
||||
color: #cccccc;
|
||||
color: #d0d0d0;
|
||||
}
|
||||
QToolButton:checked {
|
||||
background-color: #1e1e1e;
|
||||
color: #ffffff;
|
||||
border-top: 2px solid #007acc;
|
||||
padding-top: 4px;
|
||||
}
|
||||
QToolButton[done="true"] {
|
||||
color: #6a9955;
|
||||
}
|
||||
QToolButton[done="true"]:checked {
|
||||
color: #ffffff;
|
||||
}
|
||||
""")
|
||||
strip_layout = QHBoxLayout(self._step_strip)
|
||||
strip_layout.setContentsMargins(0, 0, 0, 0)
|
||||
strip_layout.setSpacing(0)
|
||||
|
||||
_tab_defs = [
|
||||
("0 Project", self._build_step0),
|
||||
|
|
@ -610,6 +627,7 @@ class WorkflowTab(QWidget):
|
|||
("7 Export", self._build_step7_export),
|
||||
("8 Playtest", self._build_step8_playtest),
|
||||
]
|
||||
self._step_labels = [label for label, _ in _tab_defs]
|
||||
|
||||
for tab_label, builder in _tab_defs:
|
||||
# Each tab: outer page → scroll area → inner content widget
|
||||
|
|
@ -650,7 +668,7 @@ class WorkflowTab(QWidget):
|
|||
back_btn.setFixedWidth(100)
|
||||
_idx = tab_idx # capture for lambda
|
||||
back_btn.clicked.connect(
|
||||
lambda _checked, i=_idx: self._step_tabs.setCurrentIndex(i - 1)
|
||||
lambda _checked, i=_idx: self._goto_step(i - 1)
|
||||
)
|
||||
nav_layout.addWidget(back_btn)
|
||||
|
||||
|
|
@ -660,20 +678,37 @@ class WorkflowTab(QWidget):
|
|||
next_btn = _make_btn("Next →", "#007acc")
|
||||
next_btn.setFixedWidth(100)
|
||||
_idx = tab_idx # capture for lambda
|
||||
_lbl = tab_label # original label without checkmark
|
||||
next_btn.clicked.connect(
|
||||
lambda _checked, i=_idx, lbl=_lbl: (
|
||||
self._step_tabs.setTabText(i, "✓ " + lbl),
|
||||
self._step_tabs.setCurrentIndex(i + 1),
|
||||
)
|
||||
lambda _checked, i=_idx: self._advance_step(i)
|
||||
)
|
||||
nav_layout.addWidget(next_btn)
|
||||
|
||||
page_layout.addWidget(nav)
|
||||
self._step_tabs.addTab(page, tab_label)
|
||||
|
||||
btn = QToolButton()
|
||||
btn.setText(self._step_strip_label(tab_idx, done=False))
|
||||
btn.setCheckable(True)
|
||||
btn.setAutoExclusive(True)
|
||||
btn.setToolButtonStyle(Qt.ToolButtonTextOnly)
|
||||
btn.setToolTip(tab_label)
|
||||
btn.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
btn.setMinimumHeight(40)
|
||||
btn.clicked.connect(lambda _c=False, i=tab_idx: self._goto_step(i))
|
||||
strip_layout.addWidget(btn, 1)
|
||||
self._step_buttons.append(btn)
|
||||
|
||||
self._step_tabs.currentChanged.connect(self._on_step_tab_changed)
|
||||
splitter.addWidget(self._step_tabs)
|
||||
if self._step_buttons:
|
||||
self._step_buttons[0].setChecked(True)
|
||||
|
||||
steps_host = QWidget()
|
||||
steps_host_layout = QVBoxLayout(steps_host)
|
||||
steps_host_layout.setContentsMargins(0, 0, 0, 0)
|
||||
steps_host_layout.setSpacing(0)
|
||||
steps_host_layout.addWidget(self._step_strip)
|
||||
steps_host_layout.addWidget(self._step_tabs, 1)
|
||||
splitter.addWidget(steps_host)
|
||||
|
||||
# ---- Right: log area ----
|
||||
log_panel = QWidget()
|
||||
|
|
@ -753,10 +788,82 @@ class WorkflowTab(QWidget):
|
|||
pass
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _step_strip_label(self, idx: int, *, done: bool) -> str:
|
||||
"""Compact strip text: number + short name, optional checkmark for done."""
|
||||
short_names = (
|
||||
"Project",
|
||||
"Prep",
|
||||
"Speaker",
|
||||
"Glossary",
|
||||
"Phase1",
|
||||
"Phase2",
|
||||
"Plugins",
|
||||
"Export",
|
||||
"Playtest",
|
||||
)
|
||||
# Step 6 label swaps for Ace scripts.
|
||||
if idx == 6 and len(self._step_labels) > 6:
|
||||
raw = self._step_labels[6]
|
||||
if "Script" in raw:
|
||||
name = "Scripts"
|
||||
else:
|
||||
name = short_names[6]
|
||||
else:
|
||||
name = short_names[idx] if 0 <= idx < len(short_names) else str(idx)
|
||||
mark = "✓" if done else ""
|
||||
return f"{mark}{idx}\n{name}"
|
||||
|
||||
def _refresh_step_strip(self, current: int | None = None):
|
||||
if current is None:
|
||||
current = self._step_tabs.currentIndex() if hasattr(self, "_step_tabs") else 0
|
||||
for i, btn in enumerate(getattr(self, "_step_buttons", [])):
|
||||
if not btn.isVisible():
|
||||
continue
|
||||
done = i in self._step_done
|
||||
btn.setText(self._step_strip_label(i, done=done))
|
||||
btn.setProperty("done", "true" if done else "false")
|
||||
btn.style().unpolish(btn)
|
||||
btn.style().polish(btn)
|
||||
if i == current and not btn.isChecked():
|
||||
btn.blockSignals(True)
|
||||
btn.setChecked(True)
|
||||
btn.blockSignals(False)
|
||||
elif i != current and btn.isChecked():
|
||||
btn.blockSignals(True)
|
||||
btn.setChecked(False)
|
||||
btn.blockSignals(False)
|
||||
|
||||
def _goto_step(self, idx: int):
|
||||
if not hasattr(self, "_step_tabs"):
|
||||
return
|
||||
idx = max(0, min(idx, self._step_tabs.count() - 1))
|
||||
# Skip hidden steps (e.g. Playtest on Ace).
|
||||
buttons = getattr(self, "_step_buttons", [])
|
||||
if 0 <= idx < len(buttons) and not buttons[idx].isVisible():
|
||||
# Prefer previous visible step.
|
||||
for j in range(idx - 1, -1, -1):
|
||||
if buttons[j].isVisible():
|
||||
idx = j
|
||||
break
|
||||
if self._step_tabs.currentIndex() != idx:
|
||||
self._step_tabs.setCurrentIndex(idx)
|
||||
else:
|
||||
self._refresh_step_strip(idx)
|
||||
|
||||
def _advance_step(self, from_idx: int):
|
||||
"""Mark *from_idx* done and move to the next visible step."""
|
||||
self._step_done.add(from_idx)
|
||||
nxt = from_idx + 1
|
||||
buttons = getattr(self, "_step_buttons", [])
|
||||
while nxt < len(buttons) and not buttons[nxt].isVisible():
|
||||
nxt += 1
|
||||
self._goto_step(nxt)
|
||||
|
||||
def _on_step_tab_changed(self, index: int):
|
||||
"""Refresh config-backed controls when their workflow page is shown."""
|
||||
previous_index = self._current_step_index
|
||||
self._current_step_index = index
|
||||
self._refresh_step_strip(index)
|
||||
|
||||
if previous_index == 0 and index != 0:
|
||||
self._auto_import_if_needed()
|
||||
|
|
@ -3201,8 +3308,13 @@ class WorkflowTab(QWidget):
|
|||
w = getattr(self, attr, None)
|
||||
if w is not None:
|
||||
w.setVisible(not is_ace)
|
||||
# Tab label
|
||||
self._step_tabs.setTabText(6, "6 Scripts" if is_ace else "6 Plugins.js")
|
||||
# Tab / strip label
|
||||
step6_label = "6 Scripts" if is_ace else "6 Plugins.js"
|
||||
self._step_tabs.setTabText(6, step6_label)
|
||||
if hasattr(self, "_step_labels") and len(self._step_labels) > 6:
|
||||
self._step_labels[6] = step6_label
|
||||
if hasattr(self, "_step_buttons") and len(self._step_buttons) > 6:
|
||||
self._step_buttons[6].setToolTip(step6_label)
|
||||
# Step 6 section header
|
||||
lbl = getattr(self, "_step6_section_label", None)
|
||||
if lbl is not None:
|
||||
|
|
@ -3239,14 +3351,18 @@ class WorkflowTab(QWidget):
|
|||
"visible player-facing strings in plugins.js, using vocab.txt as a glossary."
|
||||
)
|
||||
# Step 8 — TL Inspector (MV/MZ only; hidden for Ace)
|
||||
show_playtest = not is_ace
|
||||
if hasattr(self, "_step_tabs") and self._step_tabs.count() > 8:
|
||||
show_playtest = not is_ace
|
||||
if hasattr(self._step_tabs, "setTabVisible"):
|
||||
self._step_tabs.setTabVisible(8, show_playtest)
|
||||
else:
|
||||
self._step_tabs.setTabEnabled(8, show_playtest)
|
||||
if is_ace and self._step_tabs.currentIndex() == 8:
|
||||
self._step_tabs.setCurrentIndex(7)
|
||||
self._goto_step(7)
|
||||
if hasattr(self, "_step_buttons") and len(self._step_buttons) > 8:
|
||||
self._step_buttons[8].setVisible(show_playtest)
|
||||
self._step_buttons[8].setEnabled(show_playtest)
|
||||
self._refresh_step_strip()
|
||||
box = getattr(self, "_step8_playtest_box", None)
|
||||
install_both_btn = getattr(self, "_install_both_btn", None)
|
||||
if box is not None:
|
||||
|
|
|
|||
|
|
@ -45,11 +45,14 @@ class WolfInjectCountsTests(unittest.TestCase):
|
|||
"event 374 cmd 233 str 0: control-code mismatch - source has [\"\\\\^\"], "
|
||||
"translation has [\"\\\\ \"]\n"
|
||||
"applied 24547 translation(s) (37 untranslated, 0 drifted); wrote CommonEvent.dat\n"
|
||||
"WARNING: 1 line(s) left UNTRANSLATED by a safety guard - "
|
||||
"1 control-code mismatch, 0 not encodable\n"
|
||||
)
|
||||
applied, drifted = wolfdawn.parse_strings_inject_counts("", stderr)
|
||||
self.assertEqual(applied, 24547)
|
||||
self.assertEqual(drifted, 0)
|
||||
self.assertEqual(wolfdawn.parse_strings_inject_untranslated("", stderr), 37)
|
||||
self.assertEqual(wolfdawn.parse_strings_inject_safety_count("", stderr), 1)
|
||||
mismatches = wolfdawn.parse_strings_inject_mismatches("", stderr)
|
||||
self.assertEqual(len(mismatches), 1)
|
||||
self.assertIn("control-code mismatch", mismatches[0])
|
||||
|
|
|
|||
136
tests/test_wolf_inject_precheck.py
Normal file
136
tests/test_wolf_inject_precheck.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Unit tests for inject precheck and safety-guard parsers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
os.chdir(ROOT)
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from util import wolfdawn # noqa: E402
|
||||
from util.wolfdawn import inject as wi # noqa: E402
|
||||
from util.wolfdawn import inject_precheck as pre # noqa: E402
|
||||
|
||||
|
||||
class InjectSafetyParserTests(unittest.TestCase):
|
||||
def test_identity_count_is_not_safety(self):
|
||||
stderr = (
|
||||
"db type 49 row 18 field 21: control-code mismatch - source has "
|
||||
'["\\\\c[19]"], translation has []; edit the words\n'
|
||||
"would apply 2178 translation(s) (4 untranslated, 0 drifted); "
|
||||
"dry run - did NOT write out.project\n"
|
||||
"WARNING: 1 line(s) left UNTRANSLATED by a safety guard - "
|
||||
"1 control-code mismatch, 0 not encodable\n"
|
||||
)
|
||||
self.assertEqual(wolfdawn.parse_strings_inject_counts("", stderr), (2178, 0))
|
||||
self.assertEqual(wolfdawn.parse_strings_inject_untranslated("", stderr), 4)
|
||||
self.assertEqual(wolfdawn.parse_strings_inject_safety_count("", stderr), 1)
|
||||
lines = wolfdawn.parse_strings_inject_safety_lines("", stderr)
|
||||
self.assertEqual(len(lines), 1)
|
||||
self.assertEqual(lines[0][1], "code_mismatch")
|
||||
|
||||
def test_interpret_ignores_identity_in_summary(self):
|
||||
res = mock.Mock(
|
||||
ok=True,
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr=(
|
||||
"applied 2178 translation(s) (4 untranslated, 0 drifted); "
|
||||
"wrote DataBase.project\n"
|
||||
),
|
||||
)
|
||||
result = wi._interpret_strings_result("DataBase.project.json", res)
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(result.summary, "applied 2178 line(s)")
|
||||
self.assertNotIn("unchanged", result.summary)
|
||||
self.assertNotIn("safety", result.summary)
|
||||
|
||||
def test_interpret_labels_real_safety_warning(self):
|
||||
res = mock.Mock(
|
||||
ok=True,
|
||||
returncode=0,
|
||||
stdout="",
|
||||
stderr=(
|
||||
"db type 1 row 0 field 0: control-code mismatch - source has "
|
||||
'["\\\\^"], translation has []\n'
|
||||
"applied 10 translation(s) (0 untranslated, 0 drifted); wrote x\n"
|
||||
"WARNING: 1 line(s) left UNTRANSLATED by a safety guard - "
|
||||
"1 control-code mismatch, 0 not encodable\n"
|
||||
),
|
||||
)
|
||||
result = wi._interpret_strings_result("x.json", res)
|
||||
self.assertTrue(result.success)
|
||||
self.assertIn("skipped by safety guard", result.summary)
|
||||
|
||||
|
||||
class InjectPrecheckLocalTests(unittest.TestCase):
|
||||
def test_resolve_db_locator(self):
|
||||
doc = {
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 23,
|
||||
"typeName": "Sheet",
|
||||
"lines": [
|
||||
{
|
||||
"row": 2,
|
||||
"field": 65,
|
||||
"fieldName": "comment",
|
||||
"source": "txtblank",
|
||||
"text": "txtblank",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
line, hit = pre.resolve_locator_line(doc, "db type 23 row 2 field 65")
|
||||
self.assertIsNotNone(line)
|
||||
self.assertEqual(line["text"], "txtblank")
|
||||
self.assertEqual(hit["sheet_name"], "Sheet")
|
||||
|
||||
def test_apply_issue_text_updates_json(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "DataBase.project.json"
|
||||
doc = {
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 1,
|
||||
"typeName": "T",
|
||||
"lines": [
|
||||
{
|
||||
"row": 0,
|
||||
"field": 2,
|
||||
"fieldName": "f",
|
||||
"source": "あ\\^",
|
||||
"text": "A",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(doc), encoding="utf-8")
|
||||
issue = pre.InjectIssue(
|
||||
json_file="DataBase.project.json",
|
||||
kind="code_mismatch",
|
||||
locator="db type 1 row 0 field 2",
|
||||
message="mismatch",
|
||||
source="あ\\^",
|
||||
text="A",
|
||||
)
|
||||
ok, err = pre.apply_issue_text(Path(tmp), issue, "A\\^")
|
||||
self.assertTrue(ok, err)
|
||||
saved = json.loads(path.read_text(encoding="utf-8"))
|
||||
self.assertEqual(saved["groups"][0]["lines"][0]["text"], "A\\^")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -148,5 +148,68 @@ class TestNameWrapRoles(unittest.TestCase):
|
|||
self.assertEqual(role["font"], 14)
|
||||
|
||||
|
||||
class TestNameReconcile(unittest.TestCase):
|
||||
def test_glossary_wins_over_divergent_extracts(self):
|
||||
names = {
|
||||
"kind": "names",
|
||||
"names": [{"source": "牧場主", "text": "Rancher", "safety": "safe"}],
|
||||
}
|
||||
db = {
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 1,
|
||||
"lines": [
|
||||
{"source": "牧場主", "text": "Ranch Owner"},
|
||||
{"source": "牧場主", "text": "Rancher"},
|
||||
{"source": "牧場主", "text": "牧場主"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
report = wn.reconcile_names_to_glossary(names, {"DataBase.project.json": db})
|
||||
self.assertEqual(report.changed, 2) # Owner + identity → Rancher
|
||||
texts = [ln["text"] for ln in db["groups"][0]["lines"]]
|
||||
self.assertEqual(texts, ["Rancher", "Rancher", "Rancher"])
|
||||
self.assertTrue(all(c.reason == "glossary" for c in report.changes))
|
||||
|
||||
def test_majority_when_glossary_still_japanese(self):
|
||||
names = {
|
||||
"kind": "names",
|
||||
"names": [{"source": "魔獣マニア", "text": "魔獣マニア", "safety": "refs"}],
|
||||
}
|
||||
db = {
|
||||
"kind": "db",
|
||||
"groups": [
|
||||
{
|
||||
"type": 1,
|
||||
"lines": [
|
||||
{"source": "魔獣マニア", "text": "Beast Maniac"},
|
||||
{"source": "魔獣マニア", "text": "Monster Beast Maniac"},
|
||||
{"source": "魔獣マニア", "text": "Monster Beast Maniac"},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
report = wn.reconcile_names_to_glossary(names, {"DataBase.project.json": db})
|
||||
self.assertEqual(report.changed, 1)
|
||||
self.assertEqual(report.changes[0].new_text, "Monster Beast Maniac")
|
||||
self.assertEqual(report.changes[0].reason, "majority")
|
||||
texts = [ln["text"] for ln in db["groups"][0]["lines"]]
|
||||
self.assertEqual(
|
||||
texts,
|
||||
["Monster Beast Maniac", "Monster Beast Maniac", "Monster Beast Maniac"],
|
||||
)
|
||||
|
||||
def test_tie_breaks_lexicographically(self):
|
||||
chosen, reason = wn.choose_canonical(
|
||||
"牧場主",
|
||||
"牧場主",
|
||||
{"Ranch Owner": 4, "Rancher": 4},
|
||||
)
|
||||
self.assertEqual(reason, "majority")
|
||||
self.assertEqual(chosen, "Ranch Owner")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ __all__ = [
|
|||
"parse_strings_inject_counts",
|
||||
"parse_strings_inject_untranslated",
|
||||
"parse_strings_inject_mismatches",
|
||||
"parse_strings_inject_safety_count",
|
||||
"parse_strings_inject_safety_lines",
|
||||
"parse_names_inject_counts",
|
||||
"inject_had_applied",
|
||||
"pack",
|
||||
|
|
@ -56,20 +58,25 @@ __all__ = [
|
|||
"desc_relayout",
|
||||
]
|
||||
|
||||
# strings-inject: "applied N translation(s) (M drifted)"; names-inject uses "name change(s)".
|
||||
# strings-inject: "applied N" / "would apply N"; names-inject uses "name change(s)".
|
||||
# WolfDawn counts identity lines (text == source) as "untranslated" - not safety skips.
|
||||
_INJECT_COUNTS_RE = re.compile(
|
||||
r"applied\s+(\d+)\s+translation.*?(\d+)\s+drifted", re.IGNORECASE | re.DOTALL
|
||||
r"(?:would apply|applied)\s+(\d+)\s+translation.*?(\d+)\s+drifted",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_INJECT_UNTRANSLATED_RE = re.compile(
|
||||
r"applied\s+\d+\s+translation.*?\((\d+)\s+untranslated", re.IGNORECASE | re.DOTALL
|
||||
r"(?:would apply|applied)\s+\d+\s+translation.*?\((\d+)\s+untranslated",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_INJECT_MISMATCH_RE = re.compile(
|
||||
r"^(?:event\s+\d+\s+)?cmd\s+(\d+)\s+str\s+(\d+):\s+control-code mismatch",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
_INJECT_SAFETY_COUNT_RE = re.compile(
|
||||
r"WARNING:\s*(\d+)\s+line\(s\)\s+left\s+UNTRANSLATED\s+by\s+a\s+safety\s+guard",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_INJECT_MISMATCH_EVENT_RE = re.compile(
|
||||
r"^event\s+(\d+)\s+cmd\s+(\d+)\s+str\s+(\d+):\s+control-code mismatch",
|
||||
re.IGNORECASE | re.MULTILINE,
|
||||
_INJECT_SAFETY_LINE_RE = re.compile(
|
||||
r"^(?P<locator>.+?):\s+"
|
||||
r"(?P<kind>control-code mismatch|text not representable in Shift-JIS)\b"
|
||||
r"(?P<rest>.*)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NAMES_INJECT_COUNTS_RE = re.compile(
|
||||
r"(?:would apply|applied)\s+(\d+)\s+name change.*?(\d+)\s+(?:drifted|unmatched)",
|
||||
|
|
@ -95,7 +102,7 @@ def _inject_output_text(stdout: str, stderr: str) -> str:
|
|||
|
||||
|
||||
def parse_strings_inject_counts(stdout: str, stderr: str = "") -> tuple[int | None, int | None]:
|
||||
"""Return (applied, drifted) from wolf strings-inject output, or (None, None)."""
|
||||
"""Return (applied / would-apply, drifted) from wolf strings-inject output."""
|
||||
text = _inject_output_text(stdout, stderr)
|
||||
if not text:
|
||||
return None, None
|
||||
|
|
@ -106,23 +113,54 @@ def parse_strings_inject_counts(stdout: str, stderr: str = "") -> tuple[int | No
|
|||
|
||||
|
||||
def parse_strings_inject_untranslated(stdout: str, stderr: str = "") -> int | None:
|
||||
"""Return the untranslated line count from strings-inject summary, if present."""
|
||||
"""Return identity-line count (``text == source``), not safety-guard skips."""
|
||||
text = _inject_output_text(stdout, stderr)
|
||||
m = _INJECT_UNTRANSLATED_RE.search(text)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def parse_strings_inject_mismatches(stdout: str, stderr: str = "") -> list[str]:
|
||||
"""Return human-readable control-code mismatch locations from inject output."""
|
||||
def parse_strings_inject_safety_count(stdout: str, stderr: str = "") -> int | None:
|
||||
"""Return WARNING safety-guard skip count, if WolfDawn printed one."""
|
||||
text = _inject_output_text(stdout, stderr)
|
||||
out: list[str] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if "control-code mismatch" in line:
|
||||
out.append(line)
|
||||
m = _INJECT_SAFETY_COUNT_RE.search(text)
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def parse_strings_inject_safety_lines(
|
||||
stdout: str, stderr: str = ""
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Return ``(locator, kind, full_line)`` for each per-line safety diagnostic.
|
||||
|
||||
*kind* is ``code_mismatch`` or ``unrepresentable``.
|
||||
"""
|
||||
text = _inject_output_text(stdout, stderr)
|
||||
out: list[tuple[str, str, str]] = []
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = _INJECT_SAFETY_LINE_RE.match(line)
|
||||
if not m:
|
||||
continue
|
||||
kind_raw = m.group("kind").lower()
|
||||
kind = (
|
||||
"unrepresentable"
|
||||
if "representable" in kind_raw
|
||||
else "code_mismatch"
|
||||
)
|
||||
out.append((m.group("locator").strip(), kind, line))
|
||||
return out
|
||||
|
||||
|
||||
def parse_strings_inject_mismatches(stdout: str, stderr: str = "") -> list[str]:
|
||||
"""Return human-readable control-code mismatch lines from inject output."""
|
||||
return [
|
||||
full
|
||||
for _loc, kind, full in parse_strings_inject_safety_lines(stdout, stderr)
|
||||
if kind == "code_mismatch"
|
||||
]
|
||||
|
||||
|
||||
def parse_names_inject_counts(stdout: str, stderr: str = "") -> tuple[int | None, int | None]:
|
||||
"""Return (applied, drifted) from wolf names-inject output, or (None, None)."""
|
||||
text = _inject_output_text(stdout, stderr)
|
||||
|
|
@ -619,6 +657,7 @@ def strings_inject(
|
|||
output: PathLike,
|
||||
allow_code_drift: bool = False,
|
||||
en_punct: bool = False,
|
||||
dry_run: bool = False,
|
||||
log_fn=None,
|
||||
) -> WolfResult:
|
||||
"""``wolf strings-inject <edited.json> --base <orig> -o <out> [flags]``."""
|
||||
|
|
@ -627,6 +666,8 @@ def strings_inject(
|
|||
args.append("--allow-code-drift")
|
||||
if en_punct:
|
||||
args.append("--en-punct")
|
||||
if dry_run:
|
||||
args.append("--dry-run")
|
||||
return _run(args, log_fn=log_fn)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -127,22 +127,24 @@ def _wolf_output_snippet(stdout: str, stderr: str, *, limit: int = 400) -> str:
|
|||
def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileInjectResult:
|
||||
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
|
||||
applied, drifted = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr)
|
||||
untranslated = wolfdawn.parse_strings_inject_untranslated(res.stdout, res.stderr)
|
||||
safety = wolfdawn.parse_strings_inject_safety_count(res.stdout, res.stderr)
|
||||
mismatches = wolfdawn.parse_strings_inject_mismatches(res.stdout, res.stderr)
|
||||
detail = _wolf_output_snippet(res.stdout, res.stderr)
|
||||
|
||||
if not res.ok:
|
||||
reason = cli_err or f"wolf exited {res.returncode}"
|
||||
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
|
||||
|
||||
# Exit 2 with a positive applied count still wrote the good lines; treat as success
|
||||
# and surface safety skips in the summary (see inject_had_applied).
|
||||
if wolfdawn.inject_had_applied(applied):
|
||||
msg = f"applied {applied} line(s)"
|
||||
if drifted:
|
||||
msg += f" ({drifted} drifted)"
|
||||
if untranslated:
|
||||
msg += f" ({untranslated} skipped by safety guard)"
|
||||
if safety:
|
||||
msg += f" ({safety} skipped by safety guard)"
|
||||
return FileInjectResult(json_name, True, msg, applied=applied, detail=detail)
|
||||
|
||||
if not res.ok:
|
||||
reason = cli_err or f"wolf exited {res.returncode}"
|
||||
return FileInjectResult(json_name, False, reason, applied=applied, detail=detail)
|
||||
|
||||
if (drifted or 0) > 0:
|
||||
return FileInjectResult(
|
||||
json_name,
|
||||
|
|
@ -152,11 +154,11 @@ def _interpret_strings_result(json_name: str, res: wolfdawn.WolfResult) -> FileI
|
|||
detail=detail,
|
||||
)
|
||||
|
||||
if untranslated or mismatches:
|
||||
if safety or mismatches:
|
||||
parts = []
|
||||
if untranslated:
|
||||
parts.append(f"{untranslated} line(s) skipped by safety guard")
|
||||
if mismatches:
|
||||
if safety:
|
||||
parts.append(f"{safety} line(s) skipped by safety guard")
|
||||
elif mismatches:
|
||||
parts.append(f"{len(mismatches)} control-code mismatch(es)")
|
||||
return FileInjectResult(
|
||||
json_name, False, "; ".join(parts), applied=0, detail=detail
|
||||
|
|
|
|||
384
util/wolfdawn/inject_precheck.py
Normal file
384
util/wolfdawn/inject_precheck.py
Normal file
|
|
@ -0,0 +1,384 @@
|
|||
"""Pre-inject review: list WolfDawn safety-guard skips before writing binaries.
|
||||
|
||||
WolfDawn ``strings-inject`` outcomes:
|
||||
|
||||
* ``applied`` - translation written
|
||||
* ``untranslated`` - ``text == source`` (left alone; not a problem, not listed here)
|
||||
* ``drifted`` - base no longer holds ``source`` (stale Data/; file-level, not listed here)
|
||||
* ``control-code mismatch`` / ``text not representable`` - safety skips (listed)
|
||||
|
||||
This module dry-runs selected files and resolves safety locators back into editable
|
||||
JSON lines so Step 6 can show a review list before inject.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from util.wolfdawn import codes as wolf_codes
|
||||
from util.wolfdawn import db_dat_sibling
|
||||
from util import wolfdawn
|
||||
from util.wolfdawn.inject import NAMES_JSON, orig_base_for, repair_inject_json
|
||||
|
||||
_DB_LOC_RE = re.compile(
|
||||
r"^db\s+type\s+(?P<type>\d+)\s+row\s+(?P<row>\d+)\s+field\s+(?P<field>\d+)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EVENT_LOC_RE = re.compile(
|
||||
r"^event\s+(?P<event>\d+)"
|
||||
r"(?:\s+page\s+(?P<page>\d+))?"
|
||||
r"\s+cmd\s+(?P<cmd>\d+)\s+str\s+(?P<str>\d+)\s*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GAMEDAT_LOC_RE = re.compile(r"^gamedat\s+(?P<key>.+)\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InjectIssue:
|
||||
"""One line skipped by a WolfDawn safety guard."""
|
||||
|
||||
json_file: str
|
||||
kind: str # code_mismatch | unrepresentable
|
||||
locator: str
|
||||
message: str
|
||||
source: str = ""
|
||||
text: str = ""
|
||||
hit_id: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def summary(self) -> str:
|
||||
label = {
|
||||
"code_mismatch": "control-code mismatch",
|
||||
"unrepresentable": "not Shift-JIS encodable",
|
||||
}.get(self.kind, self.kind)
|
||||
preview = (self.text or self.source).replace("\n", " ").strip()
|
||||
if len(preview) > 90:
|
||||
preview = preview[:90] + "…"
|
||||
loc = self.locator or self.json_file
|
||||
if preview:
|
||||
return f"{label}\n{self.json_file} · {loc}\n{preview}"
|
||||
return f"{label}\n{self.json_file} · {loc}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilePrecheck:
|
||||
json_name: str
|
||||
would_apply: int | None = None
|
||||
drifted: int | None = None
|
||||
safety_count: int | None = None
|
||||
issues: list[InjectIssue] = field(default_factory=list)
|
||||
error: str = ""
|
||||
detail: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrecheckReport:
|
||||
files: list[FilePrecheck] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def issues(self) -> list[InjectIssue]:
|
||||
out: list[InjectIssue] = []
|
||||
for f in self.files:
|
||||
out.extend(f.issues)
|
||||
return out
|
||||
|
||||
@property
|
||||
def safety_issues(self) -> list[InjectIssue]:
|
||||
return list(self.issues)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return all(not f.error for f in self.files)
|
||||
|
||||
|
||||
def _load_doc(path: Path) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
except Exception:
|
||||
return None
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def resolve_locator_line(
|
||||
doc: dict[str, Any], locator: str
|
||||
) -> tuple[dict[str, Any] | None, dict[str, Any]]:
|
||||
"""Map a WolfDawn inject locator to the JSON line dict and a hit_id."""
|
||||
kind = str(doc.get("kind") or "")
|
||||
m = _DB_LOC_RE.match(locator.strip())
|
||||
if m and kind == "db":
|
||||
type_id, row, field = int(m.group("type")), int(m.group("row")), int(m.group("field"))
|
||||
for g in doc.get("groups") or []:
|
||||
if not isinstance(g, dict) or g.get("type") != type_id:
|
||||
continue
|
||||
type_name = str(g.get("typeName") or type_id)
|
||||
for ln in g.get("lines") or []:
|
||||
if not isinstance(ln, dict):
|
||||
continue
|
||||
if ln.get("row") == row and ln.get("field") == field:
|
||||
return ln, {
|
||||
"json_file": "",
|
||||
"kind": "db",
|
||||
"sheet_name": type_name,
|
||||
"row": row,
|
||||
"field_name": str(ln.get("fieldName") or ""),
|
||||
"type": type_id,
|
||||
"field": field,
|
||||
}
|
||||
return None, {
|
||||
"kind": "db",
|
||||
"type": type_id,
|
||||
"row": row,
|
||||
"field": field,
|
||||
"sheet_name": str(type_id),
|
||||
}
|
||||
|
||||
m = _EVENT_LOC_RE.match(locator.strip())
|
||||
if m and kind in ("map", "common"):
|
||||
event = int(m.group("event"))
|
||||
page = m.group("page")
|
||||
page_i = int(page) if page is not None else None
|
||||
cmd, si_arg = int(m.group("cmd")), int(m.group("str"))
|
||||
for si, sc in enumerate(doc.get("scenes") or []):
|
||||
if not isinstance(sc, dict) or sc.get("event") != event:
|
||||
continue
|
||||
if page_i is not None and sc.get("page") != page_i:
|
||||
continue
|
||||
for li, ln in enumerate(sc.get("lines") or []):
|
||||
if not isinstance(ln, dict):
|
||||
continue
|
||||
if ln.get("cmd") == cmd and ln.get("str") == si_arg:
|
||||
return ln, {
|
||||
"json_file": "",
|
||||
"kind": kind,
|
||||
"sheet_name": "",
|
||||
"scene_index": si,
|
||||
"line_index": li,
|
||||
"event": event,
|
||||
"page": page_i,
|
||||
"cmd": cmd,
|
||||
"str": si_arg,
|
||||
}
|
||||
return None, {
|
||||
"kind": kind,
|
||||
"event": event,
|
||||
"page": page_i,
|
||||
"cmd": cmd,
|
||||
"str": si_arg,
|
||||
}
|
||||
|
||||
m = _GAMEDAT_LOC_RE.match(locator.strip())
|
||||
if m and kind == "gamedat":
|
||||
key = m.group("key").strip()
|
||||
for ln in doc.get("lines") or []:
|
||||
if isinstance(ln, dict) and str(ln.get("key") or "") == key:
|
||||
return ln, {
|
||||
"json_file": "",
|
||||
"kind": "gamedat",
|
||||
"sheet_name": "Game.dat",
|
||||
"field_name": key,
|
||||
"key": key,
|
||||
}
|
||||
return None, {"kind": "gamedat", "key": key, "sheet_name": "Game.dat"}
|
||||
|
||||
return None, {}
|
||||
|
||||
|
||||
def apply_issue_text(
|
||||
translated_dir: Path,
|
||||
issue: InjectIssue,
|
||||
new_text: str,
|
||||
) -> tuple[bool, str]:
|
||||
"""Write *new_text* onto the JSON line for *issue*. Returns (ok, error)."""
|
||||
path = Path(translated_dir) / issue.json_file
|
||||
if not path.is_file():
|
||||
return False, f"{issue.json_file} not found"
|
||||
doc = _load_doc(path)
|
||||
if not doc:
|
||||
return False, f"could not read {issue.json_file}"
|
||||
|
||||
line: dict[str, Any] | None = None
|
||||
if issue.locator:
|
||||
line, _ = resolve_locator_line(doc, issue.locator)
|
||||
if line is None and issue.hit_id.get("kind") == "db":
|
||||
line, _ = resolve_locator_line(
|
||||
doc,
|
||||
f"db type {issue.hit_id.get('type')} row {issue.hit_id.get('row')} "
|
||||
f"field {issue.hit_id.get('field')}",
|
||||
)
|
||||
|
||||
if line is None:
|
||||
return False, f"could not locate {issue.locator or issue.json_file}"
|
||||
|
||||
line["text"] = new_text
|
||||
src = line.get("source")
|
||||
if isinstance(src, str) and isinstance(new_text, str):
|
||||
repaired = wolf_codes.rebuild_text_preserving_source_codes(src, new_text)
|
||||
if repaired != new_text:
|
||||
line["text"] = repaired
|
||||
path.write_text(
|
||||
json.dumps(doc, ensure_ascii=False, indent=4) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
issue.text = str(line.get("text") or "")
|
||||
return True, ""
|
||||
|
||||
|
||||
def _precheck_strings_file(
|
||||
json_name: str,
|
||||
entry: dict,
|
||||
inject_src: Path,
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
*,
|
||||
allow_code_drift: bool,
|
||||
en_punct: bool,
|
||||
) -> FilePrecheck:
|
||||
result = FilePrecheck(json_name=json_name)
|
||||
orig = orig_base_for(entry, data_dir, originals_dir)
|
||||
if not orig.exists():
|
||||
result.error = f"no pristine original at {orig} (re-run Step 0 extract)"
|
||||
return result
|
||||
if entry.get("kind") == "db" and not db_dat_sibling(orig).is_file():
|
||||
result.error = "missing database .dat pair in originals (re-run Step 0 extract)"
|
||||
return result
|
||||
|
||||
doc = _load_doc(inject_src)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="wolf-precheck-") as tmp:
|
||||
out = Path(tmp) / Path(entry["base"]).name
|
||||
res = wolfdawn.strings_inject(
|
||||
str(inject_src),
|
||||
str(orig),
|
||||
str(out),
|
||||
allow_code_drift=allow_code_drift,
|
||||
en_punct=en_punct,
|
||||
dry_run=True,
|
||||
log_fn=None,
|
||||
)
|
||||
result.detail = ((res.stdout or "") + "\n" + (res.stderr or "")).strip()
|
||||
applied, drifted = wolfdawn.parse_strings_inject_counts(res.stdout, res.stderr)
|
||||
result.would_apply = applied
|
||||
result.drifted = drifted
|
||||
result.safety_count = wolfdawn.parse_strings_inject_safety_count(res.stdout, res.stderr)
|
||||
|
||||
cli_err = wolfdawn.parse_inject_cli_error(res.stdout, res.stderr)
|
||||
if cli_err and applied is None and not result.issues:
|
||||
result.error = cli_err
|
||||
return result
|
||||
|
||||
if doc:
|
||||
for locator, kind, full in wolfdawn.parse_strings_inject_safety_lines(
|
||||
res.stdout, res.stderr
|
||||
):
|
||||
line, hit_id = resolve_locator_line(doc, locator)
|
||||
hit_id = dict(hit_id)
|
||||
hit_id["json_file"] = json_name
|
||||
if not hit_id.get("sheet_name"):
|
||||
hit_id["sheet_name"] = json_name
|
||||
src = str(line.get("source") or "") if isinstance(line, dict) else ""
|
||||
txt = str(line.get("text") or "") if isinstance(line, dict) else ""
|
||||
result.issues.append(
|
||||
InjectIssue(
|
||||
json_file=json_name,
|
||||
kind=kind,
|
||||
locator=locator,
|
||||
message=full,
|
||||
source=src,
|
||||
text=txt,
|
||||
hit_id=hit_id,
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def precheck_selected(
|
||||
selected: list[str],
|
||||
*,
|
||||
manifest_entries: list[dict],
|
||||
data_dir: Path,
|
||||
originals_dir: Path,
|
||||
translated_dir: Path,
|
||||
allow_code_drift: bool = False,
|
||||
en_punct: bool = False,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> PrecheckReport:
|
||||
"""Dry-run inject for *selected* files and collect safety-guard issues."""
|
||||
emit = log_fn or (lambda _msg: None)
|
||||
report = PrecheckReport()
|
||||
by_json = {e["json"]: e for e in manifest_entries if e.get("json")}
|
||||
ordered = sorted(set(selected), key=lambda n: (n != NAMES_JSON, n.lower()))
|
||||
|
||||
for json_name in ordered:
|
||||
if json_name == NAMES_JSON:
|
||||
# names-inject does not emit the same per-line safety locators.
|
||||
report.files.append(FilePrecheck(json_name=NAMES_JSON))
|
||||
emit(f"Precheck {NAMES_JSON}: skipped (use names-inject dry-run in log if needed)")
|
||||
continue
|
||||
|
||||
entry = by_json.get(json_name)
|
||||
if not entry:
|
||||
report.files.append(
|
||||
FilePrecheck(
|
||||
json_name,
|
||||
error="not listed in manifest (re-run Step 0 extract)",
|
||||
)
|
||||
)
|
||||
continue
|
||||
src = translated_dir / json_name
|
||||
if not src.is_file():
|
||||
report.files.append(
|
||||
FilePrecheck(json_name, error=f"not found in {translated_dir.name}/")
|
||||
)
|
||||
continue
|
||||
|
||||
emit(f"Precheck {json_name}…")
|
||||
inject_src = repair_inject_json(src)
|
||||
fp = _precheck_strings_file(
|
||||
json_name,
|
||||
entry,
|
||||
inject_src,
|
||||
data_dir,
|
||||
originals_dir,
|
||||
allow_code_drift=allow_code_drift,
|
||||
en_punct=en_punct,
|
||||
)
|
||||
if fp.error:
|
||||
emit(f" ✗ {json_name}: {fp.error}")
|
||||
else:
|
||||
parts = []
|
||||
if fp.would_apply is not None:
|
||||
parts.append(f"would apply {fp.would_apply}")
|
||||
if fp.issues:
|
||||
parts.append(f"{len(fp.issues)} safety skip(s)")
|
||||
elif fp.safety_count:
|
||||
parts.append(f"{fp.safety_count} safety skip(s)")
|
||||
else:
|
||||
parts.append("no safety skips")
|
||||
if fp.drifted:
|
||||
parts.append(f"{fp.drifted} drifted")
|
||||
emit(f" · {json_name}: " + ", ".join(parts))
|
||||
report.files.append(fp)
|
||||
return report
|
||||
|
||||
|
||||
def format_precheck_summary(report: PrecheckReport) -> str:
|
||||
"""One-line status for the Step 6 precheck label."""
|
||||
safety = len(report.safety_issues)
|
||||
errors = sum(1 for f in report.files if f.error)
|
||||
parts = []
|
||||
if safety:
|
||||
parts.append(f"{safety} safety skip(s)")
|
||||
if errors:
|
||||
parts.append(f"{errors} file error(s)")
|
||||
if not parts:
|
||||
return "Precheck clean - no safety-guard skips."
|
||||
return "Precheck: " + ", ".join(parts)
|
||||
|
||||
|
||||
def issues_for_ui(report: PrecheckReport) -> list[InjectIssue]:
|
||||
"""Safety-guard issues only."""
|
||||
return list(report.safety_issues)
|
||||
|
|
@ -22,6 +22,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Union
|
||||
|
||||
|
|
@ -376,3 +377,234 @@ def upsert_name_wrap_role(
|
|||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue