diff --git a/data/skills/project_setup.md b/data/skills/project_setup.md index b5c9e73..6d9937d 100644 --- a/data/skills/project_setup.md +++ b/data/skills/project_setup.md @@ -123,9 +123,93 @@ Do **not** include: -### Wolf file strategy (stub) +### Wolf file strategy -When analysing a WolfDawn / WOLF project later: use `wolf_json/` extracts (`names.json`, DB sheets, map/common scenes). Prefer foundation DB + narrative sheets; grep large map dumps. Same four-block ownership rules apply. +WolfDawn extractions live under `files/` as JSON lists of `{source, text}` entries. +Analyse `source` only. + +**DB / system (read in full first):** +- `DataBase.project.json`, `CDataBase.project.json`, `SysDatabase.project.json` - richest small source of character/actor names, classes, factions, lore titles +- `CommonEvent.dat.json` - common events (dialogue + system text) +- `Game.dat.json` - game/system strings (title, terms) +- `Evtext.json` - external event text when present + +**Maps (grep / sample, do not read huge files sequentially):** +- `.mps.json` - per-map events; main story dialogue; often very large +- Prefer grep of `source` for speaker patterns and recurring proper nouns +- Scan lowest-numbered maps first - early maps usually carry the most story + +**Exclude from glossary analysis:** +- `names.json` - item / skill / enemy value names (translated separately in Step 3 Names; do **not** list them in the glossary) + +--- attach the extracted JSON in files/ here before continuing --- + +### Speakers analysis (for `speakers` block) + +WolfDawn already tags who speaks. High-confidence nameplates (`literal_line1`) are always reshaped - nothing to decide. + +The only flag is whether **low-confidence** first-line guesses (`speaker_src = literal_line1_lowconf`) are real speaker names for this game: +- short first line with **no** preceding face window +- might be a nameplate, or the start of dialogue / narration + +Inspect a sample of those entries in maps / CommonEvent: +- `ENABLE` if low-confidence first lines are overwhelmingly real speaker names +- `SKIP` if many are dialogue or narration (reshaping would mislabel lines) + +Do **not** emit RPG Maker flags (`INLINE401SPEAKERS`, `FIRSTLINESPEAKERS`, `FACENAME101`). + +**Wolf `speakers` schema (use this instead of the shared RPG Maker flag list):** + +``` +Patterns detected: +- ... + +Flag decisions: +LOWCONF_FIRSTLINE : ENABLE|SKIP - + +Examples: +- ... +``` + +### Glossary rules (for `glossary` block) + +- Separator: plain hyphen-minus `-` only (never em/en dash). +- Descriptions entirely in English; refer to other characters by English name. +- Commit to one spelling - never `Sylfia / Sylphia`. +- Characters: gender, role, speech register, personality; note player-chosen names (variable / input). +- Worldbuilding: factions, lore locations (mentioned in dialogue but not map labels), unique systems/titles. +- Exclude: skill / item / weapon / armour names from `names.json`, generic RPG words, unnamed NPCs. + +### Quirks rules (for `translation_quirks` block) + +Find translation-only quirks, for example: +- Battle log / system messages with a fixed person or register +- Global dialect (archaic narration, cute speech markers game-wide) +- Recurring item/skill description style +- Unusual honorific habits + +Exclude: wrap geometry, inject layout, `names.json` harvest, speaker LOWCONF checkbox, character name lists. + +Output as short imperative bullets suitable to paste into `skills/quirks.md`. + +### Game skill rules (for `game_skill` block) + +Produce the per-game translation skill saved at `skills/game.md`. +DazedMTLTool **merges this file into the translation system prompt** (before quirks). + +**Translation Frame only** (one compact line per field; evidence-based): +- `世界観 (Theme / setting)` - genre, world type, core atmosphere +- `時代感 (Era / technology level)` - medieval / modern / sci-fi / historical / etc. +- `文体方針 (Register policy)` - overall English style +- `固有名詞方針 (Naming policy)` - invented names, titles, ranks, honorifics (high-level only) +- `神話・伝承 (Myth / folklore basis)` - **omit unless** evidence supports a specific tradition + +Do **not** include voice-rules pointers, tool-boundary essays, file inventories, per-character register (glossary), or quirks bullets. + +**Paths:** +- Game skill (API): ``skills/game.md`` +- Quirks (API): ``skills/quirks.md`` +- Optional custom API overlays: other ``skills/*.md`` except those two diff --git a/gui/setup_skills_editors.py b/gui/setup_skills_editors.py new file mode 100644 index 0000000..07e4e2e --- /dev/null +++ b/gui/setup_skills_editors.py @@ -0,0 +1,587 @@ +"""Shared Vocab / Quirks / Game skills editors for Workflow Setup steps.""" + +from __future__ import annotations + +from collections.abc import Callable + +from PyQt5.QtCore import Qt, QSize +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QHBoxLayout, + QInputDialog, + QLabel, + QMessageBox, + QPushButton, + QStackedWidget, + QTabBar, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from util.skills import ( + custom_skill_path_for_game, + game_skill_path_for_game, + list_custom_skill_paths, + migrate_game_skill_text, + quirks_path_for_game, + sanitize_custom_skill_stem, +) +from util.vocab import read_game_vocab, write_game_vocab + + +class _PlainPasteTextEdit(QTextEdit): + """QTextEdit that always pastes as plain text.""" + + def insertFromMimeData(self, source): # noqa: N802 + self.insertPlainText(source.text()) + + +def _make_btn(text: str, color: str = "#007acc") -> QPushButton: + """Match workflow_tab button styling (flat dark + coloured outline).""" + btn = QPushButton() + try: + c = color.lstrip("#") + if len(c) == 3: + c = c[0] * 2 + c[1] * 2 + c[2] * 2 + r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16) + is_flat = max(r, g, b) < 115 + except Exception: + r = g = b = 0 + is_flat = False + _PAD = "padding:6px 14px;" + _icon_color = "#cccccc" + if is_flat: + btn.setStyleSheet( + f"QPushButton{{background-color:#2d2d30;color:#cccccc;" + f"border:1px solid #555555;{_PAD}" + f"border-radius:4px;font-size:12px;font-weight:bold;" + f"font-family:'Segoe UI','Segoe UI Emoji','Apple Color Emoji',sans-serif;}}" + f"QPushButton:hover{{background-color:#3e3e42;}}" + f"QPushButton:pressed{{background-color:#007acc;color:white;}}" + f"QPushButton:disabled{{background-color:#404040;color:#666666;border-color:#444444;}}" + ) + else: + rt = min(255, r + 80) + gt = min(255, g + 80) + bt = min(255, b + 80) + text_color = f"#{rt:02x}{gt:02x}{bt:02x}" + _icon_color = text_color + base = 0x2d + rh = min(255, int(base + (r - base) * 0.18)) + gh = min(255, int(base + (g - base) * 0.18)) + bh = min(255, int(base + (b - base) * 0.18)) + hover_bg = f"#{rh:02x}{gh:02x}{bh:02x}" + rb = min(255, r + 35) + gb = min(255, g + 35) + bb = min(255, b + 35) + hover_accent = f"#{rb:02x}{gb:02x}{bb:02x}" + btn.setStyleSheet( + f"QPushButton{{background-color:#2d2d30;color:{text_color};" + f"border:1px solid {color};{_PAD}" + f"border-radius:4px;font-size:12px;font-weight:bold;" + f"font-family:'Segoe UI','Segoe UI Emoji','Apple Color Emoji',sans-serif;}}" + f"QPushButton:hover{{background-color:{hover_bg};border-color:{hover_accent};" + f"color:{hover_accent};}}" + f"QPushButton:pressed{{background-color:#1a1a1a;}}" + f"QPushButton:disabled{{background-color:#2d2d30;color:#555555;border-color:#444444;}}" + ) + from gui import qt_icons + + qt_icons.apply_button_icon(btn, text, color=_icon_color) + if not btn.icon().isNull(): + btn.setIconSize(QSize(16, 16)) + return btn + + +class SetupSkillsEditors(QWidget): + """Tabbed Vocab / Quirks / Game skills editors for Workflow Setup. + + Parameters + ---------- + game_root_fn: + Callable returning the current game root path (may be empty). + log_fn: + Callable used for status / error messages. + """ + + def __init__( + self, + parent: QWidget | None = None, + *, + game_root_fn: Callable[[], str], + log_fn: Callable[[str], None], + ): + super().__init__(parent) + self._game_root_fn = game_root_fn + self._log = log_fn + self._custom_skill_editors: dict[str, QTextEdit] = {} + self.vocab_editor: QTextEdit | None = None + self.quirks_editor: QTextEdit | None = None + self.game_skill_editor: QTextEdit | None = None + self._build_ui() + + def _build_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + editors = QTabWidget() + editors.setObjectName("setupEditors") + editors.setDocumentMode(False) + tab_bar = editors.tabBar() + tab_bar.setExpanding(False) + tab_bar.setMinimumHeight(34) + tab_bar.setStyleSheet( + "QTabBar::tab{background:#333337;color:#c0c0c0;min-width:90px;min-height:28px;" + "padding:6px 16px;border:1px solid #3c3c3c;border-bottom:none;" + "margin-right:3px;font-size:12px;font-weight:bold;}" + "QTabBar::tab:selected{background:#1e1e1e;color:#4ec9b0;" + "border-top:2px solid #4ec9b0;}" + "QTabBar::tab:hover{color:#ffffff;}" + ) + editors.setStyleSheet( + "QTabWidget::pane{border:1px solid #3c3c3c;background:#1e1e1e;}" + ) + + editors.addTab( + self._editor_page( + "data/vocab.txt (game section)", + "Paste the glossary code block. Format: Japanese (English) - notes", + self._save_vocab, + self._reload_vocab, + "vocab_editor", + ), + "Vocab", + ) + editors.addTab( + self._editor_page( + "/skills/quirks.md", + "Paste the translation_quirks block. Merged onto the system prompt at translate time.", + self._save_quirks, + self._reload_quirks, + "quirks_editor", + ), + "Quirks", + ) + + game_skills_page = QWidget() + gs_layout = QVBoxLayout(game_skills_page) + gs_layout.setContentsMargins(0, 0, 0, 0) + gs_layout.setSpacing(0) + + self._game_skills_bar = QTabBar() + self._game_skills_bar.setDocumentMode(True) + self._game_skills_bar.setExpanding(False) + self._game_skills_bar.setDrawBase(False) + self._game_skills_bar.setMinimumHeight(30) + self._game_skills_bar.setStyleSheet( + "QTabBar::tab{background:#2d2d30;color:#9d9d9d;padding:6px 12px;" + "border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}" + "QTabBar::tab:selected{background:#1e1e1e;color:#e0e0e0;}" + "QTabBar::tab:hover{color:#ffffff;}" + ) + self._game_skills_stack = QStackedWidget() + self._game_skills_stack.setStyleSheet("background:#1e1e1e;") + self._game_skills_bar.currentChanged.connect( + self._game_skills_stack.setCurrentIndex + ) + + strip = QWidget() + strip.setStyleSheet("background-color:#252526;") + strip_l = QHBoxLayout(strip) + strip_l.setContentsMargins(8, 6, 10, 0) + strip_l.setSpacing(6) + strip_l.addWidget(self._game_skills_bar, 0, Qt.AlignBottom) + + add_btn = QPushButton("+ Add custom") + add_btn.setCursor(Qt.PointingHandCursor) + add_btn.setFixedHeight(30) + add_btn.setToolTip( + "Create a custom skills/*.md overlay.\n" + "Merged into the translation system prompt - can hurt quality." + ) + add_btn.setStyleSheet( + "QPushButton{background-color:#2d2d30;color:#4ec9b0;" + "border:1px solid #3c3c3c;border-bottom:none;" + "border-top-left-radius:3px;border-top-right-radius:3px;" + "padding:0 12px;font-size:12px;font-weight:bold;}" + "QPushButton:hover{background-color:#3a3a3a;color:#7ee0c8;" + "border-color:#4ec9b0;}" + "QPushButton:pressed{background-color:#1e1e1e;}" + ) + add_btn.clicked.connect(self._add_custom_skill) + strip_l.addWidget(add_btn, 0, Qt.AlignBottom) + strip_l.addStretch(1) + gs_layout.addWidget(strip) + gs_layout.addWidget(self._game_skills_stack, 1) + + self._add_game_skill_page( + "game", + self._editor_page( + "/skills/game.md", + "Paste the game_skill Translation Frame. Merged into the translation " + "system prompt (before quirks) when this game folder is selected.", + self._save_game_skill, + self._reload_game_skill, + "game_skill_editor", + ), + ) + editors.addTab(game_skills_page, "Game skills") + root.addWidget(editors, 1) + + def _editor_page( + self, + path_hint: str, + tip: str, + save_slot, + reload_slot, + attr: str, + ) -> QWidget: + page = QWidget() + vl = QVBoxLayout(page) + vl.setContentsMargins(8, 8, 8, 8) + vl.setSpacing(6) + tip_lbl = QLabel(tip) + tip_lbl.setWordWrap(True) + tip_lbl.setStyleSheet("color:#9d9d9d;font-size:12px;") + vl.addWidget(tip_lbl) + path_lbl = QLabel(path_hint) + path_lbl.setStyleSheet( + "color:#569cd6;font-size:11px;font-family:Consolas,monospace;" + ) + vl.addWidget(path_lbl) + ed = _PlainPasteTextEdit() + ed.setMinimumHeight(160) + ed.setFont(QFont("Consolas", 9)) + ed.setStyleSheet( + "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;" + "border:none;padding:8px;" + "selection-background-color:#264f78;}" + ) + setattr(self, attr, ed) + vl.addWidget(ed, 1) + row = QHBoxLayout() + row.setSpacing(8) + save_btn = _make_btn("💾 Save", "#3a7a3a") + save_btn.setFixedWidth(110) + save_btn.clicked.connect(save_slot) + row.addWidget(save_btn) + reload_btn = _make_btn("↺ Reload", "#555") + reload_btn.setFixedWidth(110) + reload_btn.clicked.connect(reload_slot) + row.addWidget(reload_btn) + row.addStretch() + vl.addLayout(row) + return page + + def reload_all(self) -> None: + """Reload vocab, quirks, game skill, and custom skill tabs from disk.""" + self._reload_vocab() + self._reload_quirks() + self._reload_game_skill() + self._reload_custom_skills() + + def _game_root(self) -> str: + return (self._game_root_fn() or "").strip() + + def _game_root_or_warn(self) -> str | None: + root = self._game_root() + if not root: + self._log("⚠ Select a game folder in Step 0 first.") + return None + return root + + def _reload_vocab(self) -> None: + if self.vocab_editor is None: + return + try: + self.vocab_editor.setPlainText(read_game_vocab()) + except Exception as exc: + self._log(f"❌ Could not load vocab.txt: {exc}") + + def _save_vocab(self) -> None: + if self.vocab_editor is None: + return + try: + write_game_vocab(self.vocab_editor.toPlainText()) + self._log("✅ vocab.txt saved (base terms from vocab_base.txt appended).") + except Exception as exc: + self._log(f"❌ Could not save vocab.txt: {exc}") + + def _reload_quirks(self) -> None: + if self.quirks_editor is None: + return + path = quirks_path_for_game(self._game_root()) + if not path or not path.is_file(): + self.quirks_editor.setPlainText("") + return + try: + self.quirks_editor.setPlainText(path.read_text(encoding="utf-8")) + except Exception as exc: + self._log(f"❌ Could not load skills/quirks.md: {exc}") + + def _save_quirks(self) -> None: + root = self._game_root_or_warn() + if not root or self.quirks_editor is None: + return + path = quirks_path_for_game(root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + self.quirks_editor.toPlainText().rstrip() + "\n", encoding="utf-8" + ) + self._log(f"✅ Saved {path}") + except Exception as exc: + self._log(f"❌ Could not save skills/quirks.md: {exc}") + + def _reload_game_skill(self) -> None: + if self.game_skill_editor is None: + return + path = game_skill_path_for_game(self._game_root()) + if not path or not path.is_file(): + self.game_skill_editor.setPlainText("") + return + try: + raw = path.read_text(encoding="utf-8") + fixed = migrate_game_skill_text(raw) + self.game_skill_editor.setPlainText(fixed) + if fixed != raw: + path.write_text(fixed.rstrip() + "\n", encoding="utf-8") + self._log( + f"↺ Updated legacy sections in {path.name} for API use" + ) + except Exception as exc: + self._log(f"❌ Could not load game skill: {exc}") + + def _save_game_skill(self) -> None: + root = self._game_root_or_warn() + if not root or self.game_skill_editor is None: + return + path = game_skill_path_for_game(root) + try: + path.parent.mkdir(parents=True, exist_ok=True) + text = migrate_game_skill_text(self.game_skill_editor.toPlainText()) + self.game_skill_editor.setPlainText(text) + path.write_text(text.rstrip() + "\n", encoding="utf-8") + self._log(f"✅ Saved {path}") + except Exception as exc: + self._log(f"❌ Could not save game skill: {exc}") + + def _add_game_skill_page(self, title: str, page: QWidget) -> int: + idx = self._game_skills_stack.addWidget(page) + self._game_skills_bar.addTab(title) + return idx + + def _select_game_skill_tab(self, title: str) -> None: + for i in range(self._game_skills_bar.count()): + if self._game_skills_bar.tabText(i) == title: + self._game_skills_bar.setCurrentIndex(i) + return + + def _custom_skill_editor_page(self, stem: str) -> QWidget: + page = QWidget() + vl = QVBoxLayout(page) + vl.setContentsMargins(8, 8, 8, 8) + vl.setSpacing(6) + + tip = QLabel( + "Custom skill - merged into the translation system prompt. " + "Extra or conflicting rules can hurt quality." + ) + tip.setWordWrap(True) + tip.setStyleSheet("color:#c9a227;font-size:12px;") + vl.addWidget(tip) + + path_lbl = QLabel(f"/skills/{stem}.md") + path_lbl.setStyleSheet( + "color:#569cd6;font-size:11px;font-family:Consolas,monospace;" + ) + vl.addWidget(path_lbl) + + ed = _PlainPasteTextEdit() + ed.setMinimumHeight(160) + ed.setFont(QFont("Consolas", 9)) + ed.setStyleSheet( + "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;" + "border:none;padding:8px;" + "selection-background-color:#264f78;}" + ) + self._custom_skill_editors[stem] = ed + vl.addWidget(ed, 1) + + row = QHBoxLayout() + row.setSpacing(8) + save_btn = _make_btn("💾 Save", "#3a7a3a") + save_btn.setFixedWidth(110) + save_btn.clicked.connect(lambda _=False, s=stem: self._save_custom_skill(s)) + row.addWidget(save_btn) + + reload_btn = _make_btn("↺ Reload", "#555") + reload_btn.setFixedWidth(110) + reload_btn.clicked.connect( + lambda _=False, s=stem: self._reload_one_custom_skill(s) + ) + row.addWidget(reload_btn) + + delete_btn = _make_btn("🗑 Delete", "#8b0000") + delete_btn.setFixedWidth(110) + delete_btn.setToolTip("Delete this custom skill file from the game folder") + delete_btn.clicked.connect(lambda _=False, s=stem: self._delete_custom_skill(s)) + row.addWidget(delete_btn) + row.addStretch() + vl.addLayout(row) + return page + + def _reload_custom_skills(self) -> None: + bar = getattr(self, "_game_skills_bar", None) + stack = getattr(self, "_game_skills_stack", None) + if bar is None or stack is None: + return + + while bar.count() > 1: + bar.removeTab(1) + while stack.count() > 1: + w = stack.widget(1) + stack.removeWidget(w) + if w is not None: + w.deleteLater() + self._custom_skill_editors = {} + + for path in list_custom_skill_paths(self._game_root()): + stem = path.stem + page = self._custom_skill_editor_page(stem) + self._add_game_skill_page(stem, page) + try: + self._custom_skill_editors[stem].setPlainText( + path.read_text(encoding="utf-8") + ) + except Exception as exc: + self._log(f"❌ Could not load skills/{stem}.md: {exc}") + + def _reload_one_custom_skill(self, stem: str) -> None: + path = custom_skill_path_for_game(self._game_root(), stem) + ed = self._custom_skill_editors.get(stem) + if ed is None: + return + if not path or not path.is_file(): + ed.setPlainText("") + return + try: + ed.setPlainText(path.read_text(encoding="utf-8")) + except Exception as exc: + self._log(f"❌ Could not load skills/{stem}.md: {exc}") + + def _save_custom_skill(self, stem: str) -> None: + root = self._game_root_or_warn() + if not root: + return + path = custom_skill_path_for_game(root, stem) + ed = self._custom_skill_editors.get(stem) + if path is None or ed is None: + self._log("❌ Invalid custom skill.") + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(ed.toPlainText().rstrip() + "\n", encoding="utf-8") + self._log(f"✅ Saved {path}") + except Exception as exc: + self._log(f"❌ Could not save skills/{stem}.md: {exc}") + + def _add_custom_skill(self) -> None: + root = self._game_root_or_warn() + if not root: + return + + warn = QMessageBox(self) + warn.setIcon(QMessageBox.Warning) + warn.setWindowTitle("Custom skill - quality warning") + warn.setTextFormat(Qt.RichText) + warn.setText( + "Custom skills are merged into the translation system prompt.

" + "Extra or poorly written skills can distract the model and hurt " + "translation quality (conflicting rules, prompt bloat, diluted quirks).

" + "Prefer quirks.md for voice rules and game.md " + "for the Translation Frame. Add a custom skill only if you need a rare, tightly scoped " + "overlay - at your own risk." + ) + warn.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok) + warn.button(QMessageBox.Ok).setText("I understand - continue") + warn.setStyleSheet( + "QMessageBox{background-color:#252526;}" + "QLabel{color:#d4d4d4;font-size:13px;min-width:420px;}" + ) + if warn.exec_() != QMessageBox.Ok: + return + + name, ok = QInputDialog.getText( + self, + "New custom skill", + "Skill file name (letters, numbers, ._- ; saved as /skills/.md):", + ) + if not ok: + return + stem = sanitize_custom_skill_stem(name) + if not stem: + QMessageBox.warning( + self, + "Invalid name", + "Use a short name like battle-log or honorifics_extra.\n" + "Reserved: quirks, game, translation.", + ) + return + if stem in self._custom_skill_editors: + QMessageBox.information( + self, "Already exists", f"Custom skill '{stem}' is already open." + ) + self._select_game_skill_tab(stem) + return + + path = custom_skill_path_for_game(root, stem) + if path is None: + return + if path.is_file(): + self._reload_custom_skills() + self._select_game_skill_tab(stem) + return + + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + f"# {stem}\n\n" + "# Keep this short and specific. Conflicting rules hurt quality.\n", + encoding="utf-8", + ) + except Exception as exc: + self._log(f"❌ Could not create skills/{stem}.md: {exc}") + return + + page = self._custom_skill_editor_page(stem) + idx = self._add_game_skill_page(stem, page) + self._game_skills_bar.setCurrentIndex(idx) + self._reload_one_custom_skill(stem) + self._log(f"✅ Created custom skill {path}") + + def _delete_custom_skill(self, stem: str) -> None: + root = self._game_root_or_warn() + if not root: + return + path = custom_skill_path_for_game(root, stem) + reply = QMessageBox.question( + self, + "Delete custom skill", + f"Delete skills/{stem}.md from the game folder?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + try: + if path and path.is_file(): + path.unlink() + self._log(f"🗑 Deleted {path}") + except Exception as exc: + self._log(f"❌ Could not delete skills/{stem}.md: {exc}") + return + self._reload_custom_skills() diff --git a/gui/wolf_workflow_tab.py b/gui/wolf_workflow_tab.py index e137a8f..73e15ad 100644 --- a/gui/wolf_workflow_tab.py +++ b/gui/wolf_workflow_tab.py @@ -6,8 +6,8 @@ Mirrors the RPGMaker WorkflowTab, driven by the vendored WolfDawn ``wolf`` CLI Step 0 Project - select game folder; import wolf_json/ into files/ (like RPGMaker) Step 1 Pre-process - optional dazedformat + gameupdate/ copy before translating - Step 2 Glossary - build vocab.txt (characters / worldbuilding terms) before - translating so the AI keeps names and voice consistent + Step 2 Setup - Project Setup skill + vocab / quirks / game skills editors + (build glossary and API overlays before translating) Step 3 Names - translate names.json (Phase 0). WolfDawn safe entries are translated per-name; refs and verify names are skipped. Harvests short name terms into vocab.txt. @@ -99,7 +99,8 @@ from util.wolfdawn import db_classify as wolf_db from util.wolfdawn import wrap_search as wolf_ws import util.dazedwrap as dazedwrap -from util.paths import PROJECT_ROOT +from gui.setup_skills_editors import SetupSkillsEditors +from util.paths import PROJECT_ROOT, VOCAB_PATH from util.project_scanner import ( detect_wolf_layout, find_wolf_text_archives, @@ -111,7 +112,7 @@ from util.project_scanner import ( wolf_repair_nested_data_dir, wolf_unpack_out_dir, ) -from util.vocab import read_game_vocab, write_game_vocab +from util.skills import load_project_setup # Workflow-level label for the non-batch (live) translation path. The Translation # tab's own mode is called "Translate"; _workflow_mode_text() maps to that. @@ -128,93 +129,6 @@ PHASE_NAMES_KINDS = {"names"} PHASE_DB_KINDS = {"db"} PHASE_MAPS_EVENTS_KINDS = {"map", "common", "gamedat", "txt", "txt-dir"} -# Glossary-discovery prompt for Copilot / Cursor, tailored to WolfDawn's extracted -# JSON (source/text pairs staged in files/). Produces the same two-section format -# the shared vocab.txt expects, so the AI output pastes straight into the editor. -_WOLF_GLOSSARY_PROMPT = ( - "You are an expert Japanese WOLF RPG Editor game analyst building a translation glossary.\n" - "\n" - "\n" - "Extract named characters and lore-specific worldbuilding terms from this game's extracted " - "text. Produce a structured glossary in the exact format specified below. It will be loaded " - "directly into a translation tool, so strict format compliance is required.\n" - "\n" - "\n" - "--- attach the extracted JSON in files/ here before continuing ---\n" - "\n" - "\n" - "The files/ folder holds WolfDawn extractions as JSON. Each file is a list of entries with a\n" - "'source' field (the original Japanese) and a 'text' field (initially empty/identical).\n" - "Analyse the 'source' fields only. The files are:\n" - " - DataBase.project.json, CDataBase.project.json, SysDatabase.project.json — databases:\n" - " richest, small source of character/actor names, classes, factions, and lore titles.\n" - " - CommonEvent.dat.json — common events: dialogue and system text.\n" - " - Game.dat.json — game/system strings (title, terms).\n" - " - .mps.json — per-map events: the main story dialogue (can be large).\n" - " - Evtext.json — external event text, when present.\n" - " - names.json — item/skill/enemy value names (translated separately in Step 3; do NOT list them).\n" - "\n" - "\n" - "\n" - "Map files can be extremely large. Do NOT read them sequentially — you will hit context " - "limits. Instead:\n" - "1. Read the DataBase.project.json files IN FULL first — small and the best source of names.\n" - "2. For large map files, SEARCH (grep) the 'source' fields instead of reading sequentially. " - "Prioritise dialogue because it is the best evidence for character voice:\n" - " - Speaker patterns such as 【Name】, [Name], Name「…」, Name:, and at the start of a line\n" - " - Katakana clusters or kanji compound proper nouns that recur across dialogue\n" - " Scan the lowest-numbered maps first — early maps have the most story content.\n" - "3. Stop once you stop finding new names or terms. Do not pad the output.\n" - "\n" - "\n" - "\n" - "Apply to both sections:\n" - "- Separator: use a plain hyphen-minus (-). Never use an em dash or en dash. " - "The translation tool only recognises the plain hyphen.\n" - "- Descriptions must be entirely in English. Refer to other characters by English name only.\n" - "- Never give two spelling options (e.g. 'Sylfia / Sylphia' is wrong). Commit to one translation.\n" - "\n" - "# Game Characters — rules:\n" - "- Discover named characters from the databases and dialogue. Skip unnamed NPCs, generic " - "enemies, and narration-only entries.\n" - "- For each character include: gender, role, speech register, and personality. Note if the " - "name is player-chosen (a hero/actor whose name comes from a variable or input prompt).\n" - "\n" - "# Worldbuilding Terms — rules:\n" - "- Include: faction/organisation names, locations mentioned in dialogue but not on maps, " - "unique magic systems, lore titles, recurring in-universe concepts.\n" - "- Exclude: skill names, item names, weapon/armour names (translated via names.json in " - "Step 3). Skip generic RPG words. Do not repeat character names here.\n" - "\n" - "\n" - "\n" - "Return the ENTIRE glossary inside ONE fenced code block (a ``` block), with nothing before " - "or after it, so it can be copied in a single click and pasted straight into the tool.\n" - "Inside the code block, output EXACTLY two sections with these headers. Do not add any " - "preamble, explanation, or text outside the entries.\n" - "\n" - "# Game Characters\n" - "# Worldbuilding Terms\n" - "\n" - "Entry format: Japanese (English) - description\n" - "\n" - "\n" - "# Game Characters\n" - "シロ (Shiro) - Female; protagonist; player-named; speaks in a flustered, cute register " - "with feminine speech markers\n" - "ゼクス (Zex) - Male; antagonist; cold and commanding; uses an archaic formal register\n" - "カナエ (Kanae) - Female; NPC shopkeeper; warm and motherly; ends sentences with わね\n" - "\n" - "# Worldbuilding Terms\n" - "虚無の穴 (Void Rift) - Dimensional tear referenced repeatedly in late-game dialogue; " - "not a named map location\n" - "裁定者 (Arbiter) - Title held by the ruling council; lore-specific rank with no " - "real-world equivalent\n" - "\n" - "\n" -) - - # Speaker-format prompt for a repo-aware AI. WolfDawn already detects and tags who # speaks on each line, so the only decision left is whether its LOW-confidence # first-line guesses are really speaker names for this game. The AI inspects the @@ -620,7 +534,7 @@ class WolfWorkflowTab(QWidget): _tab_defs = [ ("0 Project", self._build_step0), ("1 Pre-process", self._build_step1_preprocess), - ("2 Glossary", self._build_step2_glossary), + ("2 Setup", self._build_step2_setup), ("3 Names", self._build_step3_names), ("4 Database", self._build_step4_database), ("5 Maps/Events", self._build_step5_maps_events), @@ -1142,6 +1056,8 @@ class WolfWorkflowTab(QWidget): self._save_setting("last_game_folder", folder) self._game_root = folder + if hasattr(self, "setup_editors"): + self.setup_editors.reload_all() self.detected_label.setText("Scanning…") self.detected_label.setStyleSheet( "color:#9d9d9d;font-size:13px;padding:4px 8px;" @@ -1767,80 +1683,97 @@ class WolfWorkflowTab(QWidget): self._run_task(task, on_done=on_done) - # ── Step 2: Glossary ─────────────────────────────────────────────────────── + # ── Step 2: Setup ────────────────────────────────────────────────────────── - def _build_step2_glossary(self, layout: QVBoxLayout): - layout.addWidget(_make_section_label("Step 2 · Glossary (build before translating)")) + def _build_step2_setup(self, layout: QVBoxLayout): + layout.addWidget(_make_section_label("Step 2 · Setup (Project Setup + Skills)")) layout.addWidget(self._desc( - "vocab.txt is the project-wide glossary used by every translation batch to keep " - "character names, speech register, honorifics, and lore terms consistent. Build it " - "before translating (Steps 4-5) so the AI already knows every character and term. " - "Copy the prompt below into Cursor or Copilot Chat with the extracted files/ JSON " - "open, let it analyse the game, then paste its output into the editor and save." + "Copy Project Setup into Cursor/Copilot with files/ open. Paste glossary into Vocab, " + "quirks into Quirks, game_skill into Game skills. Speakers advice (LOWCONF_FIRSTLINE) " + "is applied via the Step 5 checkbox - keep that step self-contained." )) - prompt_btn = _make_btn("📋 Copy glossary prompt for Copilot / Cursor", "#5a3a7a") - prompt_btn.clicked.connect(self._copy_wolf_glossary_prompt) - layout.addWidget(prompt_btn) - fmt = QLabel( - "Format: Japanese (English) - description\n" - "Example: シロ (Shiro) - Female; protagonist; flustered, cute register" + actions = QHBoxLayout() + actions.setSpacing(8) + copy_btn = _make_btn("📋 Copy Project Setup", "#555") + copy_btn.setFixedWidth(190) + copy_btn.setFixedHeight(32) + copy_btn.setToolTip( + "Clipboard skill for the game repo IDE. Returns glossary, speakers, " + "translation_quirks, and game_skill code blocks." ) - fmt.setFont(QFont("Consolas", 9)) - fmt.setWordWrap(True) - fmt.setStyleSheet( - "color:#569cd6;background-color:#1a1e2a;border:1px solid #2a3a5a;" - "padding:5px 10px;border-radius:4px;font-size:12px;border-left:3px solid #2a6a9a;" - ) - layout.addWidget(fmt) - - self.vocab_editor = QTextEdit() - self.vocab_editor.setMinimumHeight(120) - self.vocab_editor.setFont(QFont("Consolas", 9)) - self.vocab_editor.setStyleSheet( - "QTextEdit{background-color:#252526;color:#d4d4d4;border:1px solid #3c3c3c;" - "border-radius:4px;padding:8px;selection-background-color:#264f78;}" - ) - self._reload_vocab() - layout.addWidget(self.vocab_editor, 1) - - vrow = QHBoxLayout() - save_btn = _make_btn("💾 Save vocab.txt", "#3a7a3a") - save_btn.clicked.connect(self._save_vocab) - vrow.addWidget(save_btn) - reload_btn = _make_btn("↺ Reload", "#555") - reload_btn.clicked.connect(self._reload_vocab) - vrow.addWidget(reload_btn) - vrow.addStretch() - layout.addLayout(vrow) + copy_btn.clicked.connect(self._copy_project_setup_prompt) + actions.addWidget(copy_btn) + actions.addStretch() + layout.addLayout(actions) note = self._desc( - "You don't need to add item / skill / enemy value names here: Phase 0 translates " - "WolfDawn safe entries from names.json and harvests them into vocab.txt. " - "Foundation DB also merges short labels (map names, titles) after Step 4. " - "Focus this glossary on characters and worldbuilding terms." + "Do not list names.json item/skill/enemy values in the glossary - Phase 0 " + "(Step 3) harvests those. Focus Vocab on characters and worldbuilding." ) layout.addWidget(note) - def _copy_wolf_glossary_prompt(self): - try: - QApplication.clipboard().setText(_WOLF_GLOSSARY_PROMPT) - self._log("📋 Glossary prompt copied. Paste it into Cursor/Copilot with files/ open.") - except Exception as exc: - self._log(f"❌ Could not copy glossary prompt: {exc}") + self.setup_editors = SetupSkillsEditors( + self, + game_root_fn=lambda: self.folder_edit.text().strip() or self._game_root, + log_fn=self._log, + ) + layout.addWidget(self.setup_editors, 1) + self.setup_editors.reload_all() - def _reload_vocab(self): + def _copy_project_setup_prompt(self): + """Copy the Wolf Project Setup skill, optionally prepending known vocab speakers.""" try: - self.vocab_editor.setPlainText(read_game_vocab()) + speakers = self._read_vocab_speakers() + prepend = "" + if speakers: + speaker_lines = "\n".join(f" {orig} ({tl})" for orig, tl in speakers) + prepend = ( + "\n" + "These character names were already listed in vocab.txt.\n" + "For the glossary block '# Game Characters', prefer entries for these names, " + "then cross-check DataBase*.project.json and dialogue for other major names.\n" + "\n" + + speaker_lines + + "\n\n" + ) + prompt = load_project_setup("wolf", prepend=prepend) + QApplication.clipboard().setText(prompt) + self._log("📋 Project Setup skill copied. Paste it into Cursor/Copilot with files/ open.") except Exception as exc: - self._log(f"❌ Could not load vocab.txt: {exc}") + self._log(f"❌ Could not load Project Setup skill: {exc}") - def _save_vocab(self): + def _read_vocab_speakers(self) -> list[tuple[str, str]]: + """Parse '# Speakers' (or '# Game Characters') from vocab.txt as (orig, tl) pairs.""" + vocab_path = VOCAB_PATH + if not vocab_path.exists(): + return [] try: - write_game_vocab(self.vocab_editor.toPlainText()) - self._log("✅ vocab.txt saved (base terms from vocab_base.txt appended).") - except Exception as exc: - self._log(f"❌ Could not save vocab.txt: {exc}") + content = vocab_path.read_text(encoding="utf-8") + except Exception: + return [] + + results = [] + for header in (r"Speakers", r"Game Characters"): + m = re.search( + rf"^[ ]*#\s*{header}\s*$\r?\n(.*?)(?=^[ ]*#|\Z)", + content, + re.MULTILINE | re.DOTALL, + ) + if not m: + continue + for line in m.group(1).splitlines(): + line = line.strip() + if not line: + continue + pm = re.match(r"^(.+?)\s+\((.+?)\)", line) + if pm: + pair = (pm.group(1), pm.group(2)) + if pair not in results: + results.append(pair) + if results: + break + return results # ── Step 4: Database ─────────────────────────────────────────────────────── @@ -3912,7 +3845,7 @@ class WolfWorkflowTab(QWidget): short_names = ( "Project", "Prep", - "Glossary", + "Setup", "Names", "Database", "Maps", diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index 3f05176..938250d 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -21,17 +21,8 @@ import threading from pathlib import Path from util.paths import VOCAB_PATH -from util.skills import ( - custom_skill_path_for_game, - game_skill_path_for_game, - list_custom_skill_paths, - load_project_setup, - migrate_game_skill_text, - quirks_path_for_game, - sanitize_custom_skill_stem, -) +from util.skills import load_project_setup from util.vocab import BASE_SEPARATOR as _SHARED_BASE_SEPARATOR -from util.vocab import read_game_vocab, write_game_vocab import jsbeautifier @@ -46,7 +37,6 @@ from PyQt5.QtWidgets import ( QGridLayout, QGroupBox, QHBoxLayout, - QInputDialog, QLabel, QLineEdit, QListWidget, @@ -57,9 +47,7 @@ from PyQt5.QtWidgets import ( QSizePolicy, QSpinBox, QSplitter, - QStackedWidget, QStyle, - QTabBar, QTabWidget, QTextEdit, QToolButton, @@ -68,6 +56,8 @@ from PyQt5.QtWidgets import ( QAbstractItemView, ) +from gui.setup_skills_editors import SetupSkillsEditors + from gui.translation_tab import ( BATCH_MODE_LABEL, BATCH_MODE_BENEFIT_NOTE, @@ -622,13 +612,6 @@ def _make_icon_btn(icon_text: str, tooltip: str = "") -> QPushButton: # Helpers # ───────────────────────────────────────────────────────────────────────────── -class _PlainPasteTextEdit(QTextEdit): - """QTextEdit that always pastes as plain text to avoid spurious newlines.""" - - def insertFromMimeData(self, source): # noqa: N802 - self.insertPlainText(source.text()) - - # ───────────────────────────────────────────────────────────────────────────── # Main widget # ───────────────────────────────────────────────────────────────────────────── @@ -1716,153 +1699,13 @@ class WorkflowTab(QWidget): layout.addWidget(top) self._populate_speaker_flags() - # ---- Editors (tabbed to save vertical space) ----------------------- - editors = QTabWidget() - editors.setObjectName("setupEditors") - editors.setDocumentMode(False) - tab_bar = editors.tabBar() - tab_bar.setExpanding(False) - tab_bar.setMinimumHeight(34) - tab_bar.setStyleSheet( - "QTabBar::tab{background:#333337;color:#c0c0c0;min-width:90px;min-height:28px;" - "padding:6px 16px;border:1px solid #3c3c3c;border-bottom:none;" - "margin-right:3px;font-size:12px;font-weight:bold;}" - "QTabBar::tab:selected{background:#1e1e1e;color:#4ec9b0;" - "border-top:2px solid #4ec9b0;}" - "QTabBar::tab:hover{color:#ffffff;}" + self.setup_editors = SetupSkillsEditors( + self, + game_root_fn=lambda: self.folder_edit.text().strip(), + log_fn=self._log, ) - editors.setStyleSheet( - "QTabWidget::pane{border:1px solid #3c3c3c;background:#1e1e1e;}" - ) - _ed_ss = ( - "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;" - "border:none;padding:8px;" - "selection-background-color:#264f78;}" - ) - - def _editor_page(path_hint: str, tip: str, save_slot, reload_slot, attr: str): - page = QWidget() - vl = QVBoxLayout(page) - vl.setContentsMargins(8, 8, 8, 8) - vl.setSpacing(6) - tip_lbl = QLabel(tip) - tip_lbl.setWordWrap(True) - tip_lbl.setStyleSheet("color:#9d9d9d;font-size:12px;") - vl.addWidget(tip_lbl) - path_lbl = QLabel(path_hint) - path_lbl.setStyleSheet("color:#569cd6;font-size:11px;font-family:Consolas,monospace;") - vl.addWidget(path_lbl) - ed = _PlainPasteTextEdit() - ed.setMinimumHeight(160) - ed.setFont(QFont("Consolas", 9)) - ed.setStyleSheet(_ed_ss) - setattr(self, attr, ed) - vl.addWidget(ed, 1) - row = QHBoxLayout() - row.setSpacing(8) - save_btn = _make_btn("💾 Save", "#3a7a3a") - save_btn.setFixedWidth(110) - save_btn.clicked.connect(save_slot) - row.addWidget(save_btn) - reload_btn = _make_btn("↺ Reload", "#555") - reload_btn.setFixedWidth(110) - reload_btn.clicked.connect(reload_slot) - row.addWidget(reload_btn) - row.addStretch() - vl.addLayout(row) - return page - - editors.addTab( - _editor_page( - "data/vocab.txt (game section)", - "Paste the glossary code block. Format: Japanese (English) - notes", - self._save_vocab, - self._reload_vocab, - "vocab_editor", - ), - "Vocab", - ) - editors.addTab( - _editor_page( - "/skills/quirks.md", - "Paste the translation_quirks block. Merged onto the system prompt at translate time.", - self._save_quirks, - self._reload_quirks, - "quirks_editor", - ), - "Quirks", - ) - - # Game skills: built-in game.md + optional custom overlays - game_skills_page = QWidget() - gs_layout = QVBoxLayout(game_skills_page) - gs_layout.setContentsMargins(0, 0, 0, 0) - gs_layout.setSpacing(0) - - self._custom_skill_editors: dict[str, QTextEdit] = {} - self._game_skills_bar = QTabBar() - self._game_skills_bar.setDocumentMode(True) - self._game_skills_bar.setExpanding(False) - self._game_skills_bar.setDrawBase(False) - self._game_skills_bar.setMinimumHeight(30) - self._game_skills_bar.setStyleSheet( - "QTabBar::tab{background:#2d2d30;color:#9d9d9d;padding:6px 12px;" - "border:1px solid #3c3c3c;border-bottom:none;margin-right:2px;}" - "QTabBar::tab:selected{background:#1e1e1e;color:#e0e0e0;}" - "QTabBar::tab:hover{color:#ffffff;}" - ) - self._game_skills_stack = QStackedWidget() - self._game_skills_stack.setStyleSheet("background:#1e1e1e;") - self._game_skills_bar.currentChanged.connect(self._game_skills_stack.setCurrentIndex) - - strip = QWidget() - strip.setStyleSheet("background-color:#252526;") - strip_l = QHBoxLayout(strip) - strip_l.setContentsMargins(8, 6, 10, 0) - strip_l.setSpacing(6) - strip_l.addWidget(self._game_skills_bar, 0, Qt.AlignBottom) - - add_btn = QPushButton("+ Add custom") - add_btn.setCursor(Qt.PointingHandCursor) - add_btn.setFixedHeight(30) - add_btn.setToolTip( - "Create a custom skills/*.md overlay.\n" - "Merged into the translation system prompt - can hurt quality." - ) - add_btn.setStyleSheet( - "QPushButton{background-color:#2d2d30;color:#4ec9b0;" - "border:1px solid #3c3c3c;border-bottom:none;" - "border-top-left-radius:3px;border-top-right-radius:3px;" - "padding:0 12px;font-size:12px;font-weight:bold;}" - "QPushButton:hover{background-color:#3a3a3a;color:#7ee0c8;" - "border-color:#4ec9b0;}" - "QPushButton:pressed{background-color:#1e1e1e;}" - ) - add_btn.clicked.connect(self._add_custom_skill) - strip_l.addWidget(add_btn, 0, Qt.AlignBottom) - strip_l.addStretch(1) - gs_layout.addWidget(strip) - gs_layout.addWidget(self._game_skills_stack, 1) - - self._add_game_skill_page( - "game", - _editor_page( - "/skills/game.md", - "Paste the game_skill Translation Frame. Merged into the translation " - "system prompt (before quirks) when this game folder is selected.", - self._save_game_skill, - self._reload_game_skill, - "game_skill_editor", - ), - ) - - editors.addTab(game_skills_page, "Game skills") - layout.addWidget(editors, 1) - - self._reload_vocab() - self._reload_quirks() - self._reload_game_skill() - self._reload_custom_skills() + layout.addWidget(self.setup_editors, 1) + self.setup_editors.reload_all() def _run_parse_speakers(self): @@ -3196,9 +3039,8 @@ class WorkflowTab(QWidget): return self._save_setting("last_game_folder", folder) - self._reload_quirks() - self._reload_game_skill() - self._reload_custom_skills() + if hasattr(self, "setup_editors"): + self.setup_editors.reload_all() self.detected_label.setText("Scanning…") self.detected_label.setStyleSheet( "color:#9d9d9d;font-size:13px;padding:4px 8px;" @@ -3556,292 +3398,6 @@ class WorkflowTab(QWidget): except Exception as exc: self._log(f"❌ Could not load Project Setup skill: {exc}") - def _game_root_or_warn(self) -> str | None: - root = self.folder_edit.text().strip() - if not root: - self._log("⚠ Select a game folder in Step 0 first.") - return None - return root - - def _reload_quirks(self): - root = self.folder_edit.text().strip() - path = quirks_path_for_game(root) - if not path or not path.is_file(): - if hasattr(self, "quirks_editor"): - self.quirks_editor.setPlainText("") - return - try: - self.quirks_editor.setPlainText(path.read_text(encoding="utf-8")) - except Exception as exc: - self._log(f"❌ Could not load skills/quirks.md: {exc}") - - def _save_quirks(self): - root = self._game_root_or_warn() - if not root: - return - path = quirks_path_for_game(root) - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(self.quirks_editor.toPlainText().rstrip() + "\n", encoding="utf-8") - self._log(f"✅ Saved {path}") - except Exception as exc: - self._log(f"❌ Could not save skills/quirks.md: {exc}") - - def _reload_game_skill(self): - root = self.folder_edit.text().strip() - path = game_skill_path_for_game(root) - if not path or not path.is_file(): - if hasattr(self, "game_skill_editor"): - self.game_skill_editor.setPlainText("") - return - try: - raw = path.read_text(encoding="utf-8") - fixed = migrate_game_skill_text(raw) - self.game_skill_editor.setPlainText(fixed) - if fixed != raw: - path.write_text(fixed.rstrip() + "\n", encoding="utf-8") - self._log( - f"↺ Updated legacy quirk path in {path.name} → skills/quirks.md" - ) - except Exception as exc: - self._log(f"❌ Could not load game skill: {exc}") - - def _save_game_skill(self): - root = self._game_root_or_warn() - if not root: - return - path = game_skill_path_for_game(root) - try: - path.parent.mkdir(parents=True, exist_ok=True) - text = migrate_game_skill_text(self.game_skill_editor.toPlainText()) - self.game_skill_editor.setPlainText(text) - path.write_text(text.rstrip() + "\n", encoding="utf-8") - self._log(f"✅ Saved {path}") - except Exception as exc: - self._log(f"❌ Could not save game skill: {exc}") - - def _add_game_skill_page(self, title: str, page: QWidget) -> int: - """Add a page to the Game skills bar + stack. Returns the new index.""" - idx = self._game_skills_stack.addWidget(page) - self._game_skills_bar.addTab(title) - return idx - - def _select_game_skill_tab(self, title: str) -> None: - for i in range(self._game_skills_bar.count()): - if self._game_skills_bar.tabText(i) == title: - self._game_skills_bar.setCurrentIndex(i) - return - - def _custom_skill_editor_page(self, stem: str) -> QWidget: - """Editor page for one custom skill file under /skills/.md.""" - page = QWidget() - vl = QVBoxLayout(page) - vl.setContentsMargins(8, 8, 8, 8) - vl.setSpacing(6) - - tip = QLabel( - "Custom skill - merged into the translation system prompt. " - "Extra or conflicting rules can hurt quality." - ) - tip.setWordWrap(True) - tip.setStyleSheet("color:#c9a227;font-size:12px;") - vl.addWidget(tip) - - path_lbl = QLabel(f"/skills/{stem}.md") - path_lbl.setStyleSheet( - "color:#569cd6;font-size:11px;font-family:Consolas,monospace;" - ) - vl.addWidget(path_lbl) - - ed = _PlainPasteTextEdit() - ed.setMinimumHeight(160) - ed.setFont(QFont("Consolas", 9)) - ed.setStyleSheet( - "QTextEdit{background-color:#1e1e1e;color:#d4d4d4;" - "border:none;padding:8px;" - "selection-background-color:#264f78;}" - ) - self._custom_skill_editors[stem] = ed - vl.addWidget(ed, 1) - - row = QHBoxLayout() - row.setSpacing(8) - save_btn = _make_btn("💾 Save", "#3a7a3a") - save_btn.setFixedWidth(110) - save_btn.clicked.connect(lambda _=False, s=stem: self._save_custom_skill(s)) - row.addWidget(save_btn) - - reload_btn = _make_btn("↺ Reload", "#555") - reload_btn.setFixedWidth(110) - reload_btn.clicked.connect(lambda _=False, s=stem: self._reload_one_custom_skill(s)) - row.addWidget(reload_btn) - - delete_btn = _make_btn("🗑 Delete", "#8b0000") - delete_btn.setFixedWidth(110) - delete_btn.setToolTip("Delete this custom skill file from the game folder") - delete_btn.clicked.connect(lambda _=False, s=stem: self._delete_custom_skill(s)) - row.addWidget(delete_btn) - row.addStretch() - vl.addLayout(row) - return page - - def _reload_custom_skills(self): - """Rebuild custom skill tabs from /skills/*.md (excluding built-ins).""" - bar = getattr(self, "_game_skills_bar", None) - stack = getattr(self, "_game_skills_stack", None) - if bar is None or stack is None: - return - - # Remove existing custom tabs (index 0 is always game.md). - while bar.count() > 1: - bar.removeTab(1) - while stack.count() > 1: - w = stack.widget(1) - stack.removeWidget(w) - if w is not None: - w.deleteLater() - self._custom_skill_editors = {} - - root = self.folder_edit.text().strip() - for path in list_custom_skill_paths(root): - stem = path.stem - page = self._custom_skill_editor_page(stem) - self._add_game_skill_page(stem, page) - try: - self._custom_skill_editors[stem].setPlainText( - path.read_text(encoding="utf-8") - ) - except Exception as exc: - self._log(f"❌ Could not load skills/{stem}.md: {exc}") - - def _reload_one_custom_skill(self, stem: str): - root = self.folder_edit.text().strip() - path = custom_skill_path_for_game(root, stem) - ed = self._custom_skill_editors.get(stem) - if ed is None: - return - if not path or not path.is_file(): - ed.setPlainText("") - return - try: - ed.setPlainText(path.read_text(encoding="utf-8")) - except Exception as exc: - self._log(f"❌ Could not load skills/{stem}.md: {exc}") - - def _save_custom_skill(self, stem: str): - root = self._game_root_or_warn() - if not root: - return - path = custom_skill_path_for_game(root, stem) - ed = self._custom_skill_editors.get(stem) - if path is None or ed is None: - self._log("❌ Invalid custom skill.") - return - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(ed.toPlainText().rstrip() + "\n", encoding="utf-8") - self._log(f"✅ Saved {path}") - except Exception as exc: - self._log(f"❌ Could not save skills/{stem}.md: {exc}") - - def _add_custom_skill(self): - root = self._game_root_or_warn() - if not root: - return - - warn = QMessageBox(self) - warn.setIcon(QMessageBox.Warning) - warn.setWindowTitle("Custom skill - quality warning") - warn.setTextFormat(Qt.RichText) - warn.setText( - "Custom skills are merged into the translation system prompt.

" - "Extra or poorly written skills can distract the model and hurt " - "translation quality (conflicting rules, prompt bloat, diluted quirks).

" - "Prefer quirks.md for voice rules and game.md " - "for the Translation Frame. Add a custom skill only if you need a rare, tightly scoped " - "overlay - at your own risk." - ) - warn.setStandardButtons(QMessageBox.Cancel | QMessageBox.Ok) - warn.button(QMessageBox.Ok).setText("I understand - continue") - warn.setStyleSheet( - "QMessageBox{background-color:#252526;}" - "QLabel{color:#d4d4d4;font-size:13px;min-width:420px;}" - ) - if warn.exec_() != QMessageBox.Ok: - return - - name, ok = QInputDialog.getText( - self, - "New custom skill", - "Skill file name (letters, numbers, ._- ; saved as /skills/.md):", - ) - if not ok: - return - stem = sanitize_custom_skill_stem(name) - if not stem: - QMessageBox.warning( - self, - "Invalid name", - "Use a short name like battle-log or honorifics_extra.\n" - "Reserved: quirks, game, translation.", - ) - return - if stem in self._custom_skill_editors: - QMessageBox.information( - self, "Already exists", f"Custom skill '{stem}' is already open." - ) - self._select_game_skill_tab(stem) - return - - path = custom_skill_path_for_game(root, stem) - if path is None: - return - if path.is_file(): - # File exists on disk but tab missing - just reload tabs. - self._reload_custom_skills() - self._select_game_skill_tab(stem) - return - - try: - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - f"# {stem}\n\n" - "# Keep this short and specific. Conflicting rules hurt quality.\n", - encoding="utf-8", - ) - except Exception as exc: - self._log(f"❌ Could not create skills/{stem}.md: {exc}") - return - - page = self._custom_skill_editor_page(stem) - idx = self._add_game_skill_page(stem, page) - self._game_skills_bar.setCurrentIndex(idx) - self._reload_one_custom_skill(stem) - self._log(f"✅ Created custom skill {path}") - - def _delete_custom_skill(self, stem: str): - root = self._game_root_or_warn() - if not root: - return - path = custom_skill_path_for_game(root, stem) - reply = QMessageBox.question( - self, - "Delete custom skill", - f"Delete skills/{stem}.md from the game folder?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if reply != QMessageBox.Yes: - return - try: - if path and path.is_file(): - path.unlink() - self._log(f"🗑 Deleted {path}") - except Exception as exc: - self._log(f"❌ Could not delete skills/{stem}.md: {exc}") - return - self._reload_custom_skills() - def _read_vocab_speakers(self) -> list[tuple[str, str]]: """Parse the '# Speakers' section from vocab.txt and return (orig, tl) pairs.""" vocab_path = VOCAB_PATH @@ -3873,23 +3429,6 @@ class WorkflowTab(QWidget): results.append((pm.group(1), pm.group(2))) return results - def _reload_vocab(self): - try: - self.vocab_editor.setPlainText(read_game_vocab()) - except Exception as exc: - self._log(f"❌ Could not load vocab.txt: {exc}") - - def _save_vocab(self): - try: - write_game_vocab(self.vocab_editor.toPlainText()) - self._log("✅ vocab.txt saved (base terms from vocab_base.txt appended).") - except Exception as exc: - self._log(f"❌ Could not save vocab.txt: {exc}") - - # ───────────────────────────────────────────────────────────────────────── - # Step 2 – Actor substitution - # ───────────────────────────────────────────────────────────────────────── - # ───────────────────────────────────────────────────────────────────────── # Step 3 – Speaker detection # ─────────────────────────────────────────────────────────────────────────