diff --git a/.env.example b/.env.example index df1bb32..22b8c63 100644 --- a/.env.example +++ b/.env.example @@ -9,20 +9,21 @@ # - Leave this variable out to use the model's default setting. GEMINI_THINKING_BUDGET= -# Set to "gemini" to use the Gemini API or "openai" for OpenAI (If empty it will default to openai.) +# Set to "gemini" to use the Gemini API or "openai" for OpenAI-compatible APIs (If empty it will default to openai.) API_PROVIDER=openai -#API link, leave blank to use OpenAI API +# API URL, leave blank to use OpenAI API. +# Nvidia example: "https://integrate.api.nvidia.com/v1/" api="" -#API key +# API key key="" -#Oranization key, make something up for self hosted or other API +#Oranization key, make something up for self hosted or other API. If using Nvidia API, leave it blank or it can get wonky organization="" -#LLM model name, use gpt-3.5-turbo-1106 or gpt-3.5-turbo or gpt-4-1106-preview for OpenAI API -#For text generation webui use gpt-3.5-turbo, for other API's consult their documentation +# LLM model name. +# Default below works for OpenAI; for Gemini/Nvidia set your provider model name. model="gpt-4.1" #The language to translate TO, Don't forget to change the prompt @@ -32,7 +33,10 @@ language="English" timeout="120" #The number of files to translate at the same time, 1 recommended for free or self hosted API or gpt-4 -fileThreads="1" +fileThreads="5" + +#Concurrent API calls per file (threads per file), 1 recommended for free or self hosted API or gpt-4 +threads="1" #The wordwrap of dialogue text width="60" @@ -56,4 +60,8 @@ output_cost= 0.002 batchsize="10" # Frequency penalty - adjust according to your needs -frequency_penalty= 0.2 \ No newline at end of file +frequency_penalty= 0.2 + +# TL Inspector (Step 8 playtest) — editor opened when clicking a source line in-game +# tlEditorCmd: 'auto' scans for VS Code / Cursor, or set an absolute path to any editor exe +tlEditorCmd='auto' diff --git a/.gitignore b/.gitignore index 5849736..91f71ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ + .env *.tmp *.json @@ -19,3 +20,4 @@ __pycache__ !prompt.txt !vocab_base.txt !requirements.txt +!util/tl_inspector/TLInspector.js diff --git a/README.md b/README.md index af86efb..6dcfd1b 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ An AI-powered game translation tool with a GUI. Translate RPG Maker, Ren'Py, Tyr ## Credits - **[Sinflower](https://github.com/Sinflower)** — [RV2JSON](https://github.com/Sinflower/RV2JSON) — enables RPGMaker Ace games to be translated the same way as MV/MZ by converting rvdata2 files to JSON and back. +- **Sakura & Kao_SSS** — TL Inspector (`util/tl_inspector/`) — in-game translation source inspector and live-edit plugin for RPG Maker MV/MZ playtesting. ## Table of Contents @@ -109,9 +110,11 @@ This means Python wasn't added to your PATH. You have two options: 1. Inside the tool folder, find `.env.example` and make a copy of it named `.env`. 2. Open `.env` in any text editor (Notepad works fine) and fill in your API details: + - `api` — Your API base URL (for Nvidia use `https://integrate.api.nvidia.com/v1/`). - `key` — Your API key. - `organization` — Your organization key (make something up if using a self-hosted or non-OpenAI API). - - `API_PROVIDER` — Set to `openai` or `gemini` depending on your provider. + - `API_PROVIDER` — Use `openai` for OpenAI-compatible providers (including Nvidia), or `gemini` for Gemini. + - `model` — For Nvidia/custom OpenAI-compatible endpoints, enter the model name manually (example: `deepseek-ai/deepseek-v4-pro`). 3. The rest of the settings (wordwrap, batch size, etc.) can be left as defaults for now. You can tweak them later. ### 3. Launch the GUI diff --git a/gui/config_tab.py b/gui/config_tab.py index 8587b20..98b9f95 100644 --- a/gui/config_tab.py +++ b/gui/config_tab.py @@ -137,12 +137,27 @@ class ConfigTab(QWidget): self.reset_to_defaults() else: self.load_from_env() + self._update_model_placeholder() # Connect auto-save after initial load self.connect_auto_save() # Fetch latest models in the background once the UI is shown QTimer.singleShot(0, lambda: self.fetch_models(silent=True)) + + def _is_nvidia_api_url(self, api_url: str) -> bool: + """Return True when the configured URL points to Nvidia's OpenAI-compatible API.""" + return "integrate.api.nvidia.com" in (api_url or "").strip().lower() + + def _update_model_placeholder(self): + """Show a manual model-entry hint only when Nvidia API is selected.""" + line_edit = self.model_combo.lineEdit() + if not line_edit: + return + if self._is_nvidia_api_url(self.api_url_edit.text()): + line_edit.setPlaceholderText("Enter Nvidia model name (e.g., deepseek-ai/deepseek-v4-pro)") + else: + line_edit.setPlaceholderText("") def init_ui(self): """Initialize the user interface with horizontal icon navigation at top.""" @@ -326,11 +341,13 @@ class ConfigTab(QWidget): ("Claude (Anthropic)", "https://api.anthropic.com/v1"), ("Gemini", "https://generativelanguage.googleapis.com/v1beta/openai/"), ("DeepSeek", "https://api.deepseek.com/v1/"), + ("Nvidia", "https://integrate.api.nvidia.com/v1/"), ] for _name, _url in _url_presets: _action = api_url_menu.addAction(_name) _action.triggered.connect(lambda checked, u=_url: self.api_url_edit.setText(u)) api_url_preset_btn.setMenu(api_url_menu) + self.api_url_edit.textChanged.connect(self._update_model_placeholder) api_url_layout.addWidget(self.api_url_edit) api_url_layout.addWidget(api_url_preset_btn) diff --git a/gui/rewrite_tab.py b/gui/rewrite_tab.py index 06c847d..6b4ebc2 100644 --- a/gui/rewrite_tab.py +++ b/gui/rewrite_tab.py @@ -222,6 +222,45 @@ def _sanitise_output(text: str) -> str: return text +def _extract_claude_text(response) -> str: + """Collect text from all text blocks; skip thinking/tool blocks.""" + parts = [] + for block in getattr(response, "content", None) or []: + if getattr(block, "type", None) == "text": + t = getattr(block, "text", None) + if t: + parts.append(t) + elif hasattr(block, "text") and block.text: + # Older SDK blocks without explicit type + parts.append(block.text) + text = "".join(parts).strip() + if not text: + stop = getattr(response, "stop_reason", None) + raise ValueError( + f"Empty response from API (stop_reason={stop!r}). " + "Try again or use a different model." + ) + return text + + +def _extract_openai_text(response) -> str: + """Extract assistant message text from an OpenAI-compatible completion.""" + choices = getattr(response, "choices", None) or [] + if not choices: + raise ValueError("Empty response from API (no choices returned).") + message = choices[0].message + text = getattr(message, "content", None) + if text is None and hasattr(message, "refusal") and message.refusal: + raise ValueError(f"Model refused: {message.refusal}") + if not text or not str(text).strip(): + finish = getattr(choices[0], "finish_reason", None) + raise ValueError( + f"Empty response from API (finish_reason={finish!r}). " + "Try again or use a different model." + ) + return str(text).strip() + + def _call_llm(messages: list, model: str, api_url: str, api_key: str, is_claude: bool, timeout: int) -> tuple: """Call the LLM. Returns (text, input_tokens, output_tokens).""" @@ -237,7 +276,7 @@ def _call_llm(messages: list, model: str, api_url: str, api_key: str, messages=user_msgs, timeout=timeout, ) - text = response.content[0].text.strip() + text = _extract_claude_text(response) in_tok = getattr(response.usage, "input_tokens", 0) or 0 out_tok = getattr(response.usage, "output_tokens", 0) or 0 return text, in_tok, out_tok @@ -254,7 +293,7 @@ def _call_llm(messages: list, model: str, api_url: str, api_key: str, temperature=0, timeout=timeout, ) - text = response.choices[0].message.content.strip() + text = _extract_openai_text(response) in_tok = getattr(response.usage, "prompt_tokens", 0) or 0 out_tok = getattr(response.usage, "completion_tokens", 0) or 0 return text, in_tok, out_tok diff --git a/gui/translation_tab.py b/gui/translation_tab.py index f91761a..1235101 100644 --- a/gui/translation_tab.py +++ b/gui/translation_tab.py @@ -282,16 +282,15 @@ class TranslationWorker(QThread): # Check for required environment variables required_envs = ["api", "key", "model", "language", "timeout", "fileThreads", "threads", "width", "listWidth"] - env_missing = False - - for env in required_envs: - if os.getenv(env) is None or str(os.getenv(env))[:1] == "<": - self.emit_log(f"❌ Environment variable {env} is not set!") - env_missing = True - - if env_missing: - self.emit_log("❌ Some required environment variables are not set. Check your .env file.") - self.finished_signal.emit(False, "Environment variables missing") + missing_envs = [ + env for env in required_envs + if os.getenv(env) is None or str(os.getenv(env))[:1] == "<" + ] + if missing_envs: + names = ", ".join(missing_envs) + self.emit_log(f"❌ Missing required environment variable(s): {names}") + self.emit_log(" Check your .env file (see .env.example).") + self.finished_signal.emit(False, f"Missing env: {names}") return # Get files to process diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py index e4500e5..7bc706c 100644 --- a/gui/workflow_tab.py +++ b/gui/workflow_tab.py @@ -7,9 +7,11 @@ Provides a guided, step-by-step interface: Step 1 – (Optional) Pre-process game files Step 2 – Auto-detect speaker format and apply to module settings Step 3 – Build glossary: parse speakers, then enrich with AI prompt - Step 4 – Translation: Phase 0 (DB), Phase 1 (dialogue), Phase 1b (111 cache), Phase 2 (risky) - Step 5 – Translate visible strings in js/plugins.js - Step 6 – Export translated/ back to the game folder + Step 4 – Translation: Phase 0 (DB), Phase 1 (dialogue), Phase 1b (111 cache) + Step 5 – Translation Phase 2 (risky codes) + Step 6 – Translate visible strings in js/plugins.js (or Ace scripts) + Step 7 – Export translated/ back to the game folder + Step 8 – Install TL Inspector for playtesting and live in-game edits (MV/MZ) """ from __future__ import annotations @@ -27,6 +29,7 @@ from PyQt5.QtGui import QFont from PyQt5.QtWidgets import ( QApplication, QCheckBox, + QComboBox, QFileDialog, QFrame, QGridLayout, @@ -560,6 +563,7 @@ class WorkflowTab(QWidget): ("5 TL Phase 2", self._build_step5_tl_phase2), ("6 Plugins.js", self._build_step6_plugins_js), ("7 Export", self._build_step7_export), + ("8 Playtest", self._build_step8_playtest), ] for tab_label, builder in _tab_defs: @@ -714,6 +718,9 @@ class WorkflowTab(QWidget): if index == 5: self._populate_p2_checkboxes() + if index == 8: + self._refresh_tl_inspector_status() + self._load_tli_editor_settings() def _register_import_button(self, button: QPushButton) -> None: self._import_buttons.append(button) @@ -2398,6 +2405,335 @@ class WorkflowTab(QWidget): row.addStretch() layout.addLayout(row) + # ── Step 8: Playtest (TL Inspector) ───────────────────────────────────── + + def _build_step8_playtest(self, layout: QVBoxLayout): + self._step8_section_label = _make_section_label("Step 8 — Playtest with TL Inspector") + layout.addWidget(self._step8_section_label) + + hint = QLabel( + "Install TL Inspector into your RPG Maker MV/MZ game for playtesting. " + "Press F10 in-game to see which source file each line of text comes from, " + "open it in VSCode, or edit the text live and save directly to the JSON file." + ) + hint.setWordWrap(True) + hint.setTextFormat(Qt.RichText) + hint.setStyleSheet("color:#9d9d9d;font-size:13px;padding-bottom:4px;") + layout.addWidget(hint) + + credits = QLabel("Idea by Sakura · Plugin by Kao_SSS") + credits.setStyleSheet("color:#6a6a6a;font-size:11px;font-style:italic;padding-bottom:6px;") + layout.addWidget(credits) + + box = QWidget() + box.setObjectName("tbox") + box.setStyleSheet(self._task_box_style()) + inner = QVBoxLayout(box) + inner.setContentsMargins(10, 8, 10, 8) + inner.setSpacing(6) + + self._tli_status_label = QLabel("Status: (detect a project folder first)") + self._tli_status_label.setWordWrap(True) + self._tli_status_label.setStyleSheet("color:#7a7a7a;font-size:13px;") + inner.addWidget(self._tli_status_label) + + editor_title = QLabel("Editor settings") + editor_title.setStyleSheet("color:#4ec9b0;font-size:12px;font-weight:bold;padding-top:4px;") + inner.addWidget(editor_title) + + _TLI_LABEL_W = 80 + _TLI_BTN_W = 88 + _tli_lbl_style = "color:#9d9d9d;font-size:12px;" + + tli_grid = QGridLayout() + tli_grid.setHorizontalSpacing(6) + tli_grid.setVerticalSpacing(6) + tli_grid.setColumnStretch(1, 1) + + editor_lbl = QLabel("Editor:") + editor_lbl.setFixedWidth(_TLI_LABEL_W) + editor_lbl.setStyleSheet(_tli_lbl_style) + editor_lbl.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + tli_grid.addWidget(editor_lbl, 0, 0) + + self._tli_editor_combo = QComboBox() + self._tli_editor_combo.currentIndexChanged.connect(self._on_tli_editor_combo_changed) + tli_grid.addWidget(self._tli_editor_combo, 0, 1) + + detect_btn = _make_btn("Detect", "#4a4a4a") + detect_btn.setFixedWidth(_TLI_BTN_W) + detect_btn.setToolTip("Scan this PC for VS Code, Insiders, or Cursor") + detect_btn.clicked.connect(self._detect_tli_editors) + tli_grid.addWidget(detect_btn, 0, 2) + + self._tli_editor_custom = QLineEdit() + self._tli_editor_custom.setPlaceholderText("Path to editor executable (when Custom is selected)") + self._tli_editor_custom.setEnabled(False) + tli_grid.addWidget(self._tli_editor_custom, 1, 1) + + browse_editor_btn = _make_btn("Browse…", "#4a4a4a") + browse_editor_btn.setFixedWidth(_TLI_BTN_W) + browse_editor_btn.clicked.connect(self._browse_tli_editor) + tli_grid.addWidget(browse_editor_btn, 1, 2) + + self._tli_detect_label = QLabel("") + self._tli_detect_label.setWordWrap(True) + self._tli_detect_label.setStyleSheet("color:#6a6a6a;font-size:11px;") + tli_grid.addWidget(self._tli_detect_label, 2, 1, 1, 2) + + cfg_btn_wrap = QWidget() + cfg_btn_row = QHBoxLayout(cfg_btn_wrap) + cfg_btn_row.setContentsMargins(0, 0, 0, 0) + cfg_btn_row.setSpacing(8) + save_tli_btn = _make_btn("✔ Save settings", "#3a5a7a") + save_tli_btn.setFixedWidth(140) + save_tli_btn.setToolTip("Write editor settings to .env (used on Install / Apply)") + save_tli_btn.clicked.connect(self._save_tli_editor_settings) + cfg_btn_row.addWidget(save_tli_btn) + apply_tli_btn = _make_btn("↻ Apply to game", "#3a5a7a") + apply_tli_btn.setFixedWidth(140) + apply_tli_btn.setToolTip("Update the installed TLInspector.js in your game folder") + apply_tli_btn.clicked.connect(self._apply_tli_editor_settings) + cfg_btn_row.addWidget(apply_tli_btn) + cfg_btn_row.addStretch() + tli_grid.addWidget(cfg_btn_wrap, 3, 1, 1, 2) + + inner.addLayout(tli_grid) + + tips = QLabel( + "" + ) + tips.setWordWrap(True) + tips.setTextFormat(Qt.RichText) + inner.addWidget(tips) + + btn_row = QHBoxLayout() + btn_row.setSpacing(8) + _BTN_W = 200 + self._tli_install_btn = _make_btn("⬇ Install TL Inspector", "#3a7a3a") + self._tli_install_btn.setFixedWidth(_BTN_W) + self._tli_install_btn.clicked.connect(self._install_tl_inspector) + btn_row.addWidget(self._tli_install_btn) + + self._tli_uninstall_btn = _make_btn("⬆ Uninstall TL Inspector", "#7a3a3a") + self._tli_uninstall_btn.setFixedWidth(_BTN_W) + self._tli_uninstall_btn.clicked.connect(self._uninstall_tl_inspector) + btn_row.addWidget(self._tli_uninstall_btn) + btn_row.addStretch() + inner.addLayout(btn_row) + + layout.addWidget(box) + self._step8_playtest_box = box + self._populate_tli_editor_combo() + self._load_tli_editor_settings() + + def _populate_tli_editor_combo(self, select: str | None = None): + """Fill editor dropdown with auto-detect, found editors, and custom.""" + from util.tl_inspector.config import detect_editors + + combo = self._tli_editor_combo + combo.blockSignals(True) + combo.clear() + combo.addItem("Auto-detect (recommended)", "auto") + for label, path in detect_editors(): + combo.addItem(f"{label} — {path}", str(path)) + combo.addItem("Custom path…", "__custom__") + + want = select + if want is None: + try: + from util.tl_inspector.config import load_config + want = load_config().get("editorCmd", "auto") + except Exception: + want = "auto" + + idx = combo.findData(want) if want else 0 + if idx >= 0: + combo.setCurrentIndex(idx) + elif want and want != "auto": + custom_idx = combo.findData("__custom__") + combo.setCurrentIndex(custom_idx if custom_idx >= 0 else 0) + self._tli_editor_custom.setText(want) + else: + combo.setCurrentIndex(0) + combo.blockSignals(False) + self._on_tli_editor_combo_changed() + self._update_tli_detect_label() + + def _update_tli_detect_label(self): + from util.tl_inspector.config import detect_editors, detect_primary_editor + + found = detect_editors() + primary = detect_primary_editor() + if primary: + extra = f" ({len(found)} found)" if len(found) > 1 else "" + self._tli_detect_label.setText(f"Detected on this PC: {primary}{extra}") + else: + self._tli_detect_label.setText( + "No VS Code / Cursor found — install one or choose Custom path." + ) + + def _load_tli_editor_settings(self): + """Load TL Inspector editor settings from .env into Step 8 controls.""" + try: + from util.tl_inspector.config import load_config + cfg = load_config() + except Exception: + cfg = {"editorCmd": "auto"} + + self._populate_tli_editor_combo(select=cfg.get("editorCmd", "auto")) + + def _resolve_tli_config(self) -> dict: + """Build config dict from Step 8 editor controls.""" + mode = self._tli_editor_combo.currentData() + if mode == "__custom__": + editor = self._tli_editor_custom.text().strip() or "auto" + elif mode: + editor = str(mode) + else: + editor = "auto" + + return {"editorCmd": editor, "workspaceFolder": "auto"} + + def _on_tli_editor_combo_changed(self, _index: int | None = None): + custom = self._tli_editor_combo.currentData() == "__custom__" + self._tli_editor_custom.setEnabled(custom) + + def _detect_tli_editors(self): + try: + from util.tl_inspector.config import load_config + current = load_config().get("editorCmd", "auto") + except Exception: + current = "auto" + self._populate_tli_editor_combo(select=current) + self._log("🔍 Scanned for VS Code / Cursor installations.") + + def _browse_tli_editor(self): + start = self._tli_editor_custom.text() or self._setting("last_tli_editor", "") + path, _ = QFileDialog.getOpenFileName( + self, + "Select Editor Executable", + start, + "Executables (*.exe);;All Files (*)", + ) + if not path: + return + self._save_setting("last_tli_editor", path) + custom_idx = self._tli_editor_combo.findData("__custom__") + if custom_idx >= 0: + self._tli_editor_combo.setCurrentIndex(custom_idx) + self._tli_editor_custom.setText(path) + + def _save_tli_editor_settings(self): + cfg = self._resolve_tli_config() + try: + from util.tl_inspector.config import save_config + save_config(cfg) + self._log(f"✅ TL Inspector settings saved — editor={cfg['editorCmd']}") + except Exception as exc: + self._log(f"❌ Could not save TL Inspector settings: {exc}") + + def _apply_tli_editor_settings(self): + game_root = self.folder_edit.text().strip() + if not game_root: + self._log("⚠ No game folder set. Complete Step 0 first.") + return + cfg = self._resolve_tli_config() + try: + from util.tl_inspector.config import save_config + from util.tl_inspector.installer import apply_config + save_config(cfg) + ok, msg = apply_config(Path(game_root), cfg) + except Exception as exc: + self._log(f"❌ Could not apply TL Inspector settings: {exc}") + return + self._log(("✅ " if ok else "❌ ") + msg) + + def _refresh_tl_inspector_status(self): + """Update Step 8 status label from the current game folder.""" + label = getattr(self, "_tli_status_label", None) + if label is None: + return + game_root = self.folder_edit.text().strip() + if not game_root: + label.setText("Status: no game folder set — complete Step 0 first.") + label.setStyleSheet("color:#7a7a7a;font-size:13px;") + return + try: + from util.tl_inspector.installer import status + st = status(Path(game_root)) + except Exception as exc: + label.setText(f"Status: error — {exc}") + label.setStyleSheet("color:#f48771;font-size:13px;") + return + + if not st.get("ok"): + label.setText(f"Status: {st.get('message', 'unsupported')}") + label.setStyleSheet("color:#e9a12a;font-size:13px;") + return + + engine = st.get("engine", "?") + msg = st.get("message", "") + parts = [f"RPG Maker {engine}", msg] + if st.get("declared") and st.get("plugin_file"): + detail = "plugin declared in plugins.js and file present" + elif st.get("declared"): + detail = "declared in plugins.js (plugin file missing)" + elif st.get("plugin_file"): + detail = "plugin file present (not declared in plugins.js)" + else: + detail = "not installed" + label.setText(f"Status: {' · '.join(parts)} — {detail}") + color = "#6a9a6a" if st.get("declared") and st.get("plugin_file") else "#9d9d9d" + label.setStyleSheet(f"color:{color};font-size:13px;") + + def _install_tl_inspector(self): + game_root = self.folder_edit.text().strip() + if not game_root: + self._log("⚠ No game folder set. Complete Step 0 first.") + return + cfg = self._resolve_tli_config() + try: + from util.tl_inspector.config import save_config + from util.tl_inspector.installer import install + save_config(cfg) + ok, msg = install(Path(game_root), cfg=cfg) + except Exception as exc: + self._log(f"❌ TL Inspector install failed: {exc}") + return + self._log(("✅ " if ok else "❌ ") + msg) + self._refresh_tl_inspector_status() + + def _uninstall_tl_inspector(self): + game_root = self.folder_edit.text().strip() + if not game_root: + self._log("⚠ No game folder set. Complete Step 0 first.") + return + reply = QMessageBox.question( + self, + "Uninstall TL Inspector", + "Remove TLInspector from plugins.js and delete the plugin file?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + try: + from util.tl_inspector.installer import uninstall + ok, msg = uninstall(Path(game_root)) + except Exception as exc: + self._log(f"❌ TL Inspector uninstall failed: {exc}") + return + self._log(("✅ " if ok else "❌ ") + msg) + self._refresh_tl_inspector_status() + # ───────────────────────────────────────────────────────────────────────── # Step 0 – Project Folder logic # ───────────────────────────────────────────────────────────────────────── @@ -2501,6 +2837,13 @@ class WorkflowTab(QWidget): "Copy a prompt that instructs Copilot/Cursor to translate only " "visible player-facing strings in plugins.js, using vocab.txt as a glossary." ) + # Step 8 — TL Inspector (MV/MZ only) + if hasattr(self, "_step_tabs") and self._step_tabs.count() > 8: + self._step_tabs.setTabEnabled(8, not is_ace) + box = getattr(self, "_step8_playtest_box", None) + if box is not None: + box.setEnabled(not is_ace) + self._refresh_tl_inspector_status() def _detect_folder(self): folder = self.folder_edit.text().strip() diff --git a/modules/main.py b/modules/main.py index 4072848..e2e07b4 100644 --- a/modules/main.py +++ b/modules/main.py @@ -10,27 +10,27 @@ from dotenv import load_dotenv # This needs to be before the module imports as some of them currently try to read and use some of these values # upon import, in which case if they are unset the script will crash before we can output these messages. -envMissing = False load_dotenv() -for env in [ - "api", - "key", - "model", - "language", - "timeout", - "fileThreads", - "threads", - "width", - "listWidth", -]: - if os.getenv(env) is None or str(os.getenv(env))[:1] == "<": - tqdm.write(Fore.RED + f"Environment variable {env} is not set!") - envMissing = True -if envMissing: +_missing_envs = [ + env for env in [ + "api", + "key", + "model", + "language", + "timeout", + "fileThreads", + "threads", + "width", + "listWidth", + ] + if os.getenv(env) is None or str(os.getenv(env))[:1] == "<" +] +if _missing_envs: + names = ", ".join(_missing_envs) tqdm.write( Fore.RED - + "Some of the required environment values may not be set correctly. You can set \ -these values using an .env file, for an example see .env.example" + + f"Missing required environment variable(s): {names}. " + + "Set them in a .env file (see .env.example)." ) from modules.rpgmakermvmz import handleMVMZ, setSpeakerParseMode as setSpeakerParseMVMZ, finalizeSpeakerParse as finalizeSpeakerParseMVMZ diff --git a/util/tl_inspector/TLInspector.bat b/util/tl_inspector/TLInspector.bat new file mode 100644 index 0000000..1085807 --- /dev/null +++ b/util/tl_inspector/TLInspector.bat @@ -0,0 +1,112 @@ +@echo off +rem ============================================================================ +rem TLInspector installer / uninstaller (RPG Maker MV & MZ) +rem Idea by Sakura · Plugin by Kao_SSS +rem Put this file and TLInspector.js in the GAME ROOT (next to the .exe / +rem index.html), then double-click this file. +rem - Not installed yet -> installs it (moves the plugin into the plugins +rem folder and declares it at the end of plugins.js). +rem - Already installed -> asks whether to remove it. +rem ============================================================================ +chcp 65001 >nul +setlocal +set "TLI_ROOT=%~dp0" +powershell -NoProfile -ExecutionPolicy Bypass -Command "$l=Get-Content -LiteralPath '%~f0'; $i=[Array]::IndexOf($l,'#:PSSTART'); Invoke-Expression (($l[($i+1)..($l.Count-1)]) -join [char]10)" +endlocal +exit /b +#:PSSTART +$ErrorActionPreference = 'Stop' +try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {} +function PauseExit { [void](Read-Host "`nPress Enter to exit"); exit } + +$root = $env:TLI_ROOT +Write-Host "============================================" +Write-Host " TLInspector installer" +Write-Host " Idea by Sakura · Plugin by Kao_SSS" +Write-Host "============================================" + +# --- Detect engine / locate plugins.js (MV uses www\, MZ does not) ---------- +$mvJs = Join-Path $root 'www\js\plugins.js' +$mzJs = Join-Path $root 'js\plugins.js' +if (Test-Path -LiteralPath $mvJs) { + $engine = 'MV'; $pluginsJs = $mvJs; $pluginsDir = Join-Path $root 'www\js\plugins' +} elseif (Test-Path -LiteralPath $mzJs) { + $engine = 'MZ'; $pluginsJs = $mzJs; $pluginsDir = Join-Path $root 'js\plugins' +} else { + Write-Host "" + Write-Host "ERROR: No RPG Maker MV/MZ game found here." -ForegroundColor Red + Write-Host "Could not find 'www\js\plugins.js' (MV) or 'js\plugins.js' (MZ)." + Write-Host "Place this installer and TLInspector.js in the game ROOT folder" + Write-Host "(the one containing the .exe / index.html) and run it again." + PauseExit +} +Write-Host ("Detected RPG Maker {0}" -f $engine) +Write-Host ("plugins list: {0}" -f $pluginsJs) + +$target = Join-Path $pluginsDir 'TLInspector.js' +$srcRoot = Join-Path $root 'TLInspector.js' +$utf8 = New-Object System.Text.UTF8Encoding($false) # no BOM +$content = [IO.File]::ReadAllText($pluginsJs) +$nl = if ($content -match "`r`n") { "`r`n" } else { "`n" } +$declared = [regex]::IsMatch($content, '"name"\s*:\s*"TLInspector"') +$fileThere = Test-Path -LiteralPath $target + +# --- Already installed -> offer to remove ----------------------------------- +if ($declared -or $fileThere) { + Write-Host "" + Write-Host "TLInspector is currently INSTALLED." -ForegroundColor Yellow + $ans = Read-Host "Remove it? (Y/N)" + if ($ans -match '^(y|yes)$') { + if ($declared) { + $kept = ($content -split "`r?`n") | Where-Object { $_ -notmatch '"name"\s*:\s*"TLInspector"' } + $newText = ($kept -join $nl) + # drop a now-dangling comma before the closing ]; + $newText = [regex]::Replace($newText, ',(\s*)\];', '$1];') + [IO.File]::WriteAllText($pluginsJs, $newText, $utf8) + Write-Host "Removed the TLInspector entry from plugins.js" + } + if ($fileThere) { + Remove-Item -LiteralPath $target -Force + Write-Host ("Deleted {0}" -f $target) + } + Write-Host "" + Write-Host "Uninstalled." -ForegroundColor Green + } else { + Write-Host "Cancelled - nothing changed." + } + PauseExit +} + +# --- Not installed -> install ----------------------------------------------- +if (-not (Test-Path -LiteralPath $srcRoot)) { + Write-Host "" + Write-Host "ERROR: TLInspector.js was not found next to this installer." -ForegroundColor Red + Write-Host ("Expected: {0}" -f $srcRoot) + Write-Host "Copy TLInspector.js into the game root and run this again." + PauseExit +} +if (-not (Test-Path -LiteralPath $pluginsDir)) { + New-Item -ItemType Directory -Path $pluginsDir -Force | Out-Null +} +Move-Item -LiteralPath $srcRoot -Destination $target -Force +Write-Host ("Moved plugin -> {0}" -f $target) + +# Insert the declaration as the last entry, just before the closing ]; +$entry = ' { "name": "TLInspector", "status": true, "description": "TL source inspector", "parameters": {} }' +$idx = $content.LastIndexOf('];') +if ($idx -lt 0) { + Write-Host "" + Write-Host "ERROR: could not find the end of the plugin list ( ]; ) in plugins.js." -ForegroundColor Red + Write-Host "The plugin file was moved; please add this line before the ']' yourself:" + Write-Host $entry + PauseExit +} +$before = $content.Substring(0, $idx).TrimEnd() +$after = $content.Substring($idx) # starts at ]; +$sep = if ($before.EndsWith(',')) { $nl } else { ',' + $nl } +$newText = $before + $sep + $entry + $nl + ' ' + $after +[IO.File]::WriteAllText($pluginsJs, $newText, $utf8) +Write-Host "Declared TLInspector at the end of plugins.js" +Write-Host "" +Write-Host "Installed! Start the game, press F10 to open the inspector, and use edit to save text changes in-game." -ForegroundColor Green +PauseExit diff --git a/util/tl_inspector/TLInspector.js b/util/tl_inspector/TLInspector.js new file mode 100644 index 0000000..4ab93d3 --- /dev/null +++ b/util/tl_inspector/TLInspector.js @@ -0,0 +1,2279 @@ +//============================================================================= +// TLInspector.js +// Idea by Sakura · Plugin by Kao_SSS +//============================================================================= +/*: + * @plugindesc Translation Source Inspector - shows which source file/line every + * on-screen line of text and image comes from, and jumps to it in VSCode. + * @author Kao_SSS + * + * @help + * ---------------------------------------------------------------------------- + * TLInspector (RPGMaker MV / MZ, NW.js) + * Credits: Idea by Sakura · Plugin by Kao_SSS + * ---------------------------------------------------------------------------- + * A drop-in proofreading aid for translators. While the game runs, it tracks + * every message / choice / scrolling-text line and every on-screen image, and + * maps each one back to its EXACT source file and line number: + * + * - Dialogue / choices / scroll text -> www/data/MapXXX.json, + * CommonEvents.json, Troops.json (by identity-matching the running event + * list, so duplicate strings are never confused). + * - Pictures and other images -> img/... path + the event command + * (or JS call stack) that loaded them. + * + * Press the hotkey (default F10) to open the inspector overlay. Each row has an + * "Open in VSCode" button (runs `code -g file:line`) and an **edit** button to + * change the text and save it back to the source file without leaving the game. + * + * Live refresh (NW.js playtest only): + * In-game overlay "save to file" reloads that JSON into memory immediately. + * VSCode / external saves: we watch data/*.json; once the file mtime is stable + * (save finished), reload instantly. The game cannot hook Ctrl+S in VSCode itself. + * F9 — manual reload of all database JSON + the current map + * + * Limitations: + * - NW.js desktop playtest only (not browser) + * - Text already shown by a running event won't update until re-triggered + * (in-game overlay edit still updates the current message box) + * - Plugin .js / plugins.js changes still need F5 + * + * ---------------------------------------------------------------------------- + * Install: drop this file in www/js/plugins/ and add one line to plugins.js: + * { "name": "TLInspector", "status": true, "description": "TL source inspector", "parameters": {} } + * Place it LAST in the list. Remove that line to fully disable for a release. + * + * Config: edit the CFG object a few lines below. + * ---------------------------------------------------------------------------- + */ + +(function () { + 'use strict'; + + //========================================================================= + // Config + //========================================================================= + var CFG = { + enabled: true, + hotkey: 'F10', // key (event.key) that toggles the overlay + editorCmd: 'auto', // 'auto' = find VSCode automatically (no PATH setup + // needed). Or set an absolute path to any editor exe. + workspaceFolder: 'auto',// 'auto' = open files inside the game ROOT folder as the + // VSCode workspace (so they don't land in whatever window + // happened to be open last). Or set an absolute folder. + editorReuseWindow: true,// pass -r so VSCode reuses its current window + historySize: 80, // how many past text lines to keep + captureUiText: true, // capture plugin / menu / UI text drawn via Bitmap.drawText + // (so e.g. status-window strings from plugins.js are locatable) + liveEdit: true, // allow saving edited text back to the source file in-game + liveRefresh: true, // auto-reload data/*.json when files change on disk (NW.js) + liveRefreshStableMs: 120,// VSCode save: reload once mtime unchanged this long + liveRefreshPollMs: 400, // how often we check data/*.json for external saves + liveRefreshHotkey: 'F9',// manual reload: all DB JSON + current map + dataDirOverride: null // set an absolute path to force the data dir + }; + + if (!CFG.enabled) { return; } + + //========================================================================= + // Node / environment bootstrap (NW.js exposes require) + //========================================================================= + var nodeOk = false, fs = null, path = null, cp = null; + try { + if (typeof require === 'function') { + fs = require('fs'); + path = require('path'); + cp = require('child_process'); + nodeOk = true; + } + } catch (e) { nodeOk = false; } + + var ENGINE = (typeof Utils !== 'undefined' && Utils.RPGMAKER_NAME) || 'MV'; + + // Resolve the www root (where index.html lives) -> data dir. + // Primary method = exactly how the engine itself resolves paths + // (StorageManager.localFileDirectoryPath uses process.mainModule.filename), + // which is reliable regardless of the page URL scheme NW.js uses. + function resolveWwwRoot() { + try { + if (nodeOk && process.mainModule && process.mainModule.filename) { + return path.dirname(process.mainModule.filename); + } + } catch (e) { /* fall through */ } + try { + var href = window.location.href; // file:///C:/.../www/index.html + var p = decodeURIComponent(new URL(href).pathname); + if (/^\/[A-Za-z]:\//.test(p)) { p = p.slice(1); } // strip leading slash on Windows + return path ? path.dirname(p) : p.replace(/\/[^\/]*$/, ''); + } catch (e2) { + return nodeOk ? process.cwd() : ''; + } + } + var WWW_ROOT = resolveWwwRoot(); + var DATA_DIR = CFG.dataDirOverride || (path ? path.join(WWW_ROOT, 'data') : WWW_ROOT + '/data'); + + // The game root = folder holding the .exe. On MV that's the PARENT of www/; on MZ + // there is no www/, so it IS the resolved root. Used as the VSCode workspace folder + // so opened files land in the game's own project window, not the last-used one. + var GAME_ROOT = (function () { + try { + if (path && WWW_ROOT && path.basename(WWW_ROOT).toLowerCase() === 'www') { + return path.dirname(WWW_ROOT); + } + } catch (e) { /* ignore */ } + return WWW_ROOT; + })(); + + function mapFile(mapId) { + var n = ('000' + mapId).slice(-3); + return path ? path.join(DATA_DIR, 'Map' + n + '.json') : DATA_DIR + '/Map' + n + '.json'; + } + function dataFile(name) { + return path ? path.join(DATA_DIR, name) : DATA_DIR + '/' + name; + } + + function log() { + if (window.console) { console.log.apply(console, ['[TLInspector]'].concat([].slice.call(arguments))); } + } + log('init', { engine: ENGINE, node: nodeOk, dataDir: DATA_DIR, credits: 'Idea by Sakura · Plugin by Kao_SSS' }); + + //========================================================================= + // File cache + JSON value locator (path -> char offset -> line:col) + //========================================================================= + var fileCache = {}; // path -> { mtime, text, lineStarts } + + function readFileCached(file) { + if (!nodeOk) { return null; } + try { + var st = fs.statSync(file); + var mt = st.mtimeMs; + var c = fileCache[file]; + if (c && c.mtime === mt) { return c; } + var text = fs.readFileSync(file, 'utf8'); + var lineStarts = [0]; + for (var i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 10) { lineStarts.push(i + 1); } + } + c = { mtime: mt, text: text, lineStarts: lineStarts }; + fileCache[file] = c; + return c; + } catch (e) { return null; } + } + + function offsetToLineCol(c, off) { + var ls = c.lineStarts, lo = 0, hi = ls.length - 1, mid; + while (lo < hi) { + mid = (lo + hi + 1) >> 1; + if (ls[mid] <= off) { lo = mid; } else { hi = mid - 1; } + } + return { line: lo + 1, col: off - ls[lo] + 1 }; + } + + // --- minimal structural JSON scanner ------------------------------------ + function skipWs(t, i) { + while (i < t.length) { + var ch = t.charCodeAt(i); + if (ch === 32 || ch === 9 || ch === 10 || ch === 13) { i++; } else { break; } + } + return i; + } + function scanString(t, i) { // i at opening " -> returns index after closing " + return scanQuotedString(t, i, '"'); + } + function scanQuotedString(t, i, q) { + if (!q) { q = t[i]; } + if (q !== '"' && q !== "'") { return i; } + i++; // past opening quote + while (i < t.length) { + var ch = t[i]; + if (ch === '\\') { i += 2; continue; } + if (ch === q) { return i + 1; } + i++; + } + return i; + } + function decodeStringLiteral(t, startOff) { + var q = t[startOff]; + if (q !== '"' && q !== "'") { return null; } + var j = startOff + 1, out = ''; + while (j < t.length) { + var ch = t[j]; + if (ch === '\\') { + j++; + if (j >= t.length) { return null; } + var e = t[j]; + if (e === 'n') { out += '\n'; } + else if (e === 'r') { out += '\r'; } + else if (e === 't') { out += '\t'; } + else if (e === 'b') { out += '\b'; } + else if (e === 'f') { out += '\f'; } + else if (e === 'u') { + var hex = t.substr(j + 1, 4); + if (!/^[0-9a-fA-F]{4}$/.test(hex)) { return null; } + out += String.fromCharCode(parseInt(hex, 16)); + j += 4; + } else { out += e; } + j++; + continue; + } + if (ch === q) { + return { start: startOff, end: j, quote: q, value: out }; + } + out += ch; + j++; + } + return null; + } + function encodeStringLiteral(text, quoteChar) { + if (quoteChar === "'") { + return "'" + String(text).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + .replace(/\r/g, '\\r').replace(/\n/g, '\\n').replace(/\t/g, '\\t') + "'"; + } + return JSON.stringify(text); + } + // Scan a line for quoted literals whose decoded value exactly equals expectedText. + function findExactStringLiteralOnLine(c, line, expectedText) { + if (!c || !line || expectedText == null) { return null; } + var lineStart = c.lineStarts[line - 1]; + if (lineStart == null) { return null; } + var lineEnd = line < c.lineStarts.length ? c.lineStarts[line] : c.text.length; + var i = lineStart, best = null; + while (i < lineEnd) { + var ch = c.text[i]; + if (ch === '"' || ch === "'") { + var lit = decodeStringLiteral(c.text, i); + if (lit) { + if (lit.value === expectedText) { best = lit; } + i = lit.end + 1; + continue; + } + } + i++; + } + return best; + } + // Resolve a safe edit target: the whole string literal whose value matches expectedText. + function resolveEditSite(file, line, col, expectedText) { + var c = readFileCached(file); + if (!c || !line || expectedText == null) { return null; } + var lit = findExactStringLiteralOnLine(c, line, expectedText); + if (lit) { return lit; } + // Fallback: literal containing the column, but only if the WHOLE value matches. + var pos = c.lineStarts[line - 1] + Math.max(0, (col || 1) - 1); + var lineStart = c.lineStarts[line - 1]; + var lineEnd = line < c.lineStarts.length ? c.lineStarts[line] : c.text.length; + var i = lineStart; + while (i < lineEnd) { + var ch = c.text[i]; + if (ch === '"' || ch === "'") { + var candidate = decodeStringLiteral(c.text, i); + if (candidate) { + if (pos > candidate.start && pos < candidate.end && + candidate.value === expectedText) { + return candidate; + } + i = candidate.end + 1; + continue; + } + } + i++; + } + return null; + } + function readKey(t, i) { // i at opening quote -> { end, value } + var start = i + 1, j = i + 1, out = ''; + while (j < t.length) { + var ch = t[j]; + if (ch === '\\') { out += t[j + 1]; j += 2; continue; } + if (ch === '"') { return { end: j + 1, value: out }; } + out += ch; j++; + } + return { end: j, value: out }; + } + function skipValue(t, i) { + i = skipWs(t, i); + var ch = t[i]; + if (ch === '"') { return scanString(t, i); } + if (ch === '{' || ch === '[') { + var open = ch, close = ch === '{' ? '}' : ']', depth = 0; + for (; i < t.length; i++) { + var c = t[i]; + if (c === '"') { i = scanString(t, i) - 1; continue; } + if (c === open) { depth++; } + else if (c === close) { depth--; if (depth === 0) { return i + 1; } } + } + return i; + } + // number / true / false / null + while (i < t.length && ',}] \t\r\n'.indexOf(t[i]) === -1) { i++; } + return i; + } + + // Walk `path` (array of string keys / number indices) -> start offset of value, or -1. + function locateOffset(t, pathArr) { + var i = skipWs(t, 0); + for (var s = 0; s < pathArr.length; s++) { + var step = pathArr[s]; + i = skipWs(t, i); + if (typeof step === 'number') { + if (t[i] !== '[') { return -1; } + i = skipWs(t, i + 1); + if (t[i] === ']') { return -1; } + for (var k = 0; k < step; k++) { + i = skipValue(t, i); + i = skipWs(t, i); + if (t[i] !== ',') { return -1; } + i = skipWs(t, i + 1); + } + // i now at target element value start + } else { + if (t[i] !== '{') { return -1; } + i = skipWs(t, i + 1); + var found = false; + while (i < t.length && t[i] !== '}') { + if (t[i] !== '"') { return -1; } + var key = readKey(t, i); + i = skipWs(t, key.end); + if (t[i] !== ':') { return -1; } + i = skipWs(t, i + 1); + if (key.value === step) { found = true; break; } + i = skipValue(t, i); + i = skipWs(t, i); + if (t[i] === ',') { i = skipWs(t, i + 1); } + } + if (!found) { return -1; } + } + } + return skipWs(t, i); + } + + var lineCache = {}; // file::pathKey::mtime -> {line,col} + function locateLine(file, pathArr) { + if (!file || !pathArr || !nodeOk) { return null; } + var c = readFileCached(file); + if (!c) { return null; } + var key = file + '::' + pathArr.join('/') + '::' + c.mtime; + if (lineCache[key]) { return lineCache[key]; } + var off = locateOffset(c.text, pathArr); + var res = off >= 0 ? offsetToLineCol(c, off) : null; + lineCache[key] = res; + return res; + } + + function invalidateFileCache(file) { + delete fileCache[file]; + Object.keys(lineCache).forEach(function (k) { + if (k.indexOf(file + '::') === 0) { delete lineCache[k]; } + }); + dupIndex = null; + } + + function writeStringAtOffset(file, startOff, newText, expectedOld, quoteChar) { + var c = readFileCached(file); + if (!c) { return { ok: false, err: 'read failed' }; } + var t = c.text; + var lit = decodeStringLiteral(t, startOff); + if (!lit) { return { ok: false, err: 'not a complete string literal' }; } + if (expectedOld != null && lit.value !== expectedOld) { + return { ok: false, err: 'on-disk text differs from the captured line — use VSCode instead' }; + } + var q = quoteChar || lit.quote; + var repl = encodeStringLiteral(newText, q); + var merged = t.slice(0, lit.start) + repl + t.slice(lit.end + 1); + try { + fs.writeFileSync(file, merged, 'utf8'); + } catch (e) { return { ok: false, err: String(e.message || e) }; } + invalidateFileCache(file); + markSelfWrite(file); + return { ok: true }; + } + + function writeAtPath(file, pathArr, newText, expectedOld) { + var c = readFileCached(file); + if (!c) { return { ok: false, err: 'read failed' }; } + var off = locateOffset(c.text, pathArr); + if (off < 0) { return { ok: false, err: 'could not locate value' }; } + if (c.text[off] !== '"') { + return { ok: false, err: 'JSON value at path is not a string' }; + } + return writeStringAtOffset(file, off, newText, expectedOld, '"'); + } + + function writeAtEditSite(file, site, newText, expectedOld) { + if (!site) { return { ok: false, err: 'no edit target' }; } + return writeStringAtOffset(file, site.start, newText, expectedOld, site.quote); + } + + function memoryRootForFile(file) { + if (!path) { return null; } + var fname = path.basename(file); + if (/^Map\d+\.json$/i.test(fname)) { + if (typeof $dataMap === 'undefined' || !$dataMap) { return null; } + if (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId) { + var mapId = parseInt(fname.match(/\d+/)[0], 10); + if ($gameMap.mapId() !== mapId) { return null; } + } + return $dataMap; + } + if (fname === 'CommonEvents.json') { + return (typeof $dataCommonEvents !== 'undefined') ? $dataCommonEvents : null; + } + if (fname === 'Troops.json') { + return (typeof $dataTroops !== 'undefined') ? $dataTroops : null; + } + return null; + } + + function setAtPath(root, pathArr, val) { + var o = root; + for (var i = 0; i < pathArr.length - 1; i++) { + o = o[pathArr[i]]; + if (o == null) { return false; } + } + o[pathArr[pathArr.length - 1]] = val; + return true; + } + + function applyLiveMessage(rec, newText) { + if (typeof $gameMessage === 'undefined' || !$gameMessage) { return; } + var busy = $gameMessage.isBusy && $gameMessage.isBusy(); + if (!busy || rec.group !== currentGroup || currentGroup === -1) { return; } + try { + if ($gameMessage._texts) { + for (var i = 0; i < $gameMessage._texts.length; i++) { + if ($gameMessage._texts[i] === rec.text) { $gameMessage._texts[i] = newText; } + } + } + if ($gameMessage._choices) { + for (var j = 0; j < $gameMessage._choices.length; j++) { + if ($gameMessage._choices[j] === rec.text) { $gameMessage._choices[j] = newText; } + } + } + } catch (e) { /* ignore */ } + } + + function canEditRecord(rec) { + if (!CFG.liveEdit || !nodeOk || rec._ambig) { return false; } + if (rec.file && rec.tail) { return true; } + if (rec.file && rec._editSite) { return true; } + return false; + } + + function bindEditSite(rec, file, line, col) { + var site = resolveEditSite(file, line, col, rec.text); + if (!site) { + return false; + } + rec.file = file; + rec._editSite = site; + rec._editLine = line; + rec._editCol = col || 1; + delete rec._editQuoted; + return true; + } + + function isDataJsonFile(file) { + if (!file || !path) { return false; } + try { + var ap = absPath(file); + var dir = absPath(DATA_DIR); + if (process.platform === 'win32') { + return ap.toLowerCase().indexOf(dir.toLowerCase()) === 0 && /\.json$/i.test(ap); + } + return ap.indexOf(dir) === 0 && /\.json$/i.test(ap); + } catch (e) { return false; } + } + + function applyReloadAfterOverlaySave(file) { + if (!CFG.liveRefresh || !isDataJsonFile(file)) { return false; } + return reloadFile(file, { silent: true }); + } + + function saveRecordEdit(rec, newText) { + if (!nodeOk) { toast('Live edit requires the desktop NW.js build'); return false; } + if (newText === rec.text) { toast('No changes'); return false; } + var expected = rec.text; + var r; + if (rec.file && rec.tail) { + r = writeAtPath(rec.file, rec.tail, newText, expected); + } else if (rec.file && rec._editSite) { + r = writeAtEditSite(rec.file, rec._editSite, newText, expected); + } else if (rec.file && (rec._editLine || rec.uiLine)) { + var site = resolveEditSite(rec.file, rec._editLine || rec.uiLine, + rec._editCol || 1, expected); + if (!site) { + toast('Cannot safely edit — pick an exact quoted string match first'); + return false; + } + rec._editSite = site; + r = writeAtEditSite(rec.file, site, newText, expected); + } else { + toast('Cannot edit — source location unresolved'); + return false; + } + if (!r.ok) { toast('Save failed: ' + (r.err || '?')); log('save', r); return false; } + rec.text = newText; + rec._key = 't\u0000' + rec.kind + '\u0000' + newText + '\u0000' + (rec.file || '') + + '\u0000' + (rec.tail ? JSON.stringify(rec.tail) : ''); + applyLiveMessage(rec, newText); + var reloaded = applyReloadAfterOverlaySave(rec.file); + if (!reloaded) { refreshSceneWindows(); } + var note = rec.kind === 'ui' && !reloaded ? ' — UI may need scene change' : ''; + toast(reloaded ? 'Saved & reloaded' : ('Saved to file' + note)); + scheduleRefresh(); + return true; + } + + function prepareUiEditTarget(rec, cb) { + if (rec.tail) { cb(true); return; } + var s = searchFilesSmart(rec.text); + if (s.partial) { + toast('Partial file match — live edit needs the full string; use VSCode'); + cb(false); + return; + } + if (s.results.length === 1) { + if (bindEditSite(rec, s.results[0].file, s.results[0].line, s.results[0].col)) { + cb(true); + return; + } + toast('Match is not a complete string value — use VSCode'); + cb(false); + return; + } + if (s.results.length > 1) { + toast(s.results.length + ' matches — use "locate in files" and pick an exact match'); + cb(false); + return; + } + toast('No file match for this UI text'); + cb(false); + } + + //========================================================================= + // Provenance: map a running event command-list to its source file + path + //========================================================================= + function resolveList(list) { + try { + if (typeof $dataMap !== 'undefined' && $dataMap && $dataMap.events && + typeof $gameMap !== 'undefined' && $gameMap) { + var evs = $dataMap.events; + for (var e = 0; e < evs.length; e++) { + var ev = evs[e]; + if (!ev || !ev.pages) { continue; } + for (var p = 0; p < ev.pages.length; p++) { + if (ev.pages[p].list === list) { + return { file: mapFile($gameMap.mapId()), + prefix: ['events', e, 'pages', p, 'list'], + label: 'Map' + ('000' + $gameMap.mapId()).slice(-3) + ' ev' + e }; + } + } + } + } + if (typeof $dataCommonEvents !== 'undefined' && $dataCommonEvents) { + for (var c = 0; c < $dataCommonEvents.length; c++) { + var ce = $dataCommonEvents[c]; + if (ce && ce.list === list) { + return { file: dataFile('CommonEvents.json'), + prefix: [c, 'list'], label: 'CommonEvent ' + c }; + } + } + } + if (typeof $gameTroop !== 'undefined' && $gameTroop && $gameTroop._troopId && + typeof $dataTroops !== 'undefined' && $dataTroops) { + var tr = $dataTroops[$gameTroop._troopId]; + if (tr && tr.pages) { + for (var tp = 0; tp < tr.pages.length; tp++) { + if (tr.pages[tp].list === list) { + return { file: dataFile('Troops.json'), + prefix: [$gameTroop._troopId, 'pages', tp, 'list'], + label: 'Troop ' + $gameTroop._troopId }; + } + } + } + } + } catch (e) { log('resolveList error', e); } + return null; + } + + function pathToString(arr) { + var s = ''; + for (var i = 0; i < arr.length; i++) { + s += typeof arr[i] === 'number' ? '[' + arr[i] + ']' : (i ? '.' : '') + arr[i]; + } + return s; + } + + //========================================================================= + // Record store + //========================================================================= + var groupSeq = 0; + var currentGroup = -1; + var textRecords = []; // {kind,text,file,prefix,tail,group,ts,_key} + var imageTriggers = {}; // picture filename -> {file,prefix,tail,label} + var BUMP_GUARD_MS = 1000; + + function findByKey(key) { + for (var i = 0; i < textRecords.length; i++) { if (textRecords[i]._key === key) { return i; } } + return -1; + } + // Text seen again -> move it to the top (newest) so it never feels "undetected" + // when it was buried in history. Guard against churn from per-frame UI redraws. + function seenExisting(i, group) { + var r = textRecords[i], now = Date.now(); + if (now - r.ts < BUMP_GUARD_MS) { + r.ts = now; if (group != null && group !== -1) { r.group = group; } + return; // recently seen: keep its position + } + textRecords.splice(i, 1); + r.ts = now; if (group != null && group !== -1) { r.group = group; } + textRecords.push(r); + scheduleRefresh(); + } + + function pushText(kind, prov, tail, text, group, srcIndex, srcEventId) { + if (text == null || text === '') { return; } + var file = prov ? prov.file : null; + var key = 't\u0000' + kind + '\u0000' + text + '\u0000' + (file || '') + '\u0000' + (tail ? JSON.stringify(tail) : ''); + var i = findByKey(key); + if (i >= 0) { seenExisting(i, group); return; } + textRecords.push({ + kind: kind, text: text, file: file, + prefix: prov ? prov.prefix : null, + tail: tail, label: prov ? prov.label : '(unresolved)', + group: group, ts: Date.now(), + srcIndex: (srcIndex == null ? null : srcIndex), + srcEventId: (srcEventId == null ? 0 : srcEventId), + _key: key + }); + while (textRecords.length > CFG.historySize) { textRecords.shift(); } + scheduleRefresh(); + } + + function captureFromList(list, startIdx, textCode, eventId) { + var prov = resolveList(list); + var group = ++groupSeq; + currentGroup = group; + var i = startIdx + 1; + while (i < list.length && list[i] && list[i].code === textCode) { + pushText('text', prov, prov ? prov.prefix.concat([i, 'parameters', 0]) : null, + list[i].parameters[0], group, i, eventId); + i++; + } + // inline "Show Choices" immediately following the text block + if (list[i] && list[i].code === 102) { + captureChoices(list, i, prov, group, eventId); + } + } + + function captureChoices(list, idx, prov, group, eventId) { + if (prov === undefined) { prov = resolveList(list); } + if (group === undefined) { group = ++groupSeq; currentGroup = group; } + var choices = list[idx] && list[idx].parameters && list[idx].parameters[0]; + if (!Array.isArray(choices)) { return; } + for (var n = 0; n < choices.length; n++) { + pushText('choice', prov, + prov ? prov.prefix.concat([idx, 'parameters', 0, n]) : null, + choices[n], group, idx, eventId); + } + } + + //========================================================================= + // Hooks: text + //========================================================================= + if (typeof Game_Interpreter !== 'undefined') { + var GI = Game_Interpreter.prototype; + + var _c101 = GI.command101; + GI.command101 = function () { + var list = this._list, start = this._index; + var r = _c101.apply(this, arguments); + try { if (this._index !== start) { captureFromList(list, start, 401, this._eventId); } } + catch (e) { log('c101', e); } + return r; + }; + + var _c105 = GI.command105; + GI.command105 = function () { + var list = this._list, start = this._index; + var r = _c105.apply(this, arguments); + try { if (this._index !== start) { captureFromList(list, start, 405, this._eventId); } } + catch (e) { log('c105', e); } + return r; + }; + + var _c102 = GI.command102; + GI.command102 = function () { + // A STANDALONE "Show Choices" (not following a message — those are consumed + // inside command101) doesn't advance this._index within the method: MV bumps + // it later in executeCommand, MZ takes params as an arg. So gating on an index + // change missed standalone choice menus entirely. Instead capture exactly when + // the choices get set up: this call, while the message box wasn't already busy. + var list = this._list, idx = this._index; + var wasBusy = (typeof $gameMessage !== 'undefined') && $gameMessage && + $gameMessage.isBusy && $gameMessage.isBusy(); + var r = _c102.apply(this, arguments); + try { + if (!wasBusy && list && list[idx] && list[idx].code === 102) { + captureChoices(list, idx, undefined, undefined, this._eventId); + } + } catch (e) { log('c102', e); } + return r; + }; + + // Show Picture -> remember which command triggered each picture file + var _c231 = GI.command231; + GI.command231 = function () { + try { + var list = this._list, idx = this._index; + var name = this._params && this._params[1]; + if (name) { + var prov = resolveList(list); + imageTriggers['img/pictures/' + name + '.png'] = { + file: prov ? prov.file : null, + path: prov ? prov.prefix.concat([idx, 'parameters', 1]) : null, + label: prov ? prov.label : '(unresolved)' + }; + } + } catch (e) { log('c231', e); } + return _c231.apply(this, arguments); + }; + } + + // Clear "live" marker when the message box closes + if (typeof Game_Message !== 'undefined') { + var _clear = Game_Message.prototype.clear; + Game_Message.prototype.clear = function () { + currentGroup = -1; + scheduleRefresh(); + return _clear.apply(this, arguments); + }; + } + + // Capture plugin / menu / UI text. The true chokepoint is Bitmap.drawText: + // Window_Base.drawText/drawTextEx AND direct `this.contents.drawText(...)` calls + // inside plugins all funnel through it. We skip while a message box is showing + // (that text is captured precisely via the interpreter hooks) and de-duplicate by + // a digit-normalized key so a ticking value ("Money: 12"/"Money: 13") logs once. + if (CFG.captureUiText && typeof Bitmap !== 'undefined' && Bitmap.prototype.drawText) { + // Direct draws (Window_Base.drawText, plugin `this.contents.drawText(...)`). + // drawTextEx renders one character per call here, so single chars are dropped. + var _bmDrawText = Bitmap.prototype.drawText; + Bitmap.prototype.drawText = function (text) { + try { maybeCaptureUiText(text); } catch (e) { /* ignore */ } + return _bmDrawText.apply(this, arguments); + }; + } + if (CFG.captureUiText && typeof Window_Base !== 'undefined' && Window_Base.prototype.drawTextEx) { + // Escape-processed text (item descriptions, help text, names) arrives here as a + // whole string before being drawn character-by-character. + var _drawTextEx = Window_Base.prototype.drawTextEx; + Window_Base.prototype.drawTextEx = function (text) { + try { maybeCaptureUiText(text); } catch (e) { /* ignore */ } + return _drawTextEx.apply(this, arguments); + }; + } + function maybeCaptureUiText(text) { + if (typeof text !== 'string') { return; } + if (text.replace(/\s/g, '').length < 2) { return; } // drop single chars (per-char draws) + if (!/[^\s\d.,:%/+\-()]/.test(text)) { return; } // require a real letter + if (typeof $gameMessage !== 'undefined' && $gameMessage && $gameMessage.isBusy && $gameMessage.isBusy()) { return; } + var key = 'ui ' + text.replace(/\d+/g, '#'); // digit-normalized: "Money: 12/13" -> one entry + var i = findByKey(key); + if (i >= 0) { seenExisting(i, -1); return; } + var site = firstUserFrame(new Error().stack); + textRecords.push({ kind: 'ui', text: text, file: site.file, prefix: null, + tail: null, label: site.label, uiLine: site.line, group: -1, ts: Date.now(), _key: key }); + while (textRecords.length > CFG.historySize) { textRecords.shift(); } + scheduleRefresh(); + } + + function firstUserFrame(stack) { + var lines = (stack || '').split('\n'); + for (var i = 1; i < lines.length; i++) { + var m = lines[i].match(/(js\/plugins\/[^):]+|js\/[^):]+):(\d+):(\d+)/); + if (m && m[1].indexOf('TLInspector') === -1) { + var f = m[1]; + return { file: path ? path.join(WWW_ROOT, f) : WWW_ROOT + '/' + f, + line: parseInt(m[2], 10), label: f + ':' + m[2] }; + } + } + return { file: null, line: null, label: '(unknown)' }; + } + + //========================================================================= + // Hooks: images (load log with call-site) + //========================================================================= + var imageLog = []; // {url, label, ts, bitmap} + if (typeof ImageManager !== 'undefined') { + var _loadBitmap = ImageManager.loadBitmap; + ImageManager.loadBitmap = function (folder, filename) { + var bm = _loadBitmap.apply(this, arguments); + try { + if (filename) { + var url = folder + filename + '.png'; + var last = imageLog[imageLog.length - 1]; + if (!last || last.url !== url) { + var site = firstUserFrame(new Error().stack); + imageLog.push({ url: url, label: site.label, ts: Date.now(), bitmap: bm }); + while (imageLog.length > CFG.historySize) { imageLog.shift(); } + } else if (last && !last.bitmap) { last.bitmap = bm; } + } + } catch (e) { /* ignore */ } + return bm; + }; + } + + //========================================================================= + // Live on-screen image collection (walk the sprite tree) + //========================================================================= + function collectOnScreenImages() { + var out = [], seen = {}; + var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene; + if (!scene) { return out; } + var canvas = getGameCanvas(); + var rect = canvas ? canvas.getBoundingClientRect() : null; + var scale = rect && typeof Graphics !== 'undefined' && Graphics.width ? + rect.width / Graphics.width : 1; + + (function walk(obj) { + if (!obj) { return; } + if (obj.visible === false) { return; } + var bmp = obj.bitmap; + if (bmp && bmp._url) { + var vis = ('worldVisible' in obj) ? obj.worldVisible : true; + if (vis) { + var b = null; + try { b = obj.getBounds && obj.getBounds(); } catch (e) { b = null; } + if (b && b.width > 0 && b.height > 0) { + if (!seen[bmp._url]) { seen[bmp._url] = true; } + out.push({ + url: decodeUrl(bmp._url), + bitmap: bmp, + screen: rect ? { + x: rect.left + b.x * scale, y: rect.top + b.y * scale, + w: b.width * scale, h: b.height * scale + } : null + }); + } + } + } + var kids = obj.children; + if (kids) { for (var i = 0; i < kids.length; i++) { walk(kids[i]); } } + })(scene); + return out; + } + + function getGameCanvas() { + if (typeof Graphics !== 'undefined') { + if (Graphics._canvas) { return Graphics._canvas; } + if (Graphics._renderer && Graphics._renderer.view) { return Graphics._renderer.view; } + } + return document.querySelector('canvas'); + } + + // Bitmap URLs are percent-encoded (ImageManager uses encodeURIComponent on the + // filename), e.g. "img/pictures/%E3%83%90%E3%82%B9.png" for "バス". Decode so the + // path matches the real file on disk. + function decodeUrl(u) { + if (!u) { return u; } + try { return decodeURIComponent(u); } catch (e) { return u; } + } + + function urlToAbsPath(url) { + if (!url) { return null; } + var rel = decodeUrl(url).replace(/^\.?\//, ''); + return path ? path.join(WWW_ROOT, rel) : WWW_ROOT + '/' + rel; + } + + //========================================================================= + // External actions (VSCode / file explorer) + //========================================================================= + // Auto-locate VSCode so the user never has to touch the system PATH. + var _editorPath = null; + function detectEditor() { + if (_editorPath !== null) { return _editorPath; } + if (CFG.editorCmd && CFG.editorCmd !== 'auto') { _editorPath = CFG.editorCmd; return _editorPath; } + if (nodeOk) { + var env = process.env, plat = process.platform, cands = []; + if (plat === 'win32') { + [env.LOCALAPPDATA, env.ProgramFiles, env['ProgramFiles(x86)']].forEach(function (b) { + if (!b) { return; } + cands.push(path.join(b, 'Programs', 'Microsoft VS Code', 'Code.exe')); + cands.push(path.join(b, 'Microsoft VS Code', 'Code.exe')); + cands.push(path.join(b, 'Programs', 'Microsoft VS Code Insiders', 'Code - Insiders.exe')); + cands.push(path.join(b, 'Programs', 'cursor', 'Cursor.exe')); + cands.push(path.join(b, 'cursor', 'Cursor.exe')); + }); + } else if (plat === 'darwin') { + cands.push('/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'); + cands.push('/Applications/Cursor.app/Contents/MacOS/Cursor'); + } else { + cands.push('/usr/bin/code', '/usr/share/code/bin/code', '/snap/bin/code', '/usr/bin/cursor'); + } + for (var i = 0; i < cands.length; i++) { + try { if (fs.existsSync(cands[i])) { _editorPath = cands[i]; log('editor found', _editorPath); return _editorPath; } } + catch (e) { /* ignore */ } + } + } + _editorPath = 'code'; // fall back to PATH lookup + return _editorPath; + } + + function openInEditor(file, line, col) { + if (!nodeOk || !file) { toast('No file resolved for this entry'); return; } + var loc = file + (line ? ':' + line + (col ? ':' + col : '') : ''); + var cmd = detectEditor(); + // Pass the game folder so VSCode opens (or reuses) THAT workspace and reveals the + // file in it, instead of dropping the file into whatever window was last focused. + // Passing a folder makes VSCode route to the matching window itself, so we drop the + // -r "reuse last window" flag (which is what caused the wrong-workspace behavior). + var ws = (CFG.workspaceFolder && CFG.workspaceFolder !== 'auto') ? CFG.workspaceFolder : GAME_ROOT; + var done = function (err) { + if (err) { toast('Editor launch failed; set CFG.editorCmd to your editor path.'); log('editor', err); } + }; + try { + if (cmd === 'code') { + // PATH fallback (code.cmd) needs a shell. The command line does NOT + // start with a quote (program name has no spaces) and each path IS + // quoted, so spaces in the paths are preserved instead of being split + // into separate arguments. + var folder = ws ? '"' + ws + '" ' : ''; + cp.exec(cmd + ' ' + folder + '-g "' + loc + '"', { cwd: WWW_ROOT }, done); + } else { + // Resolved absolute editor exe: run with no shell. Node quotes each + // argument, so a path with spaces stays a single argument. + var args = []; + if (ws) { args.push(ws); } + args.push('-g', loc); + cp.execFile(cmd, args, { cwd: WWW_ROOT }, done); + } + } catch (e) { toast('Editor launch failed'); log(e); } + } + + function revealInExplorer(absPath) { + if (!nodeOk || !absPath) { return; } + try { + if (process.platform === 'win32') { + cp.execFile('explorer.exe', ['/select,', absPath]); + } else if (process.platform === 'darwin') { + cp.execFile('open', ['-R', absPath]); + } else { + cp.execFile('xdg-open', [path.dirname(absPath)]); + } + } catch (e) { log('reveal', e); } + } + + //========================================================================= + // Image decryption (RPGMaker MV .rpgmvp / MZ .png_) + encryption-aware reveal + //========================================================================= + var ENC = { + on: function () { + try { return typeof $dataSystem !== 'undefined' && $dataSystem && !!$dataSystem.hasEncryptedImages; } + catch (e) { return false; } + }, + keyBytes: function () { + try { return ($dataSystem.encryptionKey || '').match(/.{2}/g) || []; } + catch (e) { return []; } + }, + // Candidate on-disk encrypted siblings for a plain .png path. MV renames the + // extension (image.png -> image.rpgmvp); MZ keeps the name and appends "_" + // (image.png -> image.png_). The XOR algorithm is identical for both. + encPaths: function (absPng) { + return [absPng.replace(/\.png$/i, '.rpgmvp'), // MV + absPng + '_']; // MZ (image.png -> image.png_) + } + }; + + // Returns {path, created} for a real .png on disk, decrypting the encrypted + // sibling (.rpgmvp on MV, .png_ on MZ) if the plain .png does not exist. + // Replicates the engine Decrypter (16-byte fake header + XOR of the next 16 + // bytes with the encryption key). + function decryptToSibling(absPng) { + try { + if (fs.existsSync(absPng)) { return { path: absPng, created: false }; } + var cands = ENC.encPaths(absPng), enc = null; + for (var c = 0; c < cands.length; c++) { + if (fs.existsSync(cands[c])) { enc = cands[c]; break; } + } + if (!enc) { return null; } + var buf = fs.readFileSync(enc); + var body = buf.slice(16); // drop the fake PNG header + var key = ENC.keyBytes(); + for (var i = 0; i < 16 && i < key.length; i++) { + body[i] = body[i] ^ parseInt(key[i], 16); + } + fs.writeFileSync(absPng, body); + return { path: absPng, created: true }; + } catch (e) { log('decrypt', e); return null; } + } + + function revealImage(absPng) { + if (!nodeOk || !absPng) { return; } + var r = decryptToSibling(absPng); + if (!r) { toast('Not found on disk (.png / .rpgmvp / .png_)'); return; } + toast(r.created ? 'Decrypted -> revealing .png' : 'Revealing'); + revealInExplorer(r.path); + } + + //========================================================================= + // Thumbnails (from the already-decoded in-memory Bitmap, so encrypted + // images preview fine without touching disk) + a shared image-row builder. + //========================================================================= + var thumbCache = {}; // url -> dataURL + function bitmapThumb(bmp, url) { + if (url && thumbCache[url]) { return thumbCache[url]; } + try { + if (!bmp || (bmp.isReady && !bmp.isReady())) { return null; } + var src = (bmp._image && bmp._image.width) ? bmp._image + : (bmp.canvas || bmp._canvas || bmp.__canvas); + var sw = bmp.width, sh = bmp.height; + if (!src || !sw || !sh) { return null; } + var max = 60, scale = Math.min(max / sw, max / sh, 1); + var w = Math.max(1, Math.round(sw * scale)), h = Math.max(1, Math.round(sh * scale)); + var c = document.createElement('canvas'); + c.width = w; c.height = h; + c.getContext('2d').drawImage(src, 0, 0, sw, sh, 0, 0, w, h); + var data = c.toDataURL('image/png'); + if (url) { thumbCache[url] = data; } + return data; + } catch (e) { return null; } + } + + function buildImgRow(url, bitmap, opts) { + opts = opts || {}; + var abs = urlToAbsPath(url); + var row = document.createElement('div'); + row.className = 'tl-row'; + var thumb = bitmapThumb(bitmap, url); + row.innerHTML = + '
' + + (thumb ? '' + : '
?
') + + '
' + + '
' + esc(url) + '
' + + '
' + + 'reveal' + (ENC.on() ? ' (decrypt)' : '') + '' + + (opts.trigger ? 'trigger: ' + esc(opts.trigger.label) + '' : '') + + (opts.fromLabel ? 'from ' + esc(opts.fromLabel) + '' : '') + + '
' + + '
' + + '
'; + row.querySelector('[data-act="reveal"]').addEventListener('click', function () { revealImage(abs); }); + if (opts.screen) { + row.addEventListener('mouseenter', function () { showHighlight(opts.screen); }); + row.addEventListener('mouseleave', function () { hideHighlight(); }); + } + if (opts.trigger && opts.trigger.file) { + var te = row.querySelector('[data-act="trig"]'); + te.style.cursor = 'pointer'; + te.addEventListener('click', function () { + var l = locateLine(opts.trigger.file, opts.trigger.path); + openInEditor(opts.trigger.file, l && l.line, l && l.col); + }); + } + return row; + } + + //========================================================================= + // Duplicate-line search: index every message/choice string across all data + // files so a translator can find identical lines and avoid mismatched edits. + //========================================================================= + var dupIndex = null; // [{file, path, text}] + + function collectMessages(absFile, fname, obj, emit) { + function walkList(list, prefix) { + if (!Array.isArray(list)) { return; } + for (var i = 0; i < list.length; i++) { + var cmd = list[i]; + if (!cmd) { continue; } + if (cmd.code === 401 || cmd.code === 405) { + if (cmd.parameters && typeof cmd.parameters[0] === 'string') { + emit(prefix.concat([i, 'parameters', 0]), cmd.parameters[0]); + } + } else if (cmd.code === 102 && cmd.parameters && Array.isArray(cmd.parameters[0])) { + cmd.parameters[0].forEach(function (ch, n) { + if (typeof ch === 'string') { emit(prefix.concat([i, 'parameters', 0, n]), ch); } + }); + } + } + } + if (/^Map\d+/.test(fname) && obj && obj.events) { + obj.events.forEach(function (ev, e) { + if (!ev || !ev.pages) { return; } + ev.pages.forEach(function (pg, p) { walkList(pg.list, ['events', e, 'pages', p, 'list']); }); + }); + } else if (fname === 'CommonEvents.json' && Array.isArray(obj)) { + obj.forEach(function (ce, c) { if (ce && ce.list) { walkList(ce.list, [c, 'list']); } }); + } else if (fname === 'Troops.json' && Array.isArray(obj)) { + obj.forEach(function (tr, t) { + if (tr && tr.pages) { tr.pages.forEach(function (pg, p) { walkList(pg.list, [t, 'pages', p, 'list']); }); } + }); + } + } + + function buildDupIndex() { + if (dupIndex) { return dupIndex; } + dupIndex = []; + if (!nodeOk) { return dupIndex; } + try { + var files = fs.readdirSync(DATA_DIR).filter(function (f) { + return /^Map\d+\.json$/.test(f) || f === 'CommonEvents.json' || f === 'Troops.json'; + }); + files.forEach(function (fname) { + var abs = dataFile(fname); + var c = readFileCached(abs); + if (!c) { return; } + var obj; + try { obj = JSON.parse(c.text); } catch (e) { return; } + collectMessages(abs, fname, obj, function (pathArr, text) { + dupIndex.push({ file: abs, path: pathArr, text: text }); + }); + }); + log('dup index built', dupIndex.length, 'messages'); + } catch (e) { log('dup index', e); } + return dupIndex; + } + + function normText(s, ignoreNl) { + return ignoreNl ? s.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : s; + } + + function findOccurrences(text, ignoreNl) { + var idx = buildDupIndex(); + var key = normText(text, ignoreNl); + var out = []; + for (var i = 0; i < idx.length; i++) { + if (normText(idx[i].text, ignoreNl) === key) { out.push(idx[i]); } + } + return out; + } + + // Structural match: the running interpreter told us the event id + command index, + // so even if the list was a deep copy (reference match failed), we can find the + // ON-DISK command at that index whose text matches. With the event id this is + // almost always unique = confident, exact location. + function structuralCandidates(r) { + var out = []; + if (r.srcIndex == null) { return out; } + var idx = r.srcIndex, want = r.text; + function check(list, file, prefix) { + if (!Array.isArray(list) || idx < 0 || idx >= list.length) { return; } + var cmd = list[idx]; + if (!cmd || !cmd.parameters) { return; } + if ((cmd.code === 401 || cmd.code === 405) && cmd.parameters[0] === want) { + out.push({ file: file, path: prefix.concat([idx, 'parameters', 0]) }); + } else if (cmd.code === 102 && Array.isArray(cmd.parameters[0])) { + var n = cmd.parameters[0].indexOf(want); + if (n >= 0) { out.push({ file: file, path: prefix.concat([idx, 'parameters', 0, n]) }); } + } + } + try { + if (typeof $dataMap !== 'undefined' && $dataMap && $dataMap.events && + typeof $gameMap !== 'undefined' && $gameMap) { + var mf = mapFile($gameMap.mapId()); + if (r.srcEventId > 0 && $dataMap.events[r.srcEventId] && $dataMap.events[r.srcEventId].pages) { + $dataMap.events[r.srcEventId].pages.forEach(function (pg, p) { + check(pg.list, mf, ['events', r.srcEventId, 'pages', p, 'list']); + }); + } else { + $dataMap.events.forEach(function (ev, e) { + if (ev && ev.pages) { ev.pages.forEach(function (pg, p) { check(pg.list, mf, ['events', e, 'pages', p, 'list']); }); } + }); + } + } + if (!out.length && typeof $dataCommonEvents !== 'undefined' && $dataCommonEvents) { + $dataCommonEvents.forEach(function (ce, c) { if (ce && ce.list) { check(ce.list, dataFile('CommonEvents.json'), [c, 'list']); } }); + } + if (!out.length && typeof $dataTroops !== 'undefined' && $dataTroops && + typeof $gameTroop !== 'undefined' && $gameTroop && $gameTroop._troopId) { + var tr = $dataTroops[$gameTroop._troopId]; + if (tr && tr.pages) { tr.pages.forEach(function (pg, p) { check(pg.list, dataFile('Troops.json'), [$gameTroop._troopId, 'pages', p, 'list']); }); } + } + } catch (e) { log('structural', e); } + var uniq = [], seen = {}; + out.forEach(function (o) { var k = o.file + JSON.stringify(o.path); if (!seen[k]) { seen[k] = 1; uniq.push(o); } }); + return uniq; + } + + // Resolve an unresolved record: structural match (confident) first, then a global + // text-index search as a last resort. + function resolveRecord(r) { + if (r.file || r._tried || !nodeOk) { return; } + r._tried = true; + var cands = structuralCandidates(r); + if (cands.length === 1) { + r.file = cands[0].file; r.tail = cands[0].path; + r.label = '(located: event ' + (r.srcEventId || '?') + ')'; + return; + } + if (cands.length > 1) { r._candidates = cands; r._ambig = cands.length; return; } + var occ = findOccurrences(r.text, false); + if (occ.length === 0) { occ = findOccurrences(r.text, true); } // tolerate \n/wordwrap diffs + if (occ.length === 1) { + r.file = occ[0].file; r.tail = occ[0].path; r.label = '(located by text)'; + } else if (occ.length > 1) { + r._candidates = occ.map(function (o) { return { file: o.file, path: o.path }; }); + r._ambig = occ.length; + } + } + + //========================================================================= + // Literal search across data + plugin / system files (for UI text like + // "First kiss: None" that lives in plugins.js parameters, or event text that a + // plugin re-renders as UI — e.g. "Passed the preliminaries" inside a Map JSON). + //========================================================================= + var _scanFiles = null; + function pluginAndSystemFiles() { + if (_scanFiles) { return _scanFiles; } + _scanFiles = []; + if (!nodeOk) { return _scanFiles; } + try { + // Most reliable sources for UI labels first: database files (item/skill + // names + descriptions, terms), then the event data (Maps), then plugin + // parameters (plugins.js), then plugin code last (where a literal is most + // likely a false positive). + ['System', 'Items', 'Weapons', 'Armors', 'Skills', 'States', 'Actors', + 'Classes', 'Enemies', 'Troops', 'CommonEvents', 'MapInfos', 'Animations', 'Tilesets'] + .forEach(function (n) { _scanFiles.push(dataFile(n + '.json')); }); + // Map JSONs hold event message text that plugins may draw as UI (battle/ + // arena results, custom windows). Numeric-sorted so results read naturally. + try { + fs.readdirSync(DATA_DIR) + .filter(function (f) { return /^Map\d+\.json$/.test(f); }) + .sort(function (a, b) { + return parseInt(a.match(/\d+/)[0], 10) - parseInt(b.match(/\d+/)[0], 10); + }) + .forEach(function (f) { _scanFiles.push(dataFile(f)); }); + } catch (e) { /* ignore */ } + var jsDir = path.join(WWW_ROOT, 'js'); + _scanFiles.push(path.join(jsDir, 'plugins.js')); + var pdir = path.join(jsDir, 'plugins'); + fs.readdirSync(pdir).forEach(function (f) { + if (/\.js$/.test(f) && f !== 'TLInspector.js') { _scanFiles.push(path.join(pdir, f)); } + }); + } catch (e) { log('scanFiles', e); } + return _scanFiles; + } + + function isWordChar(ch) { return !!ch && /[A-Za-z0-9_]/.test(ch); } + + // Build (and cache on the file entry) the character ranges of JS comments, so we + // can drop matches that live in comments (which never render). A small lexer that + // also tracks string literals so `//` inside a string isn't treated as a comment. + function commentRangesFor(c) { + if (c._comments) { return c._comments; } + var t = c.text, ranges = [], i = 0, n = t.length, state = 0, start = 0; + while (i < n) { + var ch = t[i], nx = t[i + 1]; + if (state === 0) { + if (ch === '/' && nx === '/') { state = 1; start = i; i += 2; continue; } + if (ch === '/' && nx === '*') { state = 2; start = i; i += 2; continue; } + if (ch === "'") { state = 3; } else if (ch === '"') { state = 4; } else if (ch === '`') { state = 5; } + i++; continue; + } + if (state === 1) { if (ch === '\n') { ranges.push([start, i]); state = 0; } i++; continue; } + if (state === 2) { if (ch === '*' && nx === '/') { ranges.push([start, i + 2]); state = 0; i += 2; continue; } i++; continue; } + if (ch === '\\') { i += 2; continue; } // escape inside a string + if ((state === 3 && ch === "'") || (state === 4 && ch === '"') || (state === 5 && ch === '`')) { state = 0; } + i++; + } + if (state === 1 || state === 2) { ranges.push([start, n]); } + c._comments = ranges; + return ranges; + } + function inComment(c, pos) { + var r = commentRangesFor(c), lo = 0, hi = r.length - 1; + while (lo <= hi) { + var mid = (lo + hi) >> 1; + if (pos < r[mid][0]) { hi = mid - 1; } + else if (pos >= r[mid][1]) { lo = mid + 1; } + else { return true; } + } + return false; + } + + function searchFiles(text) { + var results = [], seen = {}; + if (!nodeOk || !text) { return results; } + var escaped = JSON.stringify(text).slice(1, -1); // JSON-escaped form (\n etc.) + var variants = escaped === text ? [text] : [text, escaped]; // raw form + escaped, deduped + pluginAndSystemFiles().forEach(function (file) { + var c = readFileCached(file); + if (!c) { return; } + var isJs = /\.js$/i.test(file); + variants.forEach(function (v) { + if (!v) { return; } + var checkL = isWordChar(v[0]), checkR = isWordChar(v[v.length - 1]); + var pos = c.text.indexOf(v); + while (pos >= 0) { + // Skip matches embedded inside a larger word/identifier, e.g. "Tools" + // inside "isDevToolsOpen", and matches inside JS comments (never render). + var embedded = (checkL && isWordChar(c.text[pos - 1])) || + (checkR && isWordChar(c.text[pos + v.length])); + if (!embedded && !(isJs && inComment(c, pos))) { + var lc = offsetToLineCol(c, pos); + var k = file + ':' + lc.line; + // A complete JSON string value ("text") is a high-confidence + // match (e.g. a menu term) vs. text buried in a longer string. + var quoted = c.text[pos - 1] === '"' && c.text[pos + v.length] === '"'; + if (!seen[k]) { seen[k] = 1; results.push({ file: file, line: lc.line, col: lc.col, quoted: quoted }); } + } + pos = c.text.indexOf(v, pos + v.length); + } + }); + }); + // Stable sort: exact quoted-value matches first, original (data-first) order otherwise. + results.forEach(function (r, i) { r._i = i; }); + results.sort(function (a, b) { return (b.quoted - a.quoted) || (a._i - b._i); }); + return results; + } + + // Exact literal first; if nothing, trim trailing words (handles "Label: " + // where the value isn't in the file) until something matches. + function searchFilesSmart(text) { + var r = searchFiles(text); + if (r.length) { return { results: r, query: text, partial: false }; } + var words = text.replace(/\n/g, ' ').split(/\s+/).filter(Boolean); + for (var n = words.length - 1; n >= 1; n--) { + var sub = words.slice(0, n).join(' '); + if (sub.length < 3) { break; } + var rr = searchFiles(sub); + if (rr.length) { return { results: rr, query: sub, partial: true }; } + } + return { results: [], query: text, partial: false }; + } + + //========================================================================= + // Overlay UI + //========================================================================= + var ui = { root: null, body: null, tab: 'text', highlight: null, hlLayer: null, + open: false, refreshTimer: 0, side: 'right', pick: false, catcher: null, + pickLabel: null, _pickHits: null, selection: null, ignoreNl: false, freeze: false }; + try { ui.side = window.localStorage.getItem('tlins.side') || 'right'; } catch (e) { /* ignore */ } + try { ui.ignoreNl = window.localStorage.getItem('tlins.ignl') === '1'; } catch (e) { /* ignore */ } + try { ui.freeze = window.localStorage.getItem('tlins.freeze') === '1'; } catch (e) { /* ignore */ } + + function applySide() { + if (!ui.root) { return; } + var left = ui.side === 'left'; + ui.root.style.left = left ? '0' : 'auto'; + ui.root.style.right = left ? 'auto' : '0'; + ui.root.style.boxShadow = (left ? '2px' : '-2px') + ' 0 12px rgba(0,0,0,0.6)'; + } + + function flipSide() { + ui.side = ui.side === 'left' ? 'right' : 'left'; + try { window.localStorage.setItem('tlins.side', ui.side); } catch (e) { /* ignore */ } + applySide(); + } + + function buildUI() { + if (ui.root) { return; } + var root = document.createElement('div'); + root.id = 'tl-inspector'; + root.style.cssText = [ + 'position:fixed', 'top:0', 'right:0', 'width:460px', 'height:100%', + 'background:rgba(20,22,28,0.96)', 'color:#e6e6e6', 'z-index:2147483646', + 'font:12px/1.45 Consolas,Menlo,monospace', 'box-shadow:-2px 0 12px rgba(0,0,0,0.6)', + 'display:none', 'flex-direction:column' + ].join(';'); + + var head = document.createElement('div'); + head.style.cssText = 'padding:8px 10px;background:#11131a;border-bottom:1px solid #333;display:flex;align-items:center;gap:8px;flex:0 0 auto'; + head.innerHTML = + 'TL Inspector' + + 'Text' + + 'Images' + + '⌖ Inspect' + + '' + + '' + + '' + + '' + + ''; + root.appendChild(head); + + var body = document.createElement('div'); + body.style.cssText = 'flex:1 1 auto;overflow:auto;padding:6px 8px'; + root.appendChild(body); + + var style = document.createElement('style'); + style.textContent = + '#tl-inspector .tl-tab{cursor:pointer;padding:2px 8px;border-radius:4px;background:#222;color:#bbb}' + + '#tl-inspector .tl-tab.on{background:#2a5db0;color:#fff}' + + '#tl-inspector .tl-btn{cursor:pointer;padding:2px 7px;border-radius:4px;background:#333}' + + '#tl-inspector .tl-btn.on{background:#3a7;color:#022}' + + '#tl-inspector .tl-btn:hover,#tl-inspector .tl-tab:hover{filter:brightness(1.3)}' + + '#tl-inspector .tl-row{border:1px solid #2b2f3a;border-radius:6px;padding:6px 8px;margin:0 0 6px;background:#1b1e26}' + + '#tl-inspector .tl-row.live{border-color:#3a7;background:#1b2620}' + + '#tl-inspector .tl-txt{color:#fff;white-space:pre-wrap;word-break:break-word;margin-bottom:4px;' + + '-webkit-user-select:text;user-select:text;cursor:text}' + + '#tl-inspector .tl-meta{color:#8aa;font-size:11px;display:flex;gap:8px;flex-wrap:wrap;align-items:center}' + + '#tl-inspector .tl-loc{color:#9cd;cursor:pointer;text-decoration:underline}' + + '#tl-inspector .tl-badge{font-size:10px;padding:0 5px;border-radius:8px;background:#345}' + + '#tl-inspector .tl-badge.choice{background:#534}' + + '#tl-inspector .tl-badge.ui{background:#553}' + + '#tl-inspector .tl-badge.live{background:#3a7;color:#022}' + + '#tl-inspector .tl-mini{cursor:pointer;color:#cda;text-decoration:underline}' + + '#tl-inspector .tl-imgwrap{display:flex;gap:8px;align-items:flex-start}' + + '#tl-inspector .tl-thumb{width:60px;height:60px;object-fit:contain;border:1px solid #333;border-radius:4px;flex:0 0 auto;' + + 'background-image:linear-gradient(45deg,#2a2a2a 25%,transparent 25%),linear-gradient(-45deg,#2a2a2a 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#2a2a2a 75%),linear-gradient(-45deg,transparent 75%,#2a2a2a 75%);background-size:12px 12px;background-position:0 0,0 6px,6px -6px,-6px 0;background-color:#15171d;image-rendering:pixelated}' + + '#tl-inspector .tl-noimg{display:flex;align-items:center;justify-content:center;color:#556;font-size:18px}' + + '#tl-inspector .tl-imginfo{flex:1;min-width:0}' + + '#tl-inspector .tl-dups{margin-top:6px;padding:4px 0 2px 8px;border-left:2px solid #46506a}' + + '#tl-inspector .tl-dup{color:#9cd;cursor:pointer;text-decoration:underline;display:block;margin:2px 0}' + + '#tl-inspector .tl-edit{margin-top:6px;display:none}' + + '#tl-inspector .tl-edit textarea{width:100%;min-height:64px;box-sizing:border-box;' + + 'background:#0f1118;color:#fff;border:1px solid #46506a;border-radius:4px;' + + 'padding:6px 8px;font:inherit;resize:vertical}' + + '#tl-inspector .tl-edit-actions{display:flex;gap:10px;margin-top:4px}' + + '#tl-inspector .tl-bar{margin:0 0 8px;color:#9ab}' + + '#tl-inspector .tl-bar input{vertical-align:middle}' + + '#tl-inspector .tl-empty{color:#778;padding:20px;text-align:center}'; + root.appendChild(style); + + document.body.appendChild(root); + ui.root = root; ui.body = body; + applySide(); + + // Stop panel mouse/wheel/touch events from reaching the game's document-level + // input handlers. Fixes: clicking the scrollbar/buttons driving the character, + // and the mouse wheel not scrolling (the game preventDefault()s wheel events). + // Keyboard: stop bubble propagation so RPG Maker's Input (window keydown) + // never sees Enter/Backspace/etc. while typing in the panel or edit box. + ['mousedown', 'mouseup', 'click', 'dblclick', 'wheel', 'contextmenu', + 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'pointerup'].forEach(function (t) { + root.addEventListener(t, function (ev) { ev.stopPropagation(); }, false); + }); + ['keydown', 'keyup', 'keypress'].forEach(function (t) { + root.addEventListener(t, function (ev) { + if (ev.key === CFG.hotkey || + (CFG.liveRefresh && ev.key === CFG.liveRefreshHotkey)) { return; } + ev.stopPropagation(); + }, false); + }); + + var frz = root.querySelector('[data-act="freeze"]'); + if (frz) { + frz.addEventListener('change', function () { + ui.freeze = this.checked; + try { window.localStorage.setItem('tlins.freeze', ui.freeze ? '1' : ''); } catch (e) { /* ignore */ } + toast(ui.freeze ? 'Game controls frozen while editor is open' : 'Game controls unfrozen'); + }); + } + + head.addEventListener('click', function (ev) { + var t = ev.target.getAttribute('data-tab'); + var a = ev.target.getAttribute('data-act'); + if (t) { ui.tab = t; render(); } + else if (a === 'refresh') { render(); } + else if (a === 'pick') { setPick(!ui.pick); } + else if (a === 'flip') { flipSide(); } + else if (a === 'close') { toggle(false); } + }); + + var hl = document.createElement('div'); + hl.style.cssText = 'position:fixed;border:2px solid #ff5;background:rgba(255,255,0,0.12);z-index:2147483645;pointer-events:none;display:none'; + document.body.appendChild(hl); + ui.highlight = hl; + + var pl = document.createElement('div'); + pl.style.cssText = 'position:fixed;background:#000d;color:#9f9;padding:3px 7px;border-radius:4px;font:11px monospace;z-index:2147483647;pointer-events:none;display:none;max-width:60vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'; + document.body.appendChild(pl); + ui.pickLabel = pl; + + var hlz = document.createElement('div'); // container for multiple pick rects + hlz.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483645;pointer-events:none;display:none'; + document.body.appendChild(hlz); + ui.hlLayer = hlz; + } + + function setTabStyles() { + var tabs = ui.root.querySelectorAll('.tl-tab'); + for (var i = 0; i < tabs.length; i++) { + tabs[i].classList.toggle('on', tabs[i].getAttribute('data-tab') === ui.tab); + } + } + + function esc(s) { + return String(s).replace(/[&<>]/g, function (c) { + return c === '&' ? '&' : c === '<' ? '<' : '>'; + }); + } + + function render() { + if (!ui.root || !ui.open) { return; } + setTabStyles(); + var st = ui.body.scrollTop; // preserve scroll across rebuilds + ui.body.innerHTML = ''; + if (ui.tab === 'text') { renderText(); } else { renderImages(); } + ui.body.scrollTop = st; + } + + // Signature of what's currently on screen + how many thumbs are ready, so the + // auto-refresh only rebuilds the Images tab when something actually changed + // (otherwise the scrollbar would keep snapping back to the top). + function onScreenSignature() { + var urls = collectOnScreenImages().map(function (i) { return i.url; }); + var ready = urls.filter(function (u) { return thumbCache[u]; }).length; + return urls.join('|') + '#' + ready + '#' + (ui.selection ? ui.selection.length : 0); + } + + function renderText() { + var bar = htmlEl('
' + + '' + + '' + + 'clear history (' + textRecords.length + ')
'); + bar.querySelector('input').addEventListener('change', function () { + ui.ignoreNl = this.checked; + try { window.localStorage.setItem('tlins.ignl', ui.ignoreNl ? '1' : ''); } catch (e) { /* ignore */ } + }); + bar.querySelector('[data-act="clearhist"]').addEventListener('click', function () { + textRecords.length = 0; + currentGroup = -1; + render(); + }); + ui.body.appendChild(bar); + + if (!textRecords.length) { + ui.body.appendChild(htmlEl('
No text captured yet.
Trigger a message in-game.
')); + return; + } + var msgBusy = (typeof $gameMessage !== 'undefined') && $gameMessage && $gameMessage.isBusy && $gameMessage.isBusy(); + for (var i = textRecords.length - 1; i >= 0; i--) { + var r = textRecords[i]; + if (!r.file) { resolveRecord(r); } // text-search fallback for identity misses + var loc = (r.file && r.tail) ? locateLine(r.file, r.tail) : null; + var line = loc ? loc.line : (r.uiLine || null); + var col = loc ? loc.col : null; + var live = msgBusy && r.group === currentGroup && currentGroup !== -1; + + var row = document.createElement('div'); + row.className = 'tl-row' + (live ? ' live' : ''); + + var fileShort = r.file ? r.file.replace(/^.*[\\\/]/, '') : null; + var locText = fileShort ? (fileShort + (line ? ':' + line : '')) + : (r._ambig ? r._ambig + ' candidates — click "locate"' : r.label); + + row.innerHTML = + '
' + esc(r.text) + '
' + + '
' + + '' + r.kind + '' + + (live ? 'LIVE' : '') + + '' + esc(locText) + '' + + '' + + (r.kind === 'ui' ? 'locate in files' : (r.file ? 'find duplicates' : 'locate in files')) + '' + + (canEditRecord(r) ? 'edit' : '') + + '
' + + '' + + ''; + + (function (rec, ln, cl, rowEl) { + var locEl = rowEl.querySelector('.tl-loc'); + if (locEl) { + locEl.addEventListener('click', function () { + if (rec.file) { openInEditor(rec.file, ln, cl); } + else { toast('Unresolved: ' + rec.label); } + }); + } + var dupBtn = rowEl.querySelector('[data-act="dups"]'); + var dupsEl = rowEl.querySelector('.tl-dups'); + if (dupBtn) { + dupBtn.addEventListener('click', function () { + if (dupsEl.style.display === 'none') { + dupsEl.style.display = 'block'; + dupsEl.innerHTML = 'searching…'; + setTimeout(function () { populateDups(dupsEl, rec); }, 0); + } else { dupsEl.style.display = 'none'; } + }); + } + var editPanel = rowEl.querySelector('.tl-edit'); + var editBtn = rowEl.querySelector('[data-act="edit"]'); + if (editBtn && editPanel) { + var ta = editPanel.querySelector('textarea'); + editBtn.addEventListener('click', function () { + function openEditor() { + ta.value = rec.text; + editPanel.style.display = 'block'; + ta.focus(); + try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch (e) { /* ignore */ } + } + if (rec.kind === 'ui' && !rec.tail && !rec._editSite) { + prepareUiEditTarget(rec, function (ok) { if (ok) { openEditor(); } }); + } else { + openEditor(); + } + }); + editPanel.querySelector('[data-act="save"]').addEventListener('click', function () { + if (saveRecordEdit(rec, ta.value)) { editPanel.style.display = 'none'; } + }); + editPanel.querySelector('[data-act="canceledit"]').addEventListener('click', function () { + editPanel.style.display = 'none'; + }); + } + })(r, line, col, row); + + ui.body.appendChild(row); + } + } + + function populateDups(container, rec) { + container.innerHTML = ''; + var MAXSHOW = 150; + var note = '', total = 0, shown = []; + + if (rec.kind === 'ui') { + // UI/plugin text: literal search across data + plugin files. + var s = searchFilesSmart(rec.text); + total = s.results.length; + note = total + ' match' + (total === 1 ? '' : 'es') + + (s.partial ? ' for "' + s.query + '" (value part omitted)' : ''); + shown = s.results.slice(0, MAXSHOW).map(function (o) { return { file: o.file, line: o.line, col: o.col, quoted: o.quoted }; }); + } else { + // Dialogue/choice text: list every identical line in the data files. + // IMPORTANT: slice BEFORE resolving line numbers — a frequent speaker name + // ("Yui") has thousands of hits and locating every one would freeze the game. + var occ = findOccurrences(rec.text, ui.ignoreNl); + total = occ.length; + note = total + ' occurrence' + (total === 1 ? '' : 's') + (ui.ignoreNl ? ' (ignoring \\n)' : ''); + shown = occ.slice(0, MAXSHOW).map(function (o) { + var l = locateLine(o.file, o.path); + return { file: o.file, line: l && l.line, col: l && l.col, + isSelf: rec.file === o.file && JSON.stringify(rec.tail) === JSON.stringify(o.path) }; + }); + } + + if (!total) { + container.innerHTML = 'no matches found' + + (rec.kind === 'ui' ? ' (text may be built at runtime)' : '') + ''; + return; + } + var head = htmlEl('
' + note + + (total > MAXSHOW ? ' — showing first ' + MAXSHOW : '') + + '  open all (' + shown.length + ')
'); + container.appendChild(head); + shown.forEach(function (x) { + var fn = x.file.replace(/^.*[\\\/]/, ''); + // For UI/file searches, flag whether the match is the WHOLE string value + // ("exact") or just contained inside a longer one ("within longer text"). + var tag = (x.quoted === true) ? ' exact' + : (x.quoted === false) ? ' within longer text' : ''; + var el = htmlEl('' + esc(fn) + (x.line ? ':' + x.line : '') + tag + + (x.isSelf ? ' (this line)' : '') + ''); + el.addEventListener('click', function () { + if (rec.kind === 'ui' && x.line) { + if (x.quoted === false) { + toast('Match is inside a longer string — pick an exact match or use VSCode'); + return; + } + if (bindEditSite(rec, x.file, x.line, x.col || 1)) { + scheduleRefresh(); + toast('Exact match selected — click edit to change this string'); + } else { + toast('Could not bind a safe edit target — use VSCode'); + } + return; + } + openInEditor(x.file, x.line, x.col); + }); + container.appendChild(el); + }); + head.querySelector('[data-act="openall"]').addEventListener('click', function () { + if (shown.length > 8 && !window.confirm('Open ' + shown.length + ' files in the editor?')) { return; } + shown.forEach(function (x) { openInEditor(x.file, x.line, x.col); }); + }); + } + + function renderImages() { + // Objects captured from an Inspect click (the full stack under the cursor) + if (ui.selection && ui.selection.length) { + var selHead = htmlEl('
' + + 'Under cursor (' + ui.selection.length + ')' + + 'clear
'); + selHead.querySelector('[data-act="clearsel"]').addEventListener('click', function () { ui.selection = null; render(); }); + ui.body.appendChild(selHead); + ui.selection.forEach(function (sel) { + ui.body.appendChild(buildImgRow(sel.url, sel.bitmap, { screen: sel.screen })); + }); + ui.body.appendChild(htmlEl('
')); + } + + var live = collectOnScreenImages(); + ui.body.appendChild(htmlEl('
On screen now (' + live.length + ')
')); + if (!live.length) { + ui.body.appendChild(htmlEl('
No image sprites on screen.
')); + } + live.forEach(function (img) { + ui.body.appendChild(buildImgRow(img.url, img.bitmap, { screen: img.screen, trigger: imageTriggers[img.url] })); + }); + + // recent loads (history) + ui.body.appendChild(htmlEl('
Recently loaded
')); + for (var i = imageLog.length - 1; i >= 0 && i > imageLog.length - 25; i--) { + var it = imageLog[i]; + ui.body.appendChild(buildImgRow(it.url, it.bitmap, { fromLabel: it.label })); + } + } + + function htmlEl(html) { + var d = document.createElement('div'); + d.innerHTML = html; + return d.firstChild; + } + + function showHighlight(s) { + if (!s || !ui.highlight) { return; } + var h = ui.highlight; + h.style.left = s.x + 'px'; h.style.top = s.y + 'px'; + h.style.width = s.w + 'px'; h.style.height = s.h + 'px'; + h.style.display = 'block'; + } + function hideHighlight() { if (ui.highlight) { ui.highlight.style.display = 'none'; } } + + //========================================================================= + // Inspect / pick mode: hover the game canvas to highlight the image object + // under the cursor (topmost), click to reveal its source file. + //========================================================================= + function canvasScale(rect) { + return (rect && typeof Graphics !== 'undefined' && Graphics.width) ? rect.width / Graphics.width : 1; + } + + function boundsToScreen(b, rect, scale) { + return { x: rect.left + b.x * scale, y: rect.top + b.y * scale, + w: b.width * scale, h: b.height * scale }; + } + + // ALL bitmap objects whose bounds contain the point, bottom-to-top draw order. + function hitTestAll(stageX, stageY) { + var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene; + var hits = []; + if (!scene) { return hits; } + (function walk(obj) { + if (!obj || obj.visible === false) { return; } + var bmp = obj.bitmap; + if (bmp && bmp._url) { + var vis = ('worldVisible' in obj) ? obj.worldVisible : true; + if (vis) { + var b = null; + try { b = obj.getBounds && obj.getBounds(); } catch (e) { b = null; } + if (b && b.width > 0 && b.height > 0 && + stageX >= b.x && stageX <= b.x + b.width && + stageY >= b.y && stageY <= b.y + b.height) { + hits.push({ url: decodeUrl(bmp._url), bounds: b, bitmap: bmp }); + } + } + } + var kids = obj.children; + if (kids) { for (var i = 0; i < kids.length; i++) { walk(kids[i]); } } + })(scene); + return hits; // index 0 = bottom-most, last = top-most + } + + function showHighlights(rects) { // multiple rects; last (top-most) is brighter + var layer = ui.hlLayer; + if (!layer) { return; } + layer.innerHTML = ''; + rects.forEach(function (r, i) { + var top = i === rects.length - 1; + var d = document.createElement('div'); + d.style.cssText = 'position:fixed;pointer-events:none;left:' + r.x + 'px;top:' + r.y + + 'px;width:' + r.w + 'px;height:' + r.h + 'px;border:2px ' + + (top ? 'solid #ff5' : 'dashed #6cf') + ';background:' + + (top ? 'rgba(255,255,0,0.12)' : 'rgba(80,170,255,0.06)'); + layer.appendChild(d); + }); + layer.style.display = 'block'; + } + function hideHighlights() { if (ui.hlLayer) { ui.hlLayer.innerHTML = ''; ui.hlLayer.style.display = 'none'; } } + + function onPickMove(ev) { + var canvas = getGameCanvas(); + if (!canvas) { return; } + var rect = canvas.getBoundingClientRect(); + var scale = canvasScale(rect); + var sx = (ev.clientX - rect.left) / scale, sy = (ev.clientY - rect.top) / scale; + var hits = hitTestAll(sx, sy); + if (hits.length) { + showHighlights(hits.map(function (h) { return boundsToScreen(h.bounds, rect, scale); })); + var topName = hits[hits.length - 1].url.replace(/^.*\//, ''); + ui.pickLabel.innerHTML = + (hits.length > 1 ? '' + hits.length + ' objects here — click to list
' : '') + + 'top: ' + esc(topName); + ui.pickLabel.style.left = Math.min(ev.clientX + 12, window.innerWidth - 260) + 'px'; + ui.pickLabel.style.top = (ev.clientY + 14) + 'px'; + ui.pickLabel.style.display = 'block'; + ui._pickHits = hits.map(function (h) { + return { url: h.url, bitmap: h.bitmap, screen: boundsToScreen(h.bounds, rect, scale) }; + }); + } else { + hideHighlights(); + ui.pickLabel.style.display = 'none'; + ui._pickHits = null; + } + } + + // Click does NOT open anything: it lists every object under the cursor in the + // panel, where the user can highlight/reveal each one deliberately. + function onPickClick(ev) { + ev.preventDefault(); ev.stopPropagation(); + var hits = ui._pickHits; + setPick(false); + if (hits && hits.length) { + ui.selection = hits; + ui.tab = 'images'; + render(); + } + } + + function setPick(on) { + buildUI(); + ui.pick = !!on; + var btn = ui.root.querySelector('[data-act="pick"]'); + if (btn) { btn.classList.toggle('on', ui.pick); } + if (ui.pick) { + var canvas = getGameCanvas(); + var rect = canvas ? canvas.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; + if (!ui.catcher) { + ui.catcher = document.createElement('div'); + ui.catcher.style.cssText = 'position:fixed;z-index:2147483644;cursor:crosshair;background:transparent'; + document.body.appendChild(ui.catcher); + ui.catcher.addEventListener('mousemove', onPickMove); + ui.catcher.addEventListener('click', onPickClick, true); + // Swallow all pointer input over the canvas while inspecting, so the + // game never reacts (this also keeps "freeze" effective during Inspect). + ['mousedown', 'mouseup', 'dblclick', 'wheel', 'contextmenu', 'mousemove', + 'touchstart', 'touchmove', 'touchend', 'pointerdown', 'pointerup', 'pointermove'].forEach(function (t) { + ui.catcher.addEventListener(t, function (e) { e.stopPropagation(); }, false); + }); + } + ui.catcher.style.left = rect.left + 'px'; + ui.catcher.style.top = rect.top + 'px'; + ui.catcher.style.width = rect.width + 'px'; + ui.catcher.style.height = rect.height + 'px'; + ui.catcher.style.display = 'block'; + toast('Inspect: hover an object, click to reveal. Esc/Inspect to exit.'); + } else { + if (ui.catcher) { ui.catcher.style.display = 'none'; } + hideHighlight(); + hideHighlights(); + if (ui.pickLabel) { ui.pickLabel.style.display = 'none'; } + } + } + + var refreshPending = false; + function scheduleRefresh() { + if (refreshPending || !ui.open) { return; } + refreshPending = true; + setTimeout(function () { refreshPending = false; render(); }, 60); + } + + function toggle(force) { + buildUI(); + ui.open = (typeof force === 'boolean') ? force : !ui.open; + ui.root.style.display = ui.open ? 'flex' : 'none'; + if (!ui.open && ui.pick) { setPick(false); } + hideHighlight(); + if (ui.open) { + render(); + if (!ui.refreshTimer) { + ui.refreshTimer = setInterval(function () { + if (ui.open && ui.tab === 'images') { + var sig = onScreenSignature(); + if (sig !== ui._imgSig) { ui._imgSig = sig; render(); } + } + }, 700); + } + } + } + + //========================================================================= + // Live refresh — hot-reload data/*.json from external editor saves (NW.js) + //========================================================================= + var _selfWrittenFiles = {}; // absPath -> timestamp (skip watcher echo from overlay saves) + var _pendingStable = {}; // absPath -> { lastMt, stableSince, timer } + var _watcherStarted = false; + var _pollTimer = null; + var _watchScanTimer = 0; + var _knownMtimes = {}; // absPath -> mtimeMs (poll-based change detection) + var DB_FILES = {}; // 'Items.json' -> { global: '$dataItems', meta: true } + + function absPath(file) { + if (!file || !path) { return file; } + try { return path.resolve(file); } catch (e) { return file; } + } + + function syncMtimeCache(file) { + if (!nodeOk || !file) { return; } + try { + _knownMtimes[absPath(file)] = fs.statSync(absPath(file)).mtimeMs; + } catch (e) { /* ignore */ } + } + + function markSelfWrite(file) { + if (!CFG.liveRefresh || !file) { return; } + _selfWrittenFiles[absPath(file)] = Date.now(); + syncMtimeCache(file); + } + + function isSelfWrite(file) { + if (!file) { return false; } + var key = absPath(file); + var t = _selfWrittenFiles[key]; + if (!t) { return false; } + var windowMs = (CFG.liveRefreshStableMs || 120) + 600; + if (Date.now() - t < windowMs) { return true; } + delete _selfWrittenFiles[key]; + return false; + } + + function buildDbFileMap() { + DB_FILES = {}; + var entries = (typeof DataManager !== 'undefined' && DataManager._databaseFiles) + ? DataManager._databaseFiles : null; + if (entries && entries.length) { + entries.forEach(function (entry) { + DB_FILES[entry.src] = { + global: entry.name, + meta: entry.src !== 'System.json' + }; + }); + return; + } + // Fallback before DataManager is ready (MV/MZ standard database list) + [ + ['Actors.json', '$dataActors', true], + ['Classes.json', '$dataClasses', true], + ['Skills.json', '$dataSkills', true], + ['Items.json', '$dataItems', true], + ['Weapons.json', '$dataWeapons', true], + ['Armors.json', '$dataArmors', true], + ['Enemies.json', '$dataEnemies', true], + ['Troops.json', '$dataTroops', true], + ['States.json', '$dataStates', true], + ['Animations.json', '$dataAnimations', true], + ['Tilesets.json', '$dataTilesets', true], + ['CommonEvents.json', '$dataCommonEvents', true], + ['System.json', '$dataSystem', false], + ['MapInfos.json', '$dataMapInfos', true] + ].forEach(function (row) { + DB_FILES[row[0]] = { global: row[1], meta: row[2] }; + }); + } + + function parseMapId(fname) { + var m = /^Map(\d+)\.json$/i.exec(fname || ''); + return m ? parseInt(m[1], 10) : null; + } + + function loadJsonFromDisk(file) { + var ap = absPath(file); + invalidateFileCache(ap); + return JSON.parse(fs.readFileSync(ap, 'utf8')); + } + + function applyDatabaseGlobal(globalName, data, useMeta) { + window[globalName] = data; + if (useMeta && typeof DataManager !== 'undefined' && DataManager.extractArrayMetadata) { + DataManager.extractArrayMetadata(data); + } + } + + function refreshSceneWindows() { + try { + var scene = (typeof SceneManager !== 'undefined') && SceneManager._scene; + if (!scene || !scene._windowLayer) { return; } + var kids = scene._windowLayer.children; + if (!kids) { return; } + for (var i = 0; i < kids.length; i++) { + var w = kids[i]; + if (w && typeof w.refresh === 'function') { + try { w.refresh(); } catch (e) { /* ignore */ } + } + } + } catch (e) { log('refreshSceneWindows', e); } + } + + function reloadFile(filePath, options) { + options = options || {}; + if (!nodeOk || !fs || !path) { + if (!options.silent) { toast('Live refresh requires NW.js playtest'); } + return false; + } + var ap = absPath(filePath); + if (!fs.existsSync(ap)) { + if (!options.silent) { toast('File not found: ' + path.basename(ap)); } + return false; + } + var fname = path.basename(ap); + if (!/\.json$/i.test(fname)) { return false; } + + try { + var mapId = parseMapId(fname); + if (mapId != null) { + var currentMap = (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId) + ? $gameMap.mapId() : 0; + if (currentMap !== mapId) { + syncMtimeCache(ap); + if (!options.silent) { toast('Map file saved (switch maps to apply)'); } + return true; + } + if (typeof $dataMap !== 'undefined') { + $dataMap = loadJsonFromDisk(ap); + } + syncMtimeCache(ap); + if (!options.silent) { toast('Reloaded ' + fname); } + refreshSceneWindows(); + return true; + } + + if (!Object.keys(DB_FILES).length) { buildDbFileMap(); } + var spec = DB_FILES[fname]; + if (spec) { + applyDatabaseGlobal(spec.global, loadJsonFromDisk(ap), spec.meta); + syncMtimeCache(ap); + if (!options.silent) { toast('Reloaded ' + fname); } + refreshSceneWindows(); + return true; + } + + log('reload skip (unknown data file)', fname); + return false; + } catch (e) { + log('reload failed', fname, e); + if (!options.silent) { toast('Reload failed: ' + fname); } + return false; + } + } + + function reloadAllForPlaytest() { + if (!nodeOk) { toast('Live refresh requires NW.js playtest'); return; } + if (!Object.keys(DB_FILES).length) { buildDbFileMap(); } + var count = 0; + Object.keys(DB_FILES).forEach(function (fname) { + if (reloadFile(dataFile(fname), { silent: true })) { count++; } + }); + var mapId = (typeof $gameMap !== 'undefined' && $gameMap && $gameMap.mapId) + ? $gameMap.mapId() : 0; + if (mapId > 0 && reloadFile(mapFile(mapId), { silent: true })) { count++; } + refreshSceneWindows(); + toast('Reloaded database + current map (' + count + ' files)'); + } + + // VSCode (etc.) saves land on disk; wait until mtime stops changing, then reload. + function scheduleFileReload(filePath) { + if (!CFG.liveRefresh || !nodeOk) { return; } + waitForStableThenReload(absPath(filePath)); + } + + function waitForStableThenReload(ap) { + if (isSelfWrite(ap)) { return; } + var stableMs = CFG.liveRefreshStableMs || 120; + var checkMs = 50; + if (!_pendingStable[ap]) { + _pendingStable[ap] = { lastMt: null, stableSince: 0, timer: 0 }; + } + if (_pendingStable[ap].timer) { clearTimeout(_pendingStable[ap].timer); } + + function tick() { + if (isSelfWrite(ap)) { + delete _pendingStable[ap]; + return; + } + try { + if (!fs.existsSync(ap)) { + _pendingStable[ap].timer = setTimeout(tick, checkMs); + return; + } + var mt = fs.statSync(ap).mtimeMs; + var p = _pendingStable[ap]; + if (p.lastMt !== mt) { + p.lastMt = mt; + p.stableSince = Date.now(); + } else if (Date.now() - p.stableSince >= stableMs) { + delete _pendingStable[ap]; + reloadFile(ap); + return; + } + p.timer = setTimeout(tick, checkMs); + } catch (e) { + delete _pendingStable[ap]; + } + } + + _pendingStable[ap].timer = setTimeout(tick, checkMs); + } + + function seedMtimes() { + if (!nodeOk) { return; } + try { + fs.readdirSync(DATA_DIR).forEach(function (fname) { + if (!/\.json$/i.test(fname)) { return; } + syncMtimeCache(path.join(DATA_DIR, fname)); + }); + } catch (e) { log('seedMtimes', e); } + } + + function pollDataDirForChanges() { + if (!CFG.liveRefresh || !nodeOk) { return; } + try { + var names = fs.readdirSync(DATA_DIR); + for (var i = 0; i < names.length; i++) { + var fname = names[i]; + if (!/\.json$/i.test(fname)) { continue; } + var ap = path.join(DATA_DIR, fname); + try { + var mt = fs.statSync(ap).mtimeMs; + var prev = _knownMtimes[ap]; + if (prev != null && mt !== prev && !isSelfWrite(ap)) { + scheduleFileReload(ap); + } else { + _knownMtimes[ap] = mt; + } + } catch (e2) { /* ignore */ } + } + } catch (e) { /* ignore */ } + } + + function startLiveRefreshPoll() { + if (_pollTimer || !CFG.liveRefresh || !nodeOk) { return; } + seedMtimes(); + var ms = CFG.liveRefreshPollMs || 400; + _pollTimer = setInterval(pollDataDirForChanges, ms); + log('live refresh polling every', ms + 'ms'); + } + + function scheduleDataDirScan() { + if (!CFG.liveRefresh) { return; } + clearTimeout(_watchScanTimer); + _watchScanTimer = setTimeout(pollDataDirForChanges, 200); + } + + function startLiveRefreshWatcher() { + if (!CFG.liveRefresh || !nodeOk || _watcherStarted) { return; } + _watcherStarted = true; + buildDbFileMap(); + startLiveRefreshPoll(); + try { + fs.watch(DATA_DIR, function (eventType, filename) { + if (filename && /\.json$/i.test(filename)) { + scheduleFileReload(path.join(DATA_DIR, filename)); + } else { + // Windows fs.watch often fires with a null filename + scheduleDataDirScan(); + } + }); + log('live refresh watching', DATA_DIR); + } catch (e) { + log('live refresh watch failed (poll-only)', e); + } + } + + if (CFG.liveRefresh && nodeOk) { + if (typeof DataManager !== 'undefined') { + startLiveRefreshWatcher(); + } else { + var _waitDbTimer = setInterval(function () { + if (typeof DataManager !== 'undefined') { + clearInterval(_waitDbTimer); + startLiveRefreshWatcher(); + } + }, 100); + } + } + + //========================================================================= + // Toast + //========================================================================= + var toastEl = null, toastTimer = 0; + function toast(msg) { + if (!toastEl) { + toastEl = document.createElement('div'); + toastEl.style.cssText = 'position:fixed;bottom:18px;left:50%;transform:translateX(-50%);background:#222;color:#fff;padding:8px 14px;border-radius:6px;z-index:2147483647;font:12px monospace;box-shadow:0 2px 8px rgba(0,0,0,.5)'; + document.body.appendChild(toastEl); + } + toastEl.textContent = msg; + toastEl.style.display = 'block'; + clearTimeout(toastTimer); + toastTimer = setTimeout(function () { toastEl.style.display = 'none'; }, 2600); + } + + //========================================================================= + // Hotkey + //========================================================================= + document.addEventListener('keydown', function (e) { + if (e.key === CFG.hotkey) { + e.preventDefault(); + e.stopPropagation(); + toggle(); + } else if (CFG.liveRefresh && e.key === CFG.liveRefreshHotkey) { + e.preventDefault(); + e.stopPropagation(); + reloadAllForPlaytest(); + } else if (e.key === 'Escape' && ui.pick) { + e.preventDefault(); + e.stopPropagation(); + setPick(false); + } + }, true); + + // "Freeze game controls" guard: when enabled and the editor is open, block input + // events from reaching the game's document/window listeners. Events targeting the + // panel (or the Inspect catcher) and our own hotkeys are always allowed through. + function inPanel(node) { + return !!(node && ui.root && (ui.root.contains(node) || (ui.catcher && ui.catcher === node))); + } + function isEditableInPanel(node) { + if (!node || !ui.root || !ui.root.contains(node)) { return false; } + var tag = node.tagName; + return tag === 'TEXTAREA' || tag === 'INPUT' || node.isContentEditable; + } + function freezeGuard(e) { + if (!ui.open || !ui.freeze) { return; } + var isKey = (e.type === 'keydown' || e.type === 'keyup' || e.type === 'keypress'); + if (isKey) { + if (e.key === CFG.hotkey || e.key === 'Escape' || + (CFG.liveRefresh && e.key === CFG.liveRefreshHotkey)) { return; } + // Typing in the panel / edit box must keep normal browser behaviour + // (Backspace, Enter for newlines, etc.) — only block keys aimed at the game. + if (inPanel(e.target) || isEditableInPanel(document.activeElement)) { return; } + // Block the game from seeing this key. + e.stopPropagation(); + if (e.stopImmediatePropagation) { e.stopImmediatePropagation(); } + if (e.cancelable && !(e.ctrlKey || e.metaKey)) { e.preventDefault(); } + return; + } + if (inPanel(e.target)) { return; } // let the panel receive mouse/wheel/touch + e.stopPropagation(); + if (e.stopImmediatePropagation) { e.stopImmediatePropagation(); } + if (e.cancelable) { e.preventDefault(); } + } + ['mousedown', 'mouseup', 'click', 'dblclick', 'wheel', 'mousemove', 'contextmenu', + 'touchstart', 'touchmove', 'touchend', 'keydown', 'keyup', 'keypress', + 'pointerdown', 'pointerup', 'pointermove'].forEach(function (t) { + window.addEventListener(t, freezeGuard, true); // capture: runs before game handlers + }); + + window.TLInspector = { + open: function () { toggle(true); }, + close: function () { toggle(false); }, + reload: function (file) { return reloadFile(file); }, + reloadAll: function () { reloadAllForPlaytest(); return true; }, + records: textRecords, + images: imageLog, + cfg: CFG + }; + + var _readyMsg = 'ready - ' + CFG.hotkey + ' inspector'; + if (CFG.liveRefresh && nodeOk) { + _readyMsg += ', ' + CFG.liveRefreshHotkey + ' reload data (auto-watch on)'; + } + log(_readyMsg); +})(); diff --git a/util/tl_inspector/__init__.py b/util/tl_inspector/__init__.py new file mode 100644 index 0000000..895b10b --- /dev/null +++ b/util/tl_inspector/__init__.py @@ -0,0 +1,16 @@ +"""TL Inspector install helpers for RPG Maker MV/MZ (Idea: Sakura · Plugin: Kao_SSS).""" + +from util.tl_inspector.config import detect_editors, load_config, save_config +from util.tl_inspector.installer import apply_config, bundled_plugin_path, detect_engine, install, status, uninstall + +__all__ = [ + "apply_config", + "bundled_plugin_path", + "detect_editors", + "detect_engine", + "install", + "load_config", + "save_config", + "status", + "uninstall", +] diff --git a/util/tl_inspector/config.py b/util/tl_inspector/config.py new file mode 100644 index 0000000..0db0dfa --- /dev/null +++ b/util/tl_inspector/config.py @@ -0,0 +1,135 @@ +"""TL Inspector configuration — .env storage, editor detection, JS patching.""" + +from __future__ import annotations + +import json +import os +import re +import sys +from pathlib import Path + +from dotenv import dotenv_values + +ENV_EDITOR = "tlEditorCmd" + +CFG_KEYS = ("editorCmd", "workspaceFolder") + +DEFAULTS = { + "editorCmd": "auto", + "workspaceFolder": "auto", +} + +_PKG_ROOT = Path(__file__).resolve().parent +BUNDLED_PLUGIN = _PKG_ROOT / "TLInspector.js" +ENV_PATH = Path(".env") + + +def detect_editors() -> list[tuple[str, Path]]: + """Return installed code editors as (label, executable path) pairs.""" + out: list[tuple[str, Path]] = [] + seen: set[str] = set() + + def add(label: str, path: Path) -> None: + key = str(path).lower() + if key in seen or not path.is_file(): + return + seen.add(key) + out.append((label, path)) + + if sys.platform == "win32": + bases = [ + os.environ.get("LOCALAPPDATA"), + os.environ.get("ProgramFiles"), + os.environ.get("ProgramFiles(x86)"), + ] + for raw in bases: + if not raw: + continue + base = Path(raw) + add("VS Code", base / "Programs" / "Microsoft VS Code" / "Code.exe") + add("VS Code", base / "Microsoft VS Code" / "Code.exe") + add( + "VS Code Insiders", + base / "Programs" / "Microsoft VS Code Insiders" / "Code - Insiders.exe", + ) + add("Cursor", base / "Programs" / "cursor" / "Cursor.exe") + add("Cursor", base / "cursor" / "Cursor.exe") + elif sys.platform == "darwin": + add( + "VS Code", + Path("/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"), + ) + add("Cursor", Path("/Applications/Cursor.app/Contents/MacOS/Cursor")) + else: + for path in ( + Path("/usr/bin/code"), + Path("/usr/share/code/bin/code"), + Path("/snap/bin/code"), + Path("/usr/bin/cursor"), + ): + label = "Cursor" if "cursor" in path.name else "VS Code" + add(label, path) + + return out + + +def detect_primary_editor() -> Path | None: + editors = detect_editors() + return editors[0][1] if editors else None + + +def load_config(env_path: Path | None = None) -> dict: + path = env_path or ENV_PATH + env = dotenv_values(path) if path.is_file() else {} + cfg = dict(DEFAULTS) + editor = (env.get(ENV_EDITOR) or "").strip() + if editor: + cfg["editorCmd"] = editor + return cfg + + +def save_config(cfg: dict, env_path: Path | None = None) -> None: + path = env_path or ENV_PATH + text = path.read_text(encoding="utf-8") if path.is_file() else "" + updates = { + ENV_EDITOR: cfg.get("editorCmd", "auto"), + } + for key, val in updates.items(): + quoted = f"'{val}'" + pattern = rf"^({re.escape(key)}\s*=\s*)(?:'[^']*'|\"[^\"]*\"|[^\n]*)" + text, n = re.subn(pattern, rf"\g<1>{quoted}", text, count=1, flags=re.MULTILINE) + if n == 0: + text = text.rstrip("\n") + f"\n{key}={quoted}\n" + if not path.exists(): + path.touch() + path.write_text(text, encoding="utf-8") + + +def _js_literal(value) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + return json.dumps(str(value)) + + +def patch_cfg_block(js: str, overrides: dict) -> str: + for key, val in overrides.items(): + if key not in CFG_KEYS: + continue + lit = _js_literal(val) + pattern = rf"({re.escape(key)}:\s*)(?:'[^']*'|\"[^\"]*\"|true|false|null|\d+)" + js, count = re.subn(pattern, rf"\g<1>{lit}", js, count=1) + if count == 0: + raise ValueError(f"Could not patch CFG.{key} in TLInspector.js") + return js + + +def prepare_plugin_js(source: Path | None = None, cfg: dict | None = None) -> str: + src = source or BUNDLED_PLUGIN + text = src.read_text(encoding="utf-8") + effective = {**DEFAULTS, **(cfg or load_config())} + effective["workspaceFolder"] = "auto" + return patch_cfg_block(text, effective) diff --git a/util/tl_inspector/installer.py b/util/tl_inspector/installer.py new file mode 100644 index 0000000..a20607e --- /dev/null +++ b/util/tl_inspector/installer.py @@ -0,0 +1,155 @@ +"""Install / uninstall TLInspector into an RPG Maker MV or MZ game folder. + +Credits: Idea by Sakura · Plugin by Kao_SSS +""" + +from __future__ import annotations + +import re +from pathlib import Path + +PLUGIN_NAME = "TLInspector" +PLUGIN_ENTRY = ( + ' { "name": "TLInspector", "status": true, ' + '"description": "TL source inspector", "parameters": {} }' +) + +_PKG_ROOT = Path(__file__).resolve().parent +DEFAULT_PLUGIN_SRC = _PKG_ROOT / "TLInspector.js" + + +def bundled_plugin_path() -> Path: + return DEFAULT_PLUGIN_SRC + + +def detect_engine(game_root: Path) -> tuple[str, Path, Path] | None: + """Return (engine, plugins_js, plugins_dir) or None if not MV/MZ.""" + root = Path(game_root) + mv_js = root / "www" / "js" / "plugins.js" + mz_js = root / "js" / "plugins.js" + if mv_js.is_file(): + return "MV", mv_js, root / "www" / "js" / "plugins" + if mz_js.is_file(): + return "MZ", mz_js, root / "js" / "plugins" + return None + + +def _read_plugins_js(plugins_js: Path) -> tuple[str, str]: + content = plugins_js.read_text(encoding="utf-8") + nl = "\r\n" if "\r\n" in content else "\n" + return content, nl + + +def _is_declared(content: str) -> bool: + return bool(re.search(r'"name"\s*:\s*"TLInspector"', content)) + + +def status(game_root: Path) -> dict: + """Return install state for the game folder.""" + info = detect_engine(game_root) + if info is None: + return { + "ok": False, + "engine": None, + "installed": False, + "declared": False, + "plugin_file": None, + "message": "No RPG Maker MV/MZ game found (missing plugins.js).", + } + engine, plugins_js, plugins_dir = info + target = plugins_dir / f"{PLUGIN_NAME}.js" + content, _ = _read_plugins_js(plugins_js) + declared = _is_declared(content) + file_there = target.is_file() + return { + "ok": True, + "engine": engine, + "installed": declared or file_there, + "declared": declared, + "plugin_file": file_there, + "plugins_js": str(plugins_js), + "target": str(target), + "message": ( + "Installed" + if declared and file_there + else "Partially installed" + if declared or file_there + else "Not installed" + ), + } + + +def install(game_root: Path, source_js: Path | None = None, cfg: dict | None = None) -> tuple[bool, str]: + """Copy TLInspector.js into the game and declare it in plugins.js.""" + from util.tl_inspector.config import load_config, prepare_plugin_js + + info = detect_engine(game_root) + if info is None: + return False, "No RPG Maker MV/MZ game found at that path." + + engine, plugins_js, plugins_dir = info + src = Path(source_js) if source_js else DEFAULT_PLUGIN_SRC + if not src.is_file(): + return False, f"TLInspector.js not found: {src}" + + target = plugins_dir / f"{PLUGIN_NAME}.js" + content, nl = _read_plugins_js(plugins_js) + + plugins_dir.mkdir(parents=True, exist_ok=True) + effective_cfg = cfg if cfg is not None else load_config() + target.write_text(prepare_plugin_js(src, effective_cfg), encoding="utf-8") + + if not _is_declared(content): + idx = content.rfind("];") + if idx < 0: + return False, "Could not find plugin list end ( ]; ) in plugins.js." + before = content[:idx].rstrip() + after = content[idx:] + sep = nl if before.endswith(",") else "," + nl + content = before + sep + PLUGIN_ENTRY + nl + " " + after + plugins_js.write_text(content, encoding="utf-8", newline="") + + return True, f"TLInspector installed for RPG Maker {engine}. Press F10 in-game to open." + + +def uninstall(game_root: Path) -> tuple[bool, str]: + """Remove TLInspector from plugins.js and delete the plugin file.""" + info = detect_engine(game_root) + if info is None: + return False, "No RPG Maker MV/MZ game found at that path." + + _, plugins_js, plugins_dir = info + target = plugins_dir / f"{PLUGIN_NAME}.js" + content, nl = _read_plugins_js(plugins_js) + + if _is_declared(content): + kept = [ + line for line in re.split(r"\r?\n", content) + if not re.search(r'"name"\s*:\s*"TLInspector"', line) + ] + new_text = nl.join(kept) + new_text = re.sub(r",(\s*)\];", r"\1];", new_text) + plugins_js.write_text(new_text, encoding="utf-8", newline="") + + if target.is_file(): + target.unlink() + + return True, "TLInspector uninstalled." + + +def apply_config(game_root: Path, cfg: dict | None = None) -> tuple[bool, str]: + """Rewrite an installed TLInspector.js with current editor settings.""" + from util.tl_inspector.config import load_config, prepare_plugin_js + + info = detect_engine(game_root) + if info is None: + return False, "No RPG Maker MV/MZ game found at that path." + + _, _, plugins_dir = info + target = plugins_dir / f"{PLUGIN_NAME}.js" + if not target.is_file(): + return False, "TLInspector is not installed in this game folder." + + effective_cfg = cfg if cfg is not None else load_config() + target.write_text(prepare_plugin_js(DEFAULT_PLUGIN_SRC, effective_cfg), encoding="utf-8") + return True, "TL Inspector editor settings applied to the installed plugin." diff --git a/util/translation.py b/util/translation.py index bdbae86..9a67263 100644 --- a/util/translation.py +++ b/util/translation.py @@ -88,6 +88,13 @@ def _write_request_debug_log(provider, request_payload, usage): except Exception: pass +def _normalize_openai_base_url(url: str) -> str: + """Ensure OpenAI SDK global base_url has a trailing slash.""" + _url = (url or "").strip() + if _url and not _url.endswith("/"): + _url += "/" + return _url + # Tracks which distinct batch sizes have already been cache-written during this estimate run. # Each unique numLines value maps to a distinct output_config schema → one write per size. # Persisted to disk so sequential GUI subprocesses share state. @@ -329,17 +336,17 @@ def validate_translation_content(original_items, translated_items, langRegex): return is_valid, invalid_indices, reasons # Load .env, strip accidental whitespace, set base URL / org / API key. -# Handles the Gemini compatibility layer as a special case. +# Gemini uses its compatibility endpoint only when no custom API URL is set. load_dotenv() api_provider = os.getenv("API_PROVIDER", "openai").lower() env_api = os.getenv("api", "").strip() -if api_provider == "gemini": - # Use Google Generative Language compatibility endpoint when running Gemini +if api_provider == "gemini" and not env_api: + # Use Google Generative Language compatibility endpoint only as fallback. openai.base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" openai.organization = None else: if env_api: - openai.base_url = env_api + openai.base_url = _normalize_openai_base_url(env_api) # Support both 'organization' (gui/.env.example) and legacy 'org' names org = os.getenv("organization") or os.getenv("org") if org: @@ -1182,10 +1189,10 @@ def translateText(system, user, history, penalty, formatType, model, numLines=No _live_api = os.getenv("api", "").strip() _live_key = os.getenv("key", "").strip() _live_provider = os.getenv("API_PROVIDER", "openai").lower() - if _live_provider == "gemini": + if _live_provider == "gemini" and not _live_api: openai.base_url = "https://generativelanguage.googleapis.com/v1beta/openai/" elif _live_api: - openai.base_url = _live_api + openai.base_url = _normalize_openai_base_url(_live_api) if _live_key: openai.api_key = _live_key