From 5fd6e82b5316710c0e1c240819af2d0dc333215a Mon Sep 17 00:00:00 2001 From: dazedanon Date: Thu, 12 Mar 2026 20:20:17 -0500 Subject: [PATCH] I'm doing stuff --- gameupdate/GameUpdate.bat | 14 + gameupdate/GameUpdate_linux.sh | 13 + gameupdate/README.md | 102 ++ gameupdate/gameupdate/patch.bat | 265 ++++++ gameupdate/gameupdate/patch.sh | 169 ++++ gui/__init__.py | 6 +- gui/main.py | 24 +- gui/workflow_tab.py | 1569 +++++++++++++++++++++++++++++++ util/actor_substitutor.py | 248 +++++ util/project_scanner.py | 235 +++++ util/speaker_detector.py | 225 +++++ 11 files changed, 2862 insertions(+), 8 deletions(-) create mode 100644 gameupdate/GameUpdate.bat create mode 100644 gameupdate/GameUpdate_linux.sh create mode 100644 gameupdate/README.md create mode 100644 gameupdate/gameupdate/patch.bat create mode 100644 gameupdate/gameupdate/patch.sh create mode 100644 gui/workflow_tab.py create mode 100644 util/actor_substitutor.py create mode 100644 util/project_scanner.py create mode 100644 util/speaker_detector.py diff --git a/gameupdate/GameUpdate.bat b/gameupdate/GameUpdate.bat new file mode 100644 index 0000000..ab6b767 --- /dev/null +++ b/gameupdate/GameUpdate.bat @@ -0,0 +1,14 @@ +@echo off +setlocal + +REM Copy GAMEUPDATE.bat to a new file +copy "gameupdate\patch.bat" "gameupdate\patch2.bat" + +REM Run the new file +call "gameupdate\patch2.bat" + +REM Delete the new file +del "gameupdate\patch2.bat" + +endlocal +@echo on \ No newline at end of file diff --git a/gameupdate/GameUpdate_linux.sh b/gameupdate/GameUpdate_linux.sh new file mode 100644 index 0000000..732096a --- /dev/null +++ b/gameupdate/GameUpdate_linux.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# Enable error handling +set -e + +# Copy patch.sh to a new file in gameupdate +cp "gameupdate/patch.sh" "gameupdate/patch2.sh" + +# Run the new file +bash "gameupdate/patch2.sh" + +# Delete the new file +rm "gameupdate/patch2.sh" diff --git a/gameupdate/README.md b/gameupdate/README.md new file mode 100644 index 0000000..a924ae1 --- /dev/null +++ b/gameupdate/README.md @@ -0,0 +1,102 @@ +# Apply Patch +1. Click Code +2. Click Download ZIP +3. Extract to game folder and Replace All. + +## Future Patching +1. Run GAMEUPDATE.bat to auto patch. + +# Troubleshooting +**GAMEUPDATE.bat doesn't update and closes immediately** +1. Make sure your path doesn't contain any Japanese characters or lots of whitespace. +2. Make sure you actually have permissions in the folder + +For WOLF RPG games, if you downloaded the game off of DLSite, you will need to do some extra steps to patch it. This is because there is a "master" file called Data.wolf that will take priority over the english patch files. You will need this file to be a folder before patching will work. + +# Wolf Games +1) Download the latest UberWolf.exe release from the following link: +https://github.com/Sinflower/UberWolf/releases +2) Drag Data.wolf onto UberWolf.exe. This will create a new folder called data.wolf~ +3) Rename the new data.wolf~ folder to Data +4) Delete the Data.wolf file +5) Delete previous_patch_sha.txt (this will exist if you ran GameUpdate.bat previously) +6) Run GameUpdate.bat + +# Edit/Contribute +TLDR 3 steps. + + Fork the repository. + Make the changes. + Submit a merge request. + +If everything looks good and doesn't break things I'll merge it in. + +Longer Version: + +# Required Software: +* [VSCode](https://code.visualstudio.com/) Make sure you check all the boxes for context menus. +* [Git](https://git-scm.com/downloads) (Use the default for everything. Just keep clicking Next) + +# Guide to contributing + +### 1. Fork the Repository +- Go to the repository you want to fork. +- Click the "Fork" button. + +### 2. Clone Your Fork +- Clone your forked repository to your local machine. + ```sh + git clone https://gitgud.io/YOUR_USERNAME/REPO_NAME.git + ``` + +### 3. Make Your Changes (In VSCode) +- Edit the files locally on your new branch using VSCode. +- Add and commit your changes. + ```sh + git add . + git commit -m "Description of your changes" + ``` + +### 4. Push Your Changes +- Push your changes to your fork on GitGud.io. + ```sh + git push origin your-feature-branch + ``` + +### 5. Create a Merge Request +- Go to your fork on GitGud.io. +- Click on "Merge Requests" in the sidebar. +- Click the "New merge request" button. +- Select the branch you made changes to and the target project (the original repo). +- Provide a title and description for your merge request and submit it. + +--- + +## Example + +Assuming you want to fork a repository named `example-project`: + +### 1. Fork the Repo +- Navigate to `https://gitgud.io/original_user/example-project` and click "Fork". + +### 2. Clone Your Fork + ```sh + git clone https://gitgud.io/YOUR_USERNAME/example-project.git + ``` + +### 3. Make Changes and Commit + ```sh + # Make changes to the files + git add . + git commit -m "Add new feature to example project" + ``` + +### 4. Push Changes + ```sh + git push origin add-new-feature + ``` + +### 5. Create a Merge Request +- Go to `https://gitgud.io/YOUR_USERNAME/example-project/merge_requests` and click on "New merge request" +- Choose the source branch `add-new-feature` and target branch (default: `main` or `master`) +- Fill in the details and submit the merge request \ No newline at end of file diff --git a/gameupdate/gameupdate/patch.bat b/gameupdate/gameupdate/patch.bat new file mode 100644 index 0000000..22b661f --- /dev/null +++ b/gameupdate/gameupdate/patch.bat @@ -0,0 +1,265 @@ +@echo off +setlocal EnableExtensions EnableDelayedExpansion + +REM Check if being run from gameupdate folder (incorrect usage) +for %%I in ("%CD%") do set "CURRENT_FOLDER=%%~nxI" +if /I "%CURRENT_FOLDER%"=="gameupdate" ( + echo. + echo ======================================== + echo ERROR: Do not run patch.bat directly! + echo ======================================== + echo. + echo You are running this from the gameupdate folder. + echo This will not work correctly! + echo. + echo Please go back to the game's root folder and + echo run GameUpdate.bat instead. + echo ======================================== + echo. + pause + exit /b 1 +) + +REM Determine important paths +set "SCRIPT_DIR=%~dp0" +set "ROOT_DIR=%CD%" +set "CONFIG_FILE=%SCRIPT_DIR%patch-config.txt" + +echo Using root directory: "%ROOT_DIR%" +echo Using config: "%CONFIG_FILE%" + +echo Checking for pwsh... +set _my_shell=pwsh + +REM Check if pwsh.exe exists +where /q !_my_shell! +if !errorlevel! neq 0 ( + echo pwsh not found. + echo. + echo PowerShell 7 ^(pwsh^) is faster and recommended. + set /p "INSTALL_PWSH=Would you like to install it now via winget? (Y/N): " + if /I "!INSTALL_PWSH!"=="Y" ( + echo Installing PowerShell 7 via winget... + winget install --id Microsoft.PowerShell --source winget --accept-package-agreements --accept-source-agreements + if !errorlevel! neq 0 ( + echo Install failed or winget not available. Falling back to powershell... + set _my_shell=powershell + ) else ( + echo PowerShell 7 installed successfully. + echo Please re-run GameUpdate.bat to use pwsh. + pause + exit /b 0 + ) + ) else ( + echo Skipping install. Falling back to powershell... + set _my_shell=powershell + ) + + REM Check if powershell.exe exists + echo Checking for powershell... + where /q !_my_shell! + if !errorlevel! neq 0 ( + echo.Error: Powershell not found! + pause + exit /B 1 + ) else ( + echo powershell found. + ) +) else ( + echo pwsh found. +) + +echo Using !_my_shell! for script execution. + +REM Check if patch-config.txt exists in gameupdate folder +if not exist "%CONFIG_FILE%" ( + echo "Config file (gameupdate\patch-config.txt) not found! Assuming no patching needed." + pause + exit /b +) + +REM Read configuration from file +for /f "usebackq tokens=1,2 delims==" %%a in ("%CONFIG_FILE%") do ( + if "%%a"=="username" set "username=%%b" + if "%%a"=="repo" set "repo=%%b" + if "%%a"=="branch" set "branch=%%b" +) + +REM -------------------------------------------------------- +REM PRE-SETUP: Ensure SRPG data and patch structure exists +REM Run Steps 1 and 2 BEFORE pulling repo patch to avoid overwriting updates +REM 1) Unpack once if data folder doesn't exist (and data.dts does) +REM 2) Create Patch once if patch folder doesn't exist +REM -------------------------------------------------------- +set "UNPACKER=%ROOT_DIR%\SRPG_Unpacker.exe" +if exist "%ROOT_DIR%\data.dts" ( + if exist "%UNPACKER%" ( + echo [Pre-Setup] Running SRPG_Unpacker preparation steps... + + REM Step 1: Unpack (once) + if not exist "%ROOT_DIR%\data\" ( + set "SHOULD_UNPACK=1" + ) else if not exist "%ROOT_DIR%\data\project.dat" ( + set "SHOULD_UNPACK=1" + ) else ( + set "SHOULD_UNPACK=0" + ) + + if "!SHOULD_UNPACK!"=="1" ( + if exist "%ROOT_DIR%\data.dts" ( + echo [Pre-Setup] Step 1: Unpacking data.dts to data + pushd "%ROOT_DIR%" + "%UNPACKER%" -o "data" "data.dts" + if !errorlevel! neq 0 ( + echo [Pre-Setup] ERROR: Unpack failed. Continuing. + ) + popd + ) else ( + echo [Pre-Setup] Step 1: Skipping unpack - no data folder and no data.dts found. + ) + ) else ( + echo [Pre-Setup] Step 1: data folder exists; skipping unpack. + ) + + REM Step 2: Create Patch (once) + if not exist "%ROOT_DIR%\patch\" ( + if exist "%ROOT_DIR%\data\project.dat" ( + echo [Pre-Setup] Step 2: Creating patch from data\project.dat + pushd "%ROOT_DIR%" + "%UNPACKER%" ".\data\project.dat" -c + if !errorlevel! neq 0 ( + echo [Pre-Setup] ERROR: Create Patch failed. Continuing. + ) + popd + ) else ( + echo [Pre-Setup] Step 2: Skipping create patch - data\project.dat not found. + ) + ) else ( + echo [Pre-Setup] Step 2: patch folder exists; skipping create. + ) + ) else ( + echo [Pre-Setup] SRPG_Unpacker.exe not found in root; skipping pre-setup steps. + ) +) else ( + echo [Pre-Setup] data.dts not found; skipping pre-setup SRPG steps. +) + +REM Get the latest hash +echo "Getting latest commit SHA hash" +!_my_shell! -Command "(Invoke-RestMethod -Uri 'https://gitgud.io/api/v4/projects/%username%%%2F%repo%/repository/branches/%branch%').commit.id" > "%ROOT_DIR%\latest_patch_sha.txt" + +REM Read the latest SHA from the file +set /p latest_patch_sha=<"%ROOT_DIR%\latest_patch_sha.txt" + +REM Check if previous_patch_sha.txt exists in gameupdate +if not exist "%SCRIPT_DIR%previous_patch_sha.txt" ( + echo "Previous SHA hash not found!" + echo "Assuming first time patching..." + goto download_extract +) + +REM Read the stored SHA from previous check +set /p previous_patch_sha=<"%SCRIPT_DIR%previous_patch_sha.txt" + +REM Trim whitespace from SHA strings +set "previous_patch_sha=%previous_patch_sha: =%" +set "latest_patch_sha=%latest_patch_sha: =%" + +REM Compare trimmed SHAs +if "%latest_patch_sha%" neq "%previous_patch_sha%" ( + echo "Update found! Patching..." + goto download_extract +) else ( + echo "Patch is up to date." +) + +REM Delete latest_patch_sha.txt +del "%ROOT_DIR%\latest_patch_sha.txt" + +endlocal +pause +exit /b + +:download_extract + +REM Escape single quotes in paths +set "escaped_root=%ROOT_DIR:'=''%" + +REM Download zip file to root +echo "Downloading latest patch..." +!_my_shell! -Command "Set-Location -LiteralPath '%escaped_root%'; $ProgressPreference = 'SilentlyContinue'; Invoke-WebRequest -Uri 'https://gitgud.io/%username%/%repo%/-/archive/%branch%/%repo%-%branch%.zip' -OutFile 'repo.zip'" +if !errorlevel! neq 0 ( + pause + exit /b +) + +REM Extract contents, overwriting conflicts into root +echo "Extracting..." +!_my_shell! -Command "Set-Location -LiteralPath '%escaped_root%'; $ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '.\repo.zip' -DestinationPath '.' -Force" +if !errorlevel! neq 0 ( + echo Extraction failed! + del "%ROOT_DIR%\repo.zip" + rmdir /s /q "%ROOT_DIR%\%repo%-%branch%" + pause + exit /b +) +echo "Applying patch..." +xcopy /s /e /y "%ROOT_DIR%\%repo%-%branch%\*" "%ROOT_DIR%\" +if !errorlevel! neq 0 ( + echo Patch application failed! + del "%ROOT_DIR%\repo.zip" + rmdir /s /q "%ROOT_DIR%\%repo%-%branch%" + pause + exit /b +) +REM -------------------------------------------------------- +REM POST-APPLY: Run Steps 3 and 4 after patch files are merged +REM 3) Apply Patch to data\project.dat +REM 4) Pack data back into data.dts +REM -------------------------------------------------------- +set "UNPACKER=%ROOT_DIR%\SRPG_Unpacker.exe" +if exist "%ROOT_DIR%\data.dts" ( + if exist "%UNPACKER%" ( + echo Running SRPG_Unpacker apply/pack steps... + + REM Step 3: Apply Patch + if exist "%ROOT_DIR%\data\project.dat" ( + echo Step 3: Applying patch to data\project.dat + pushd "%ROOT_DIR%" + "%UNPACKER%" ".\data\project.dat" -a + if !errorlevel! neq 0 ( + echo ERROR: Apply Patch failed. + ) + popd + ) else ( + echo ERROR: data\project.dat not found; cannot apply patch. + ) + + REM Step 4: Pack + if exist "%ROOT_DIR%\data\" ( + echo Step 4: Packing data to data.dts + pushd "%ROOT_DIR%" + "%UNPACKER%" -o "data.dts" "data" + if !errorlevel! neq 0 ( + echo WARNING: Pack failed. + ) + popd + ) else ( + echo Step 4: Skipping pack - data folder not found. + ) + ) else ( + echo SRPG_Unpacker.exe not found in root; skipping SRPG patch steps. + ) +) else ( + echo data.dts not found; skipping SRPG patch steps. +) +REM Clean up +echo "Cleaning up..." +del "%ROOT_DIR%\repo.zip" +rmdir /s /q "%ROOT_DIR%\%repo%-%branch%" +del "%ROOT_DIR%\latest_patch_sha.txt" +REM Store latest SHA for next check in gameupdate +echo %latest_patch_sha% > "%SCRIPT_DIR%previous_patch_sha.txt" +endlocal +pause +exit /b diff --git a/gameupdate/gameupdate/patch.sh b/gameupdate/gameupdate/patch.sh new file mode 100644 index 0000000..4986803 --- /dev/null +++ b/gameupdate/gameupdate/patch.sh @@ -0,0 +1,169 @@ +#!/bin/bash + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(pwd)" +CONFIG_FILE="$SCRIPT_DIR/patch-config.txt" + +check_dependency() { + if ! command -v "$1" > /dev/null 2>&1; then + echo "Error: '$1' is not installed. Please install it using 'pkg install $1'." + exit 1 + fi +} + +# Check for jq, unzip, and curl +check_dependency jq +check_dependency unzip +check_dependency curl + +# Check if CONFIG_FILE exists +if [ ! -f "$CONFIG_FILE" ]; then + echo "Config file '$CONFIG_FILE' not found! Assuming no patching needed." + exit 0 +fi + +# Convert line endings to Unix format +sed -i 's/\r$//' "$CONFIG_FILE" + +# Debug information +echo "Root directory: $ROOT_DIR" +echo "Config file path: $CONFIG_FILE" + +# Read configuration from file +. "$CONFIG_FILE" + +# Get the latest hash +echo "Getting latest commit SHA hash" +latest_patch_sha=$(curl -s "https://gitgud.io/api/v4/projects/$username%2F$repo/repository/branches/$branch" | jq -r '.commit.id') + +# -------------------------------------------------------- +# PRE-SETUP: Ensure SRPG data and patch structure exists +# Run Steps 1 and 2 BEFORE pulling repo patch to avoid overwriting updates +# 1) Unpack once if data folder doesn't exist (and data.dts does) +# 2) Create Patch once if patch folder doesn't exist +# -------------------------------------------------------- +UNPACKER="$ROOT_DIR/SRPG_Unpacker.exe" +if [ -f "$ROOT_DIR/data.dts" ]; then + if [ -f "$UNPACKER" ]; then + echo "[Pre-Setup] Running SRPG_Unpacker preparation steps..." + + # Step 1: Unpack (once) + if [ ! -d "$ROOT_DIR/data" ]; then + if [ -f "$ROOT_DIR/data.dts" ]; then + echo "[Pre-Setup] Step 1: Unpacking data.dts -> data" + ( cd "$ROOT_DIR" && "$UNPACKER" -o data data.dts ) || echo "[Pre-Setup] ERROR: Unpack failed." + else + echo "[Pre-Setup] Step 1: Skipping unpack (no data folder and no data.dts found)." + fi + else + echo "[Pre-Setup] Step 1: data folder exists; skipping unpack." + fi + + # Step 2: Create Patch (once) + if [ ! -d "$ROOT_DIR/patch" ]; then + if [ -f "$ROOT_DIR/data/project.dat" ]; then + echo "[Pre-Setup] Step 2: Creating patch from data/project.dat" + ( cd "$ROOT_DIR" && "$UNPACKER" ./data/project.dat -c ) || echo "[Pre-Setup] ERROR: Create Patch failed." + else + echo "[Pre-Setup] Step 2: Skipping create patch (data/project.dat not found)." + fi + else + echo "[Pre-Setup] Step 2: patch folder exists; skipping create." + fi + else + echo "[Pre-Setup] SRPG_Unpacker.exe not found in root; skipping pre-setup steps." + fi + else + echo "[Pre-Setup] data.dts not found; skipping pre-setup SRPG steps." + fi + +download_extract() { + # Download zip file into root + echo "Downloading latest patch..." + curl -sL "https://gitgud.io/$username/$repo/-/archive/$branch/$repo-$branch.zip" -o "$ROOT_DIR/repo.zip" + if [ $? -ne 0 ]; then + echo "Download failed!" + rm -f "$ROOT_DIR/repo.zip" + rm -rf "$ROOT_DIR/$repo-$branch" + return 1 + fi + + # Extract contents, overwriting conflicts into root + echo "Extracting..." + unzip -qo "$ROOT_DIR/repo.zip" -d "$ROOT_DIR" + if [ $? -ne 0 ]; then + echo "Extraction failed!" + rm -f "$ROOT_DIR/repo.zip" + rm -rf "$ROOT_DIR/$repo-$branch" + return 1 + fi + + echo "Applying patch..." + cp -r "$ROOT_DIR/$repo-$branch/"* "$ROOT_DIR/" + if [ $? -ne 0 ]; then + echo "Patch application failed!" + rm -f "$ROOT_DIR/repo.zip" + rm -rf "$ROOT_DIR/$repo-$branch" + return 1 + fi + + echo "Cleaning up..." + rm -f "$ROOT_DIR/repo.zip" + rm -rf "$ROOT_DIR/$repo-$branch" + rm -f "$ROOT_DIR/latest_patch_sha.txt" + + # Store latest SHA for next check in gameupdate + echo "$latest_patch_sha" > "$SCRIPT_DIR/previous_patch_sha.txt" + + # -------------------------------------------------------- + # POST-APPLY: Run Steps 3 and 4 after patch files are merged + # 3) Apply Patch to data/project.dat + # 4) Pack data back into data.dts + # -------------------------------------------------------- + UNPACKER="$ROOT_DIR/SRPG_Unpacker.exe" + if [ -f "$ROOT_DIR/data.dts" ]; then + if [ -f "$UNPACKER" ]; then + echo "Running SRPG_Unpacker apply/pack steps..." + + # Step 3: Apply Patch + if [ -f "$ROOT_DIR/data/project.dat" ]; then + echo "Step 3: Applying patch to data/project.dat" + ( cd "$ROOT_DIR" && "$UNPACKER" ./data/project.dat -a ) || echo "ERROR: Apply Patch failed." + else + echo "ERROR: data/project.dat not found; cannot apply patch." + fi + + # Step 4: Pack + if [ -d "$ROOT_DIR/data" ]; then + echo "Step 4: Packing data -> data.dts" + ( cd "$ROOT_DIR" && "$UNPACKER" -o data.dts data ) || echo "WARNING: Pack failed." + else + echo "Step 4: Skipping pack (data folder not found)." + fi + else + echo "SRPG_Unpacker.exe not found in root; skipping SRPG patch steps." + fi + else + echo "data.dts not found; skipping SRPG patch steps." + fi +} + +# Check if previous_patch_sha.txt exists in gameupdate +if [ ! -f "$SCRIPT_DIR/previous_patch_sha.txt" ]; then + echo "Previous SHA hash not found!" + echo "Assuming first time patching..." + download_extract +else + # Read the stored SHA from previous check + previous_patch_sha=$(cat "$SCRIPT_DIR/previous_patch_sha.txt") + + # Compare trimmed SHAs + if [ "$latest_patch_sha" != "$previous_patch_sha" ]; then + echo "Update found! Patching..." + download_extract + else + echo "Patch is up to date." + fi +fi diff --git a/gui/__init__.py b/gui/__init__.py index 2c23ec7..a77871a 100644 --- a/gui/__init__.py +++ b/gui/__init__.py @@ -11,11 +11,13 @@ from .config_tab import ConfigTab from .rpgmaker_tab import RPGMakerTab from .log_viewer import LogViewer from .file_manager import FileManager +from .workflow_tab import WorkflowTab __all__ = [ "DazedMTLGUI", - "ConfigTab", + "ConfigTab", "RPGMakerTab", "LogViewer", - "FileManager" + "FileManager", + "WorkflowTab", ] diff --git a/gui/main.py b/gui/main.py index d25e231..4fe4acd 100644 --- a/gui/main.py +++ b/gui/main.py @@ -19,6 +19,7 @@ from PyQt5.QtGui import QIcon, QFont, QPixmap, QScreen # Import configuration widgets from gui.config_tab import ConfigTab from gui.translation_tab import TranslationTab +from gui.workflow_tab import WorkflowTab class DazedMTLGUI(QMainWindow): """Main GUI window for the DazedMTLTool.""" @@ -188,10 +189,17 @@ class DazedMTLGUI(QMainWindow): btn_translation.clicked.connect(lambda: self.switch_page(0)) sidebar_layout.addWidget(btn_translation) self.nav_buttons.append(btn_translation) - - # Configuration button (second) + + # Workflow / Automation button (second) + btn_workflow = self.create_nav_button("⚡", "Workflow") + btn_workflow.setToolTip("Workflow — automated translation pipeline") + btn_workflow.clicked.connect(lambda: self.switch_page(1)) + sidebar_layout.addWidget(btn_workflow) + self.nav_buttons.append(btn_workflow) + + # Configuration button (third) btn_config = self.create_nav_button("⚙️", "Configuration") - btn_config.clicked.connect(lambda: self.switch_page(1)) + btn_config.clicked.connect(lambda: self.switch_page(2)) sidebar_layout.addWidget(btn_config) self.nav_buttons.append(btn_config) @@ -252,11 +260,15 @@ class DazedMTLGUI(QMainWindow): def setup_tabs(self): """Set up all the tabs in the interface.""" - # Translation Execution Tab (first) + # Translation Execution Tab (index 0) self.translation_tab = TranslationTab(self) self.content_stack.addWidget(self.translation_tab) - - # Configuration Tab (second) + + # Workflow / Automation Tab (index 1) + self.workflow_tab = WorkflowTab(self) + self.content_stack.addWidget(self.workflow_tab) + + # Configuration Tab (index 2) self.config_tab = ConfigTab() self.config_tab.config_changed.connect(self.on_config_changed) self.content_stack.addWidget(self.config_tab) diff --git a/gui/workflow_tab.py b/gui/workflow_tab.py new file mode 100644 index 0000000..ee5f754 --- /dev/null +++ b/gui/workflow_tab.py @@ -0,0 +1,1569 @@ +""" +RPGMaker Workflow Tab - Automation hub for the full translation pipeline. + +Provides a guided, step-by-step interface: + + Step 0 – Select game project folder and import data files into files/ + Step 1 – Edit vocab.txt (glossary) inline + Step 2 – Actor variable substitution (\\n[X] ↔ names) + Step 3 – Auto-detect speaker format and apply to module settings + Step 4 – Phase 1 (safe dialogue codes) and Phase 2 (risky codes) translation + Step 5 – Export translated/ back to the game folder +""" + +from __future__ import annotations + +import json +import os +import sys +import threading +from pathlib import Path + +from PyQt5.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal +from PyQt5.QtGui import QFont +from PyQt5.QtWidgets import ( + QApplication, + QCheckBox, + QFileDialog, + QFrame, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMessageBox, + QPushButton, + QScrollArea, + QSizePolicy, + QSpinBox, + QSplitter, + QStyle, + QTextEdit, + QVBoxLayout, + QWidget, +) + +# --------------------------------------------------------------------------- +# Phase profiles applied to rpgmakermvmz.py before each translation run +# --------------------------------------------------------------------------- + +PHASE1_CONFIG = { + # Safe dialogue / choices + "CODE101": False, + "CODE401": True, + "CODE405": True, + "CODE102": True, + "CODE408": True, + # Risky codes OFF + "CODE122": False, + "CODE355655": False, + "CODE357": False, + "CODE657": False, + "CODE356": False, + "CODE320": False, + "CODE324": False, + "CODE325": False, + "CODE111": False, + "CODE108": False, +} + +PHASE2_CONFIG = { + # Dialogue OFF (already handled by Phase 1) + "CODE101": False, + "CODE401": False, + "CODE405": False, + "CODE102": False, + "CODE408": False, + # Risky codes ON + "CODE122": True, + "CODE357": True, + "CODE111": True, + "CODE356": False, # plugin cmd — user can enable manually if needed + "CODE108": False, # comment — rarely needed +} + + +# ───────────────────────────────────────────────────────────────────────────── +# Background workers +# ───────────────────────────────────────────────────────────────────────────── + +class _ScanWorker(QThread): + """Run project_scanner.list_data_files in a thread.""" + done = pyqtSignal(object) # list[dict] + error = pyqtSignal(str) + + def __init__(self, data_path: str, engine: str): + super().__init__() + self.data_path = data_path + self.engine = engine + + def run(self): + try: + from util.project_scanner import list_data_files + result = list_data_files(self.data_path, self.engine) + self.done.emit(result) + except Exception as exc: + self.error.emit(str(exc)) + + +class _ImportWorker(QThread): + """Copy selected files into files/ directory.""" + done = pyqtSignal(int, list) # count_copied, errors + log = pyqtSignal(str) + + def __init__(self, file_items: list[dict], dest_dir: str): + super().__init__() + self.file_items = file_items + self.dest_dir = dest_dir + + def run(self): + try: + from util.project_scanner import import_to_files + self.log.emit(f"Importing {len(self.file_items)} file(s) into files/ …") + count, errors = import_to_files(self.file_items, self.dest_dir) + self.done.emit(count, errors) + except Exception as exc: + self.done.emit(0, [str(exc)]) + + +class _ActorSubstituteWorker(QThread): + done = pyqtSignal(dict) + log = pyqtSignal(str) + + def __init__(self, mode: str): # "substitute" or "restore" + super().__init__() + self.mode = mode + + def run(self): + try: + from util.actor_substitutor import ( + load_actor_map, + substitute_in_files, + restore_in_translated, + ) + actor_map = load_actor_map() + if not actor_map: + self.done.emit({"error": "No actor map found. Make sure files/Actors.json exists."}) + return + if self.mode == "substitute": + self.log.emit("Substituting \\n[X] → actor names in files/ …") + stats = substitute_in_files(actor_map=actor_map) + else: + self.log.emit("Restoring actor names → \\n[X] in translated/ …") + stats = restore_in_translated(actor_map=actor_map) + self.done.emit(stats) + except Exception as exc: + self.done.emit({"error": str(exc)}) + + +class _SpeakerDetectWorker(QThread): + done = pyqtSignal(dict) + log = pyqtSignal(str) + + def run(self): + try: + from util.speaker_detector import detect_speaker_format + self.log.emit("Scanning files/ for speaker patterns …") + result = detect_speaker_format() + self.done.emit(result) + except Exception as exc: + self.done.emit({"error": str(exc)}) + + +class _ExportWorker(QThread): + done = pyqtSignal(int, list) + log = pyqtSignal(str) + + def __init__(self, game_data_path: str): + super().__init__() + self.game_data_path = game_data_path + + def run(self): + try: + from util.project_scanner import export_to_game + self.log.emit(f"Exporting translated/ → {self.game_data_path} …") + count, errors = export_to_game("translated", self.game_data_path) + self.done.emit(count, errors) + except Exception as exc: + self.done.emit(0, [str(exc)]) + + +class _SubprocessWorker(QThread): + """Run an arbitrary shell command in a given working directory, streaming output.""" + done = pyqtSignal(bool, str) # success, final message + log = pyqtSignal(str) + + def __init__(self, cmd: list, cwd: str, label: str = ""): + super().__init__() + self.cmd = cmd + self.cwd = cwd + self.label = label or cmd[0] + + def run(self): + import subprocess + import shutil as _shutil + try: + exe = _shutil.which(self.cmd[0]) + if exe is None: + self.done.emit( + False, + f"'{self.cmd[0]}' not found on PATH. " + "Make sure it is installed and accessible from the terminal.", + ) + return + self.log.emit(f"$ {' '.join(str(c) for c in self.cmd)} — cwd: {self.cwd}") + proc = subprocess.Popen( + self.cmd, + cwd=self.cwd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + ) + for line in proc.stdout: + stripped = line.rstrip("\n") + if stripped: + self.log.emit(stripped) + proc.wait() + if proc.returncode == 0: + self.done.emit(True, f"{self.label}: finished successfully.") + else: + self.done.emit(False, f"{self.label}: exited with code {proc.returncode}.") + except Exception as exc: + self.done.emit(False, f"{self.label}: {exc}") + + +class _FileCopyWorker(QThread): + """Recursively copy a source folder into a destination folder.""" + done = pyqtSignal(int, list) # count_copied, errors + log = pyqtSignal(str) + + def __init__(self, src: str, dst: str): + super().__init__() + self.src = src + self.dst = dst + + def run(self): + import shutil + src = Path(self.src) + dst = Path(self.dst) + if not src.is_dir(): + self.done.emit(0, [f"Source folder not found: {src}"]) + return + dst.mkdir(parents=True, exist_ok=True) + copied = 0 + errors: list[str] = [] + self.log.emit(f"Copying {src} → {dst} …") + for fp in src.rglob("*"): + if not fp.is_file(): + continue + rel = fp.relative_to(src) + target = dst / rel + try: + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(fp, target) + copied += 1 + self.log.emit(f" copied {rel}") + except Exception as exc: + errors.append(f"{rel}: {exc}") + self.done.emit(copied, errors) + + +class _JsFormatWorker(QThread): + """Format a JavaScript file using jsbeautifier (pure Python, no Node required).""" + done = pyqtSignal(bool, str) + log = pyqtSignal(str) + + def __init__(self, js_path: str): + super().__init__() + self.js_path = js_path + + def run(self): + import subprocess as _sp + import sys + try: + import jsbeautifier + except ImportError: + self.log.emit("jsbeautifier not installed — installing via pip…") + r = _sp.run( + [sys.executable, "-m", "pip", "install", "jsbeautifier"], + capture_output=True, text=True, + ) + if r.returncode != 0: + self.done.emit(False, f"pip install failed: {r.stderr.strip()}") + return + self.log.emit("jsbeautifier installed.") + try: + import jsbeautifier + except ImportError: + self.done.emit(False, "jsbeautifier could not be imported after install.") + return + try: + p = Path(self.js_path) + self.log.emit(f"Formatting {p.name} …") + original = p.read_text(encoding="utf-8") + opts = jsbeautifier.default_options() + opts.indent_size = 2 + opts.indent_char = " " + opts.max_preserve_newlines = 2 + opts.preserve_newlines = True + opts.end_with_newline = True + formatted = jsbeautifier.beautify(original, opts) + p.write_text(formatted, encoding="utf-8") + self.done.emit(True, f"plugins.js formatted successfully ({len(formatted):,} chars).") + except Exception as exc: + self.done.emit(False, f"Format error: {exc}") + + +def _make_section_label(text: str) -> QLabel: + lbl = QLabel(text) + lbl.setStyleSheet( + "font-size:13px; font-weight:bold; color:#007acc; " + "padding:8px 0 4px 0; background:transparent;" + ) + return lbl + + +def _make_hr() -> QFrame: + hr = QFrame() + hr.setFrameShape(QFrame.HLine) + hr.setFrameShadow(QFrame.Sunken) + hr.setStyleSheet("QFrame{color:#444;margin:8px 0;}") + return hr + + +def _make_btn(text: str, color: str = "#007acc") -> QPushButton: + btn = QPushButton(text) + # Derive a slightly lighter hover colour by blending toward white + try: + r = int(color[1:3], 16) + g = int(color[3:5], 16) + b = int(color[5:7], 16) + rh = min(255, r + 30) + gh = min(255, g + 30) + bh = min(255, b + 30) + hover_color = f"#{rh:02x}{gh:02x}{bh:02x}" + rp = max(0, r - 30) + gp = max(0, g - 30) + bp = max(0, b - 30) + press_color = f"#{rp:02x}{gp:02x}{bp:02x}" + except Exception: + hover_color = color + press_color = color + btn.setStyleSheet( + f"QPushButton{{background:{color};color:#fff;border:none;" + f"padding:6px 14px;border-radius:3px;font-size:11px;" + f"icon-size:16px;" + f"font-family:'Segoe UI Emoji','Segoe UI','Apple Color Emoji',sans-serif;}}" + f"QPushButton:hover{{background:{hover_color};}}" + f"QPushButton:pressed{{background:{press_color};padding:7px 13px 5px 15px;}}" + f"QPushButton:disabled{{background:#555;color:#999;}}" + ) + return btn + + +# ───────────────────────────────────────────────────────────────────────────── +# Main widget +# ───────────────────────────────────────────────────────────────────────────── + +class WorkflowTab(QWidget): + """Guided automation tab for the full RPGMaker translation workflow.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.parent_window = parent + try: + self.settings = QSettings("DazedTranslations", "DazedMTLTool") + except Exception: + self.settings = None + + # State + self._data_path: str | None = None + self._engine: str = "MVMZ" + self._file_items: list[dict] = [] + self._detect_result: dict | None = None + self._worker = None # active background QThread + # Pre-process paths (auto-populated after folder detection) + self._plugins_js_path: str = "" + self._gameupdate_path: str = "" + + self._init_ui() + + # ───────────────────────────────── UI setup ────────────────────────────── + + def _init_ui(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Splitter: left=steps, right=log + splitter = QSplitter(Qt.Horizontal) + splitter.setHandleWidth(4) + splitter.setStyleSheet("QSplitter::handle{background:#333;}") + + # ---- Left: scrollable step panels ---- + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setStyleSheet("QScrollArea{border:none;}") + container = QWidget() + container.setStyleSheet("background:#1e1e1e;") + vbox = QVBoxLayout(container) + vbox.setContentsMargins(18, 14, 18, 14) + vbox.setSpacing(4) + + self._build_step0(vbox) + vbox.addWidget(_make_hr()) + self._build_preprocess(vbox) + vbox.addWidget(_make_hr()) + self._build_step1(vbox) + vbox.addWidget(_make_hr()) + self._build_step2(vbox) + vbox.addWidget(_make_hr()) + self._build_step3(vbox) + vbox.addWidget(_make_hr()) + self._build_step4(vbox) + vbox.addWidget(_make_hr()) + self._build_step6(vbox) + vbox.addStretch() + + scroll.setWidget(container) + splitter.addWidget(scroll) + + # ---- Right: log area ---- + log_panel = QWidget() + log_panel.setStyleSheet("background:#1e1e1e;") + lp_layout = QVBoxLayout(log_panel) + lp_layout.setContentsMargins(0, 0, 0, 0) + lp_layout.setSpacing(0) + + log_header = QLabel(" Log") + log_header.setStyleSheet( + "background:#252526;color:#ccc;font-size:11px;" + "padding:6px 8px;border-bottom:1px solid #333;" + ) + lp_layout.addWidget(log_header) + + self.log_area = QTextEdit() + self.log_area.setReadOnly(True) + self.log_area.setFont(QFont("Consolas", 9)) + self.log_area.setStyleSheet( + "QTextEdit{background:#1e1e1e;color:#d4d4d4;" + "border:none;padding:8px;}" + ) + lp_layout.addWidget(self.log_area) + + clear_btn = QPushButton("Clear Log") + clear_btn.setStyleSheet( + "QPushButton{background:#252526;color:#888;border:none;" + "font-size:10px;padding:4px;}QPushButton:hover{color:#ccc;}" + ) + clear_btn.clicked.connect(self.log_area.clear) + lp_layout.addWidget(clear_btn) + + splitter.addWidget(log_panel) + splitter.setSizes([640, 380]) + + root.addWidget(splitter) + self.setLayout(root) + + # Auto-detect on first start if a folder was previously saved + if self._setting("last_game_folder", ""): + QTimer.singleShot(100, self._detect_folder) + + # ── Step 0: Project Folder ────────────────────────────────────────────── + + def _build_step0(self, layout: QVBoxLayout): + layout.addWidget(_make_section_label("Step 0 — Project Folder")) + + # Folder picker row + row0 = QHBoxLayout() + self.folder_edit = QLineEdit() + self.folder_edit.setPlaceholderText("Path to game root folder…") + self.folder_edit.setStyleSheet( + "QLineEdit{background:#2d2d30;color:#ccc;border:1px solid #555;" + "padding:5px;font-size:11px;border-radius:2px;}" + ) + saved = self._setting("last_game_folder", "") + if saved: + self.folder_edit.setText(saved) + row0.addWidget(self.folder_edit, 1) + + browse_btn = _make_btn(" Browse…") + browse_btn.setIcon(browse_btn.style().standardIcon(QStyle.SP_DirOpenIcon)) + browse_btn.clicked.connect(self._browse_folder) + row0.addWidget(browse_btn) + + detect_btn = _make_btn("🔍 Detect", "#3a7a3a") + detect_btn.setToolTip("Scan the folder to find the data/ directory and list files") + detect_btn.clicked.connect(self._detect_folder) + row0.addWidget(detect_btn) + layout.addLayout(row0) + + # Detected info + self.detected_label = QLabel("No folder detected yet.") + self.detected_label.setStyleSheet("color:#888;font-size:10px;padding:2px 0;") + layout.addWidget(self.detected_label) + + # File list + self.file_list = QListWidget() + self.file_list.setMaximumHeight(180) + self.file_list.setStyleSheet( + "QListWidget{background:#252526;color:#ccc;border:1px solid #444;" + "font-size:10px;}" + "QListWidget::item:selected{background:#37373d;}" + "QListWidget::item:hover{background:#2a2d2e;}" + ) + layout.addWidget(self.file_list) + + # Action row + row1 = QHBoxLayout() + sel_all = _make_btn("Select All", "#555") + sel_all.clicked.connect(self._select_all_files) + row1.addWidget(sel_all) + + sel_core = _make_btn("Core Only", "#555") + sel_core.setToolTip("Select only core database files; deselect map files") + sel_core.clicked.connect(self._select_core_only) + row1.addWidget(sel_core) + + row1.addStretch() + + self.import_btn = _make_btn("⬇ Import Selected → files/", "#007acc") + self.import_btn.setEnabled(False) + self.import_btn.clicked.connect(self._import_files) + row1.addWidget(self.import_btn) + layout.addLayout(row1) + + # ── Step 1: Vocab / Glossary ──────────────────────────────────────────── + + # ── Copilot prompt templates ──────────────────────────────────────────── + + _PROMPT_GLOSSARY = ( + "You are helping me build a complete translation glossary for a Japanese RPGMaker game.\n" + "\n" + "Please scan ALL of these game files (Actors.json, CommonEvents.json, Map*.json, " + "Troops.json, and any other available files) and produce TWO sections:\n" + "\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "SECTION 1 — NAMED CHARACTERS\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "Identify every NAMED CHARACTER that appears in dialogue or descriptions.\n" + "For each character provide:\n" + " - Japanese name (katakana/kanji as it appears in-game)\n" + " - English transliteration or translation\n" + " - Gender (Male / Female / Unknown) — infer from speech patterns or pronouns\n" + " - Brief role note (e.g. protagonist, antagonist, NPC, merchant)\n" + "\n" + "Format — one entry per line:\n" + "山田太郎 (Yamada Taro) - Male; protagonist; high-school student\n" + "浅井花子 (Asai Hanako) - Female; childhood friend; lives in 栄町\n" + "\n" + "Rules:\n" + " - Named characters only — no generic enemy types or unnamed NPCs.\n" + " - If a character has a player-chosen name (e.g. Actors.json id 1), note it explicitly.\n" + "\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "SECTION 2 — WORLDBUILDING TERMS\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + "Identify lore-specific terms that appear in dialogue, descriptions, or narration " + "but do NOT have a dedicated database file — i.e. terms the translation tool will NOT " + "auto-populate from Skills.json, Items.json, Armors.json, Weapons.json, etc.\n" + "\n" + "Target specifically:\n" + " - Faction / organisation names (kingdoms, guilds, cults, nations)\n" + " - Location names that are mentioned in dialogue but are not map titles\n" + " - Unique magic systems, schools of magic, or power classifications\n" + " - Lore titles and honorifics unique to this setting\n" + " - Recurring in-universe concepts, events, or proper nouns with no English equivalent\n" + "\n" + "For each term provide:\n" + " - Japanese original (as it appears in-game)\n" + " - Recommended English translation\n" + " - Brief note explaining what it is\n" + "\n" + "Format — one entry per line:\n" + "魔世界 (Demon World) - The demonic realm referenced in NPC dialogue; not a named map\n" + "聖剣教団 (Holy Blade Order) - Antagonist faction controlling the eastern territories\n" + "\n" + "Rules:\n" + " - Do NOT list skill names, item names, weapon names, armour names — the tool handles those.\n" + " - Skip generic RPG words (ポーション, レベル, ステータス, etc.).\n" + " - Do NOT repeat character names here — those belong in Section 1." + ) + + # ── Step 1 (Optional): Pre-process ──────────────────────────────── + + def _build_preprocess(self, layout: QVBoxLayout): + header_row = QHBoxLayout() + header_row.addWidget(_make_section_label("Step 1 (Optional) — Pre-process")) + opt_badge = QLabel("personal / optional") + opt_badge.setStyleSheet( + "color:#888;font-size:9px;border:1px solid #555;" + "padding:1px 6px;border-radius:8px;margin-left:6px;" + ) + header_row.addWidget(opt_badge) + header_row.addStretch() + layout.addLayout(header_row) + + hint = QLabel( + "Three optional preparation tasks to run against the game folder before " + "importing files. Paths are auto-filled when a project folder is detected." + ) + hint.setWordWrap(True) + hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:6px;") + layout.addWidget(hint) + + tasks_box = QGroupBox() + tasks_box.setStyleSheet( + "QGroupBox{border:1px solid #3a3a3a;border-radius:3px;margin-top:4px;" + "padding:6px;background:#1a1a1a;}" + ) + tb = QVBoxLayout(tasks_box) + tb.setSpacing(10) + + # ---- Task A: dazedformat ----------------------------------------- + ta = QGroupBox("A — Format JSON files with dazedformat") + ta.setStyleSheet(self._task_box_style()) + ta_inner = QVBoxLayout(ta) + ta_inner.setSpacing(4) + ta_desc = QLabel( + "Runs dazedformat . in the game\'s data folder to " + "normalise all JSON files before importing." + ) + ta_desc.setTextFormat(Qt.RichText) + ta_desc.setWordWrap(True) + ta_desc.setStyleSheet("color:#aaa;font-size:10px;") + ta_inner.addWidget(ta_desc) + ta_path_row = QHBoxLayout() + ta_path_row.addWidget(QLabel("Data folder:")) + self.pp_data_path_label = QLabel("(detect a project folder first)") + self.pp_data_path_label.setStyleSheet("color:#888;font-size:10px;") + ta_path_row.addWidget(self.pp_data_path_label, 1) + ta_inner.addLayout(ta_path_row) + ta_btn_row = QHBoxLayout() + run_dazed = _make_btn("▶ Run dazedformat", "#555") + run_dazed.clicked.connect(self._run_dazedformat) + ta_btn_row.addWidget(run_dazed) + ta_btn_row.addStretch() + ta_inner.addLayout(ta_btn_row) + tb.addWidget(ta) + + # ---- Task B: prettier on plugins.js ----------------------------- + tb_box = QGroupBox("B — Format plugins.js with Prettier") + tb_box.setStyleSheet(self._task_box_style()) + tb_inner = QVBoxLayout(tb_box) + tb_inner.setSpacing(4) + tb_desc = QLabel( + "Formats plugins.js using jsbeautifier (pure Python — no Node.js required) " + "so it is human-readable before editing or inspection." + ) + tb_desc.setTextFormat(Qt.RichText) + tb_desc.setWordWrap(True) + tb_desc.setStyleSheet("color:#aaa;font-size:10px;") + tb_inner.addWidget(tb_desc) + tb_path_row = QHBoxLayout() + tb_path_lbl = QLabel("plugins.js:") + tb_path_row.addWidget(tb_path_lbl) + self.pp_plugins_edit = QLineEdit() + self.pp_plugins_edit.setPlaceholderText("Path to plugins.js…") + self.pp_plugins_edit.setStyleSheet( + "QLineEdit{background:#2d2d30;color:#ccc;border:1px solid #555;" + "padding:3px;font-size:10px;border-radius:2px;}" + ) + tb_path_row.addWidget(self.pp_plugins_edit, 1) + browse_plugins = _make_btn("", "#444") + browse_plugins.setIcon(browse_plugins.style().standardIcon(QStyle.SP_DirOpenIcon)) + browse_plugins.setFixedWidth(34) + browse_plugins.clicked.connect(self._browse_plugins_js) + tb_path_row.addWidget(browse_plugins) + tb_inner.addLayout(tb_path_row) + tb_btn_row = QHBoxLayout() + run_prettier = _make_btn("▶ Format plugins.js", "#555") + run_prettier.clicked.connect(self._run_prettier) + tb_btn_row.addWidget(run_prettier) + tb_btn_row.addStretch() + tb_inner.addLayout(tb_btn_row) + tb.addWidget(tb_box) + + # ---- Task C: copy gameupdate/ ----------------------------------- + tc = QGroupBox("C — Apply gameupdate/ patch files") + tc.setStyleSheet(self._task_box_style()) + tc_inner = QVBoxLayout(tc) + tc_inner.setSpacing(4) + tc_desc = QLabel( + "Copies everything from the gameupdate/ folder in the game root " + "into the game\'s data folder, overwriting existing files." + ) + tc_desc.setTextFormat(Qt.RichText) + tc_desc.setWordWrap(True) + tc_desc.setStyleSheet("color:#aaa;font-size:10px;") + tc_inner.addWidget(tc_desc) + + tc_src_row = QHBoxLayout() + tc_src_row.addWidget(QLabel("Source:")) + self.pp_gameupdate_edit = QLineEdit() + self.pp_gameupdate_edit.setPlaceholderText("Path to gameupdate/ folder…") + self.pp_gameupdate_edit.setStyleSheet( + "QLineEdit{background:#2d2d30;color:#ccc;border:1px solid #555;" + "padding:3px;font-size:10px;border-radius:2px;}" + ) + tc_src_row.addWidget(self.pp_gameupdate_edit, 1) + browse_gu = _make_btn("", "#444") + browse_gu.setIcon(browse_gu.style().standardIcon(QStyle.SP_DirOpenIcon)) + browse_gu.setFixedWidth(34) + browse_gu.clicked.connect(self._browse_gameupdate) + tc_src_row.addWidget(browse_gu) + tc_inner.addLayout(tc_src_row) + + tc_dst_row = QHBoxLayout() + tc_dst_row.addWidget(QLabel("Destination:")) + self.pp_gameupdate_dst_label = QLabel("(data folder auto-filled from project)") + self.pp_gameupdate_dst_label.setStyleSheet("color:#888;font-size:10px;") + tc_dst_row.addWidget(self.pp_gameupdate_dst_label, 1) + tc_inner.addLayout(tc_dst_row) + + tc_btn_row = QHBoxLayout() + run_gu = _make_btn("▶ Copy gameupdate/", "#555") + run_gu.clicked.connect(self._run_gameupdate) + tc_btn_row.addWidget(run_gu) + tc_btn_row.addStretch() + tc_inner.addLayout(tc_btn_row) + tb.addWidget(tc) + + layout.addWidget(tasks_box) + + # ---- Run All button ------------------------------------------ + run_all_row = QHBoxLayout() + run_all_btn = _make_btn("▶▶ Run All 3 Tasks", "#007acc") + run_all_btn.setToolTip("Run dazedformat, prettier, and gameupdate copy in sequence") + run_all_btn.clicked.connect(self._run_all_preprocess) + run_all_row.addStretch() + run_all_row.addWidget(run_all_btn) + layout.addLayout(run_all_row) + + @staticmethod + def _task_box_style() -> str: + return ( + "QGroupBox{color:#bbb;border:1px solid #444;border-radius:2px;" + "margin-top:6px;font-size:10px;padding:4px;}" + "QGroupBox::title{padding:0 4px;}" + ) + + def _build_step1(self, layout: QVBoxLayout): + layout.addWidget(_make_section_label("Step 2 — Glossary (vocab.txt)")) + hint = QLabel( + "Edit your glossary below. Character names, genders, and worldbuilding terms " + "here are injected into every AI prompt for consistent terminology." + ) + hint.setWordWrap(True) + hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:4px;") + layout.addWidget(hint) + + # ---- Copilot / Cursor prompt helpers -------------------------------- + prompt_box = QGroupBox("AI Prompt Helpers (Copilot / Cursor)") + prompt_box.setStyleSheet( + "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;" + "margin-top:8px;font-size:11px;}" + "QGroupBox::title{padding:0 6px;}" + ) + pb_inner = QVBoxLayout(prompt_box) + pb_inner.setSpacing(6) + + prompt_hint = QLabel( + "Copy a prompt below, paste it into GitHub Copilot Chat or Cursor with your " + "game files open, then paste the AI’s output into vocab.txt." + ) + prompt_hint.setWordWrap(True) + prompt_hint.setStyleSheet("color:#888;font-size:10px;") + pb_inner.addWidget(prompt_hint) + + # Combined glossary prompt + glossary_row = QHBoxLayout() + glossary_label = QLabel( + "👥🌍 Full Glossary — named characters (with gender & role) " + "and unique worldbuilding terms in one pass." + ) + glossary_label.setTextFormat(Qt.RichText) + glossary_label.setWordWrap(True) + glossary_label.setStyleSheet("color:#ccc;font-size:10px;") + glossary_row.addWidget(glossary_label, 1) + copy_glossary_btn = _make_btn("📋 Copy Prompt", "#555") + copy_glossary_btn.setToolTip("Copy the full glossary discovery prompt to clipboard") + copy_glossary_btn.clicked.connect(lambda: self._copy_to_clipboard(self._PROMPT_GLOSSARY, "Glossary prompt copied.")) + glossary_row.addWidget(copy_glossary_btn) + pb_inner.addLayout(glossary_row) + + layout.addWidget(prompt_box) + + # ---- vocab.txt editor ----------------------------------------------- + layout.addWidget(_make_section_label("vocab.txt editor")) + format_hint = QLabel( + "Expected line format: Japanese (English) - Gender; role; notes\n" + "Example: 山田太郎 (Yamada Taro) - Male; protagonist; village blacksmith" + ) + format_hint.setFont(QFont("Consolas", 9)) + format_hint.setStyleSheet( + "color:#569cd6;background:#252526;border:1px solid #3a3a3a;" + "padding:5px 8px;border-radius:2px;font-size:10px;" + ) + layout.addWidget(format_hint) + + self.vocab_editor = QTextEdit() + self.vocab_editor.setMinimumHeight(160) + self.vocab_editor.setFont(QFont("Consolas", 9)) + self.vocab_editor.setStyleSheet( + "QTextEdit{background:#252526;color:#d4d4d4;border:1px solid #444;" + "padding:6px;}" + ) + self._reload_vocab() + layout.addWidget(self.vocab_editor) + + row = QHBoxLayout() + save_vocab = _make_btn("💾 Save vocab.txt", "#3a7a3a") + save_vocab.clicked.connect(self._save_vocab) + row.addWidget(save_vocab) + + reload_vocab = _make_btn("↺ Reload", "#555") + reload_vocab.clicked.connect(self._reload_vocab) + row.addWidget(reload_vocab) + row.addStretch() + layout.addLayout(row) + + # ── Step 2: Actor Variables ───────────────────────────────────────────── + + def _build_step2(self, layout: QVBoxLayout): + layout.addWidget(_make_section_label("Step 3 — Actor Variable Substitution")) + hint = QLabel( + "Replaces \\n[X] RPGMaker name variables with actual actor names " + "so the AI has proper context. After translation the variables are " + "restored — rename-able protagonists continue to work at runtime." + ) + hint.setWordWrap(True) + hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:4px;") + layout.addWidget(hint) + + self.actor_status = QLabel("Click Scan to preview substitutions.") + self.actor_status.setWordWrap(True) + self.actor_status.setStyleSheet("color:#aaa;font-size:10px;padding:4px 0;") + layout.addWidget(self.actor_status) + + row = QHBoxLayout() + scan_actors = _make_btn("🔍 Scan \\n[X] usage", "#555") + scan_actors.setToolTip("Scan files/ and show which actor IDs are referenced") + scan_actors.clicked.connect(self._scan_actor_vars) + row.addWidget(scan_actors) + row.addStretch() + layout.addLayout(row) + + row2 = QHBoxLayout() + self.sub_btn = _make_btn("▶ Substitute \\n[X] → Names (pre-translation)", "#007acc") + self.sub_btn.setToolTip("Modifies files/ in-place. Run before translating.") + self.sub_btn.clicked.connect(self._substitute_actors) + row2.addWidget(self.sub_btn) + + self.restore_btn = _make_btn("↩ Restore Names → \\n[X] (post-translation)", "#7a4a7a") + self.restore_btn.setToolTip("Modifies translated/ in-place. Run after translating.") + self.restore_btn.clicked.connect(self._restore_actors) + row2.addWidget(self.restore_btn) + layout.addLayout(row2) + + # ── Step 3: Speaker Detection ─────────────────────────────────────────── + + def _build_step3(self, layout: QVBoxLayout): + layout.addWidget(_make_section_label("Step 4 — Speaker Format Detection")) + hint = QLabel( + "Scans map files to determine how speaker names are embedded in dialogue " + "and automatically sets the correct INLINE401SPEAKERS / FIRSTLINESPEAKERS " + "/ FACENAME101 flags." + ) + hint.setWordWrap(True) + hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:4px;") + layout.addWidget(hint) + + self.speaker_result = QLabel("Run the scan to auto-detect speaker format.") + self.speaker_result.setWordWrap(True) + self.speaker_result.setStyleSheet( + "color:#aaa;font-size:10px;padding:4px 0;" + "background:#252526;border:1px solid #444;padding:6px;border-radius:2px;" + ) + layout.addWidget(self.speaker_result) + + row = QHBoxLayout() + scan_spk = _make_btn("🔍 Scan Speaker Format", "#555") + scan_spk.clicked.connect(self._detect_speakers) + row.addWidget(scan_spk) + + self.apply_speaker_btn = _make_btn("✔ Apply to RPGMaker Settings", "#3a7a3a") + self.apply_speaker_btn.setEnabled(False) + self.apply_speaker_btn.clicked.connect(self._apply_speaker_settings) + row.addWidget(self.apply_speaker_btn) + row.addStretch() + layout.addLayout(row) + + # ── Step 4: Translation ───────────────────────────────────────────────── + + def _build_step4(self, layout: QVBoxLayout): + layout.addWidget(_make_section_label("Step 5 — Translation")) + + p1_box = QGroupBox("Phase 1 – Safe Codes (dialogue + choices)") + p1_box.setStyleSheet( + "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;" + "margin-top:8px;font-size:11px;}" + "QGroupBox::title{padding:0 6px;}" + ) + p1_inner = QVBoxLayout(p1_box) + p1_desc = QLabel("Codes: 401 (Show Text), 405 (continued), 102 (Choices), 408 (extra lines)\n" + "These rarely break game logic — safe to translate first.") + p1_desc.setStyleSheet("color:#888;font-size:10px;") + p1_desc.setWordWrap(True) + p1_inner.addWidget(p1_desc) + p1_row = QHBoxLayout() + run_p1 = _make_btn("▶ Run Phase 1", "#007acc") + run_p1.clicked.connect(lambda: self._run_phase(1)) + p1_row.addWidget(run_p1) + p1_row.addStretch() + p1_inner.addLayout(p1_row) + layout.addWidget(p1_box) + + p2_box = QGroupBox("Phase 2 – Risky Codes (variables + conditionals)") + p2_box.setStyleSheet( + "QGroupBox{color:#ccc;border:1px solid #444;border-radius:3px;" + "margin-top:8px;font-size:11px;}" + "QGroupBox::title{padding:0 6px;}" + ) + p2_inner = QVBoxLayout(p2_box) + p2_desc = QLabel( + "Codes: 122 (Control Variables), 357 (Plugin Commands), 111 (Conditional Branch)\n" + "Strings in these codes are often used in script comparisons — mistranslations " + "can break game logic. Scan first, then translate carefully." + ) + p2_desc.setStyleSheet("color:#888;font-size:10px;") + p2_desc.setWordWrap(True) + p2_inner.addWidget(p2_desc) + + p2_row = QHBoxLayout() + scan_p2 = _make_btn("🔍 Scan Risky Strings", "#555") + scan_p2.setToolTip( + "Preview all unique string values in codes 122 / 357 / 111 " + "before deciding what to translate." + ) + scan_p2.clicked.connect(self._scan_risky_strings) + p2_row.addWidget(scan_p2) + + run_p2 = _make_btn("▶ Run Phase 2", "#7a4a00") + run_p2.clicked.connect(lambda: self._run_phase(2)) + p2_row.addWidget(run_p2) + p2_row.addStretch() + p2_inner.addLayout(p2_row) + layout.addWidget(p2_box) + + # ── Step 6: Export ────────────────────────────────────────────────────── + + def _build_step6(self, layout: QVBoxLayout): + layout.addWidget(_make_section_label("Step 6 — Export to Game")) + hint = QLabel( + "Copy all files from translated/ back into the game's data folder " + "to patch the game in-place." + ) + hint.setWordWrap(True) + hint.setStyleSheet("color:#888;font-size:10px;padding-bottom:4px;") + layout.addWidget(hint) + + row = QHBoxLayout() + export_btn = _make_btn("📤 Export translated/ → Game Folder", "#3a7a3a") + export_btn.clicked.connect(self._export_to_game) + row.addWidget(export_btn) + row.addStretch() + layout.addLayout(row) + + # ───────────────────────────────────────────────────────────────────────── + # Step 0 – Project Folder logic + # ───────────────────────────────────────────────────────────────────────── + + def _browse_folder(self): + start = self.folder_edit.text() or self._setting("last_game_folder", "") + folder = QFileDialog.getExistingDirectory(self, "Select Game Root Folder", start) + if folder: + self.folder_edit.setText(folder) + self._save_setting("last_game_folder", folder) + self._detect_folder() + + def _detect_folder(self): + folder = self.folder_edit.text().strip() + if not folder: + self._log("⚠ No folder path entered.") + return + + self._save_setting("last_game_folder", folder) + self.detected_label.setText("Scanning…") + self.file_list.clear() + self.import_btn.setEnabled(False) + + try: + from util.project_scanner import find_data_folder + data_path, engine = find_data_folder(folder) + except Exception as exc: + self.detected_label.setText(f"Error: {exc}") + return + + if data_path is None: + self.detected_label.setText( + "⚠ No recognised data folder found. " + "Make sure this is a valid RPGMaker game directory." + ) + return + + self._data_path = str(data_path) + self._engine = engine + self.detected_label.setText( + f"Engine: {engine} · Data folder: {data_path}" + ) + self._log(f"Detected data folder: {data_path} (engine: {engine})") + + worker = _ScanWorker(self._data_path, self._engine) + worker.done.connect(self._on_scan_done) + worker.error.connect(lambda e: self._log(f"❌ Scan error: {e}")) + self._worker = worker + worker.start() + + def _on_scan_done(self, items: list): + self._file_items = items + self.file_list.clear() + + for item in items: + cat = item["category"] + icon = "📄" if cat == "core" else ("🗺" if cat == "map" else "❓") + lw = QListWidgetItem(f"{icon} {item['name']} ({item['size_kb']:.1f} KB)") + lw.setData(Qt.UserRole, item) + lw.setCheckState(Qt.Checked if item["default"] else Qt.Unchecked) + if cat == "core": + lw.setForeground(__import__("PyQt5.QtGui", fromlist=["QColor"]).QColor("#9cdcfe")) + elif cat == "map": + lw.setForeground(__import__("PyQt5.QtGui", fromlist=["QColor"]).QColor("#c5c5c0")) + self.file_list.addItem(lw) + + self.import_btn.setEnabled(len(items) > 0) + self._log(f"Found {len(items)} importable file(s).") + self._populate_preprocess_paths() + + def _select_all_files(self): + count = self.file_list.count() + for i in range(count): + self.file_list.item(i).setCheckState(Qt.Checked) + if count: + self._log(f"✔ Selected all {count} file(s).") + + def _select_core_only(self): + core = other = 0 + for i in range(self.file_list.count()): + item = self.file_list.item(i) + data = item.data(Qt.UserRole) + is_core = bool(data and data.get("category") == "core") + item.setCheckState(Qt.Checked if is_core else Qt.Unchecked) + if is_core: + core += 1 + else: + other += 1 + if self.file_list.count(): + self._log(f"✔ Selected {core} core file(s); deselected {other} other(s).") + + def _import_files(self): + selected = [] + for i in range(self.file_list.count()): + lw = self.file_list.item(i) + if lw.checkState() == Qt.Checked: + selected.append(lw.data(Qt.UserRole)) + + if not selected: + self._log("⚠ No files selected.") + return + + self.import_btn.setEnabled(False) + worker = _ImportWorker(selected, "files") + worker.log.connect(self._log) + worker.done.connect(self._on_import_done) + self._worker = worker + worker.start() + + def _on_import_done(self, count: int, errors: list): + self.import_btn.setEnabled(True) + if errors: + self._log(f"⚠ {len(errors)} error(s) during import:") + for e in errors[:10]: + self._log(f" {e}") + self._log(f"✅ Imported {count} file(s) into files/") + + # ───────────────────────────────────────────────────────────────────────── + # Step 1 – Vocab + # ───────────────────────────────────────────────────────────────────────── + + def _reload_vocab(self): + vocab_path = Path("vocab.txt") + try: + if vocab_path.exists(): + self.vocab_editor.setPlainText(vocab_path.read_text(encoding="utf-8")) + else: + self.vocab_editor.setPlainText("# Add character glossary entries here\n") + except Exception as exc: + self._log(f"❌ Could not load vocab.txt: {exc}") + + def _save_vocab(self): + try: + Path("vocab.txt").write_text( + self.vocab_editor.toPlainText(), encoding="utf-8" + ) + self._log("✅ vocab.txt saved.") + except Exception as exc: + self._log(f"❌ Could not save vocab.txt: {exc}") + + # ───────────────────────────────────────────────────────────────────────── + # Step 2 – Actor substitution + # ───────────────────────────────────────────────────────────────────────── + + def _scan_actor_vars(self): + try: + from util.actor_substitutor import scan_variables, load_actor_map + counts = scan_variables("files") + actor_map = load_actor_map() + if not counts: + self.actor_status.setText("No \\n[X] variables found in files/") + return + lines = [] + for aid, cnt in sorted(counts.items()): + name = actor_map.get(aid, "???") + lines.append(f" \\n[{aid}] → {name} ({cnt} occurrence(s))") + self.actor_status.setText("\n".join(lines)) + self._log("Actor variable scan complete.") + except Exception as exc: + self._log(f"❌ Scan error: {exc}") + + def _substitute_actors(self): + reply = QMessageBox.question( + self, + "Substitute Actor Variables", + "This will modify files/ in-place, replacing \\n[X] codes with actor names.\n\n" + "Back up files/ first if needed. Continue?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if reply != QMessageBox.Yes: + return + self.sub_btn.setEnabled(False) + w = _ActorSubstituteWorker("substitute") + w.log.connect(self._log) + w.done.connect(self._on_actor_done) + self._worker = w + w.start() + + def _restore_actors(self): + reply = QMessageBox.question( + self, + "Restore Actor Variables", + "This will modify translated/ in-place, replacing actor names back with \\n[X] codes.\n\n" + "Continue?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if reply != QMessageBox.Yes: + return + self.restore_btn.setEnabled(False) + w = _ActorSubstituteWorker("restore") + w.log.connect(self._log) + w.done.connect(self._on_actor_done) + self._worker = w + w.start() + + def _on_actor_done(self, stats: dict): + self.sub_btn.setEnabled(True) + self.restore_btn.setEnabled(True) + if "error" in stats: + self._log(f"❌ {stats['error']}") + return + mods = stats.get("files_modified", 0) + total = stats.get("total_substitutions", 0) + found = stats.get("variables_found", {}) + errors = stats.get("errors", []) + self._log( + f"✅ {total} substitution(s) across {mods} file(s). " + f"Actors: {', '.join(f'ID {k}={v}' for k, v in found.items()) or 'none'}" + ) + for e in errors: + self._log(f" ⚠ {e}") + + # ───────────────────────────────────────────────────────────────────────── + # Step 3 – Speaker detection + # ───────────────────────────────────────────────────────────────────────── + + def _detect_speakers(self): + self.apply_speaker_btn.setEnabled(False) + self.speaker_result.setText("Scanning…") + w = _SpeakerDetectWorker() + w.log.connect(self._log) + w.done.connect(self._on_speaker_done) + self._worker = w + w.start() + + def _on_speaker_done(self, result: dict): + if "error" in result: + self.speaker_result.setText(f"❌ Error: {result['error']}") + self._log(f"❌ Speaker detection error: {result['error']}") + return + + self._detect_result = result + scores = result.get("scores", {}) + best = result.get("best_mode", "NONE") + conf = result.get("confidence", "low") + note = result.get("note", "") + + conf_color = {"high": "#4ec9b0", "medium": "#dcdcaa", "low": "#f48771"}.get(conf, "#ccc") + score_str = " | ".join(f"{k}: {v}" for k, v in scores.items()) + self.speaker_result.setText( + f"Best mode: {best} ({conf} confidence)\n" + f"Scores — {score_str}\n" + f"{note}" + ) + self._log(f"Speaker detection: {best} ({conf}) — {note}") + + if best != "NONE": + self.apply_speaker_btn.setEnabled(True) + + def _apply_speaker_settings(self): + if not self._detect_result: + return + cfg = self._detect_result.get("recommended_config", {}) + try: + from gui.config_integration import ConfigIntegration + ci = ConfigIntegration() + ci.update_rpgmaker_config(cfg) + self._log( + f"✅ Applied speaker settings: " + + ", ".join(f"{k}={v}" for k, v in cfg.items()) + ) + # Refresh the RPGMaker tab if accessible + self._refresh_rpgmaker_tab(cfg) + except Exception as exc: + self._log(f"❌ Could not apply settings: {exc}") + + def _refresh_rpgmaker_tab(self, cfg: dict): + """Push the new config to the RPGMaker tab widget so it refreshes live.""" + try: + if self.parent_window and hasattr(self.parent_window, "config_tab"): + ct = self.parent_window.config_tab + if hasattr(ct, "rpgmaker_tab") and ct.rpgmaker_tab: + ct.rpgmaker_tab.set_config(cfg) + except Exception: + pass + + # ───────────────────────────────────────────────────────────────────────── + # Step 4 – Translation phases + # ───────────────────────────────────────────────────────────────────────── + + def _run_phase(self, phase: int): + config = PHASE1_CONFIG if phase == 1 else PHASE2_CONFIG + label = "Phase 1 (safe codes)" if phase == 1 else "Phase 2 (risky codes)" + + # Apply config profile + try: + from gui.config_integration import ConfigIntegration + ci = ConfigIntegration() + ci.update_rpgmaker_config(config) + self._log(f"Applied {label} config profile.") + except Exception as exc: + self._log(f"❌ Could not apply phase config: {exc}") + return + + # Launch translation worker + try: + from gui.translation_tab import TranslationWorker + except Exception as exc: + self._log(f"❌ Could not import TranslationWorker: {exc}") + return + + project_root = Path(__file__).parent.parent + module_info = ["RPG Maker MV/MZ", [".json"], None] + + # Build file order: core DB files first (they populate the glossary), + # then map files. Within each group, sort alphabetically. + files_dir = project_root / "files" + all_json = sorted( + p.name for p in files_dir.glob("*.json") if p.name != ".gitkeep" + ) if files_dir.exists() else [] + core_files = [f for f in all_json if not f.lower().startswith("map")] + map_files = [f for f in all_json if f.lower().startswith("map")] + ordered_files = core_files + map_files + if ordered_files: + self._log( + f"File order: {len(core_files)} core file(s) first, " + f"then {len(map_files)} map file(s)." + ) + + self._log(f"Starting {label} translation…") + worker = TranslationWorker( + project_root, module_info, estimate_only=False, + selected_files=ordered_files if ordered_files else None, + ) + worker.log_signal.connect(self._log) + worker.progress_signal.connect( + lambda cur, tot, fn: self._log(f" [{cur}/{tot}] {fn}") + ) + worker.finished_signal.connect( + lambda ok, msg: self._log( + f"{'✅' if ok else '❌'} {label} finished: {msg}" + ) + ) + self._worker = worker + worker.start() + + # ───────────────────────────────────────────────────────────────────────── + # Risky string scanner (preview mode) + # ───────────────────────────────────────────────────────────────────────── + + def _scan_risky_strings(self): + risky_codes = {122, 357, 111} + collected: dict[int, list[str]] = {c: [] for c in risky_codes} + files_dir = Path("files") + + try: + for fp in sorted(files_dir.glob("*.json")): + data = json.loads(fp.read_text(encoding="utf-8-sig")) + self._collect_risky(data, risky_codes, collected) + + self._log("=" * 50) + self._log("Risky Code String Scan (preview — nothing modified):") + total = 0 + for code in sorted(risky_codes): + strings = sorted(set(collected[code])) + total += len(strings) + self._log(f"\n CODE {code} — {len(strings)} unique string(s):") + for s in strings[:50]: + self._log(f" {repr(s)}") + if len(strings) > 50: + self._log(f" … ({len(strings) - 50} more)") + self._log(f"\n Total unique strings: {total}") + self._log("=" * 50) + except Exception as exc: + self._log(f"❌ Scan error: {exc}") + + def _collect_risky(self, obj, risky_codes: set, collected: dict): + if isinstance(obj, list): + for item in obj: + self._collect_risky(item, risky_codes, collected) + elif isinstance(obj, dict): + code = obj.get("code") + if code in risky_codes: + params = obj.get("parameters") or [] + for p in params: + if isinstance(p, str) and p.strip(): + collected[code].append(p) + for v in obj.values(): + self._collect_risky(v, risky_codes, collected) + + # ───────────────────────────────────────────────────────────────────────── + # Step 5 – Export to game + # ───────────────────────────────────────────────────────────────────────── + + def _export_to_game(self): + game_data = self._data_path + if not game_data: + # Prompt the user + game_data = QFileDialog.getExistingDirectory( + self, "Select Game Data Folder to Export Into" + ) + if not game_data: + return + self._data_path = game_data + + reply = QMessageBox.question( + self, + "Export to Game", + f"This will overwrite files in:\n{game_data}\n\n" + "Make a backup first if needed. Continue?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply != QMessageBox.Yes: + return + + w = _ExportWorker(game_data) + w.log.connect(self._log) + w.done.connect(self._on_export_done) + self._worker = w + w.start() + + def _on_export_done(self, count: int, errors: list): + if errors: + self._log(f"⚠ {len(errors)} error(s) during export:") + for e in errors[:10]: + self._log(f" {e}") + self._log(f"✅ Exported {count} file(s) to game folder.") + + # ───────────────────────────────────────────────────────────────────────── + # Helpers + # ───────────────────────────────────────────────────────────────────────── + + # ───────────────────────────────────────────────────────────────────────────── + # Step 1 (Optional) – Pre-process handlers + # ───────────────────────────────────────────────────────────────────────────── + + def _populate_preprocess_paths(self): + """Auto-fill pre-process paths from the detected game root and data path.""" + game_root = self.folder_edit.text().strip() + data_path = self._data_path or "" + + # Update dazedformat label + try: + self.pp_data_path_label.setText(data_path or "(no data folder detected)") + except Exception: + pass + + # Find plugins.js + if game_root: + for candidate in ( + Path(game_root) / "js" / "plugins.js", + Path(game_root) / "www" / "js" / "plugins.js", + ): + if candidate.is_file(): + self._plugins_js_path = str(candidate) + break + else: + self._plugins_js_path = str(Path(game_root) / "js" / "plugins.js") + try: + self.pp_plugins_edit.setText(self._plugins_js_path) + except Exception: + pass + + # Gameupdate path — default to the tool's own gameupdate/ folder + tool_gameupdate = Path(__file__).parent.parent / "gameupdate" + self._gameupdate_path = str(tool_gameupdate) + try: + self.pp_gameupdate_edit.setText(self._gameupdate_path) + except Exception: + pass + try: + self.pp_gameupdate_dst_label.setText(data_path or "(no data folder detected)") + except Exception: + pass + + def _browse_plugins_js(self): + start = self.pp_plugins_edit.text() or self.folder_edit.text() + path, _ = QFileDialog.getOpenFileName( + self, "Select plugins.js", start, "JavaScript files (*.js);;All files (*)" + ) + if path: + self.pp_plugins_edit.setText(path) + + def _browse_gameupdate(self): + start = self.pp_gameupdate_edit.text() or self.folder_edit.text() + folder = QFileDialog.getExistingDirectory(self, "Select gameupdate folder", start) + if folder: + self.pp_gameupdate_edit.setText(folder) + + def _run_dazedformat(self): + data_path = self._data_path + if not data_path: + self._log("⚠ No data folder detected. Complete Step 0 first.") + return + w = _SubprocessWorker(["dazedformat", "."], cwd=data_path, label="dazedformat") + w.log.connect(self._log) + w.done.connect(lambda ok, msg: self._log(("✅ " if ok else "❌ ") + msg)) + self._worker = w + w.start() + + def _run_prettier(self): + plugins_js = self.pp_plugins_edit.text().strip() + if not plugins_js: + self._log("⚠ No plugins.js path set.") + return + p = Path(plugins_js) + if not p.is_file(): + self._log(f"⚠ plugins.js not found: {p}") + return + w = _JsFormatWorker(str(p)) + w.log.connect(self._log) + w.done.connect(lambda ok, msg: self._log(("✅ " if ok else "❌ ") + msg)) + self._worker = w + w.start() + + def _run_gameupdate(self): + src = self.pp_gameupdate_edit.text().strip() + dst = self._data_path + if not src: + self._log("⚠ No gameupdate folder path set.") + return + if not dst: + self._log("⚠ No data folder detected. Complete Step 0 first.") + return + if not Path(src).is_dir(): + self._log(f"⚠ gameupdate folder not found: {src}") + return + w = _FileCopyWorker(src, dst) + w.log.connect(self._log) + w.done.connect(self._on_gameupdate_done) + self._worker = w + w.start() + + def _on_gameupdate_done(self, count: int, errors: list): + self._log(f"✅ gameupdate: copied {count} file(s).") + for e in errors: + self._log(f" ⚠ {e}") + + def _run_all_preprocess(self): + """Launch all three pre-process tasks, skipping any whose prerequisites are missing.""" + import shutil as _shutil + data_path = self._data_path + plugins_js = self.pp_plugins_edit.text().strip() + gameupdate_src = self.pp_gameupdate_edit.text().strip() + skipped: list[str] = [] + + # A — dazedformat + if data_path and _shutil.which("dazedformat"): + self._log("▶ [A] dazedformat …") + self._run_dazedformat() + else: + reason = "data folder missing" if not data_path else "dazedformat not on PATH" + skipped.append(f"A (dazedformat): {reason}") + + # B — jsbeautifier (pure Python, no Node required) + if plugins_js and Path(plugins_js).is_file(): + self._log("▶ [B] formatting plugins.js …") + self._run_prettier() + else: + reason = f"plugins.js not found ({plugins_js or 'not set'})" + skipped.append(f"B (format plugins.js): {reason}") + + # C — gameupdate copy + if gameupdate_src and Path(gameupdate_src).is_dir() and data_path: + self._log("▶ [C] gameupdate copy …") + self._run_gameupdate() + else: + if not gameupdate_src or not Path(gameupdate_src).is_dir(): + reason = f"source not found ({gameupdate_src or 'not set'})" + else: + reason = "data folder missing" + skipped.append(f"C (gameupdate): {reason}") + + for msg in skipped: + self._log(f" ⏭ Skipped: {msg}") + + def _copy_to_clipboard(self, text: str, confirmation: str = "Copied."): + try: + QApplication.clipboard().setText(text) + self._log(f"📋 {confirmation}") + except Exception as exc: + self._log(f"❌ Could not copy to clipboard: {exc}") + + def _log(self, message: str): + self.log_area.append(message) + sb = self.log_area.verticalScrollBar() + sb.setValue(sb.maximum()) + + def _setting(self, key: str, default=None): + if self.settings: + return self.settings.value(f"workflow/{key}", default) + return default + + def _save_setting(self, key: str, value): + if self.settings: + self.settings.setValue(f"workflow/{key}", value) diff --git a/util/actor_substitutor.py b/util/actor_substitutor.py new file mode 100644 index 0000000..b3663ed --- /dev/null +++ b/util/actor_substitutor.py @@ -0,0 +1,248 @@ +""" +Actor Variable Substitutor + +RPGMaker uses \\n[X] (stored as \\n[X] in JSON, single backslash after json.load) +to pull actor names dynamically at runtime. This module: + + 1. Builds a map of actor_id -> name from Actors.json + (prefers translated/Actors.json over files/Actors.json so the AI + receives the target-language name in context) + + 2. substitute_in_files() – walks every .json file inside `files/`, + replaces \\n[X] with the actor's name, writes the file back. + Returns per-file statistics. + + 3. restore_in_translated() – walks every .json file inside `translated/`, + replaces the substituted name back with \\n[X]. + Returns per-file statistics. + +The rename-able protagonist case is handled automatically: substitute the +default/translated name for context during translation, then restore the +\\n[X] variable so the player's custom name still works at runtime. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Optional + +# Pattern that matches \n[X] after json.load() (single literal backslash) +_VAR_RE = re.compile(r"\\n\[(\d+)\]", re.IGNORECASE) + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def load_actor_map( + files_dir: str | Path = "files", + translated_dir: str | Path = "translated", +) -> dict[int, str]: + """Return {actor_id: name}. + + Prefers translated/Actors.json (English names) over files/Actors.json. + """ + for candidates in ( + Path(translated_dir) / "Actors.json", + Path(files_dir) / "Actors.json", + ): + if candidates.is_file(): + try: + with open(candidates, "r", encoding="utf-8-sig") as fh: + data = json.load(fh) + actor_map: dict[int, str] = {} + for entry in data: + if not entry or not isinstance(entry, dict): + continue + actor_id = entry.get("id") + name = (entry.get("name") or "").strip() + if actor_id is not None and name: + actor_map[int(actor_id)] = name + if actor_map: + return actor_map + except Exception: + continue + return {} + + +def substitute_in_files( + files_dir: str | Path = "files", + actor_map: Optional[dict[int, str]] = None, + translated_dir: str | Path = "translated", +) -> dict: + """Replace \\n[X] with actor names in all .json files inside *files_dir*. + + If *actor_map* is None it is built automatically via load_actor_map(). + Returns a summary dict: + { + "files_modified": int, + "total_substitutions": int, + "variables_found": {actor_id: name, ...}, + "errors": [str, ...] + } + """ + files_dir = Path(files_dir) + if actor_map is None: + actor_map = load_actor_map(files_dir, translated_dir) + + stats = { + "files_modified": 0, + "total_substitutions": 0, + "variables_found": {}, + "errors": [], + } + + for fp in sorted(files_dir.glob("*.json")): + try: + original = fp.read_text(encoding="utf-8-sig") + data = json.loads(original) + count = [0] + found: dict[int, str] = {} + + new_data = _walk(data, actor_map, count, found, substitute=True) + + if count[0] > 0: + stats["total_substitutions"] += count[0] + stats["files_modified"] += 1 + stats["variables_found"].update(found) + out = json.dumps(new_data, ensure_ascii=False, indent=4) + fp.write_text(out + "\n", encoding="utf-8") + except Exception as exc: + stats["errors"].append(f"{fp.name}: {exc}") + + return stats + + +def restore_in_translated( + translated_dir: str | Path = "translated", + actor_map: Optional[dict[int, str]] = None, + files_dir: str | Path = "files", +) -> dict: + """Replace actor names back with \\n[X] in all .json files inside *translated_dir*. + + If *actor_map* is None it is built via load_actor_map() which prefers + translated/Actors.json (the already-translated names). + + Returns a summary dict with the same shape as substitute_in_files(). + """ + translated_dir = Path(translated_dir) + if actor_map is None: + actor_map = load_actor_map(files_dir, translated_dir) + + # Build reverse map: name (lowercased) → "\\n[X]" + # We match case-insensitively so the AI's casing doesn't break restore. + # We keep the original-case name for display in stats. + reverse: dict[str, tuple[str, int]] = {} # lower_name -> (var_string, actor_id) + for aid, name in actor_map.items(): + if name: + reverse[name.lower()] = (f"\\n[{aid}]", aid) + + stats = { + "files_modified": 0, + "total_substitutions": 0, + "variables_found": {}, + "errors": [], + } + + for fp in sorted(translated_dir.glob("*.json")): + # Never restore inside Actors.json itself — that file has the names + # as first-class translated data, not as variable stand-ins. + if fp.name == "Actors.json": + continue + try: + data = json.loads(fp.read_text(encoding="utf-8-sig")) + count = [0] + found: dict[int, str] = {} + + new_data = _walk_restore(data, reverse, count, found) + + if count[0] > 0: + stats["total_substitutions"] += count[0] + stats["files_modified"] += 1 + stats["variables_found"].update(found) + out = json.dumps(new_data, ensure_ascii=False, indent=4) + fp.write_text(out + "\n", encoding="utf-8") + except Exception as exc: + stats["errors"].append(f"{fp.name}: {exc}") + + return stats + + +def scan_variables( + files_dir: str | Path = "files", +) -> dict[int, int]: + """Scan *files_dir* and return {actor_id: occurrence_count} without modifying files.""" + counts: dict[int, int] = {} + for fp in Path(files_dir).glob("*.json"): + try: + text = fp.read_text(encoding="utf-8-sig") + # Search in raw JSON text (\\n[ in file = single \n[ after parse) + # The raw JSON file stores it as \\n[X] so we search that directly: + for m in re.finditer(r"\\\\n\[(\d+)\]", text): + aid = int(m.group(1)) + counts[aid] = counts.get(aid, 0) + 1 + except Exception: + pass + return counts + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _sub_string(s: str, actor_map: dict[int, str], count: list, found: dict) -> str: + def replace(m: re.Match) -> str: + aid = int(m.group(1)) + name = actor_map.get(aid) + if name: + count[0] += 1 + found[aid] = name + return name + return m.group(0) + return _VAR_RE.sub(replace, s) + + +def _restore_string(s: str, reverse: dict[str, tuple[str, int]], count: list, found: dict) -> str: + """Replace actor names with \\n[X] in a translated string.""" + # We need to handle the fact that names can be substrings of other words. + # Build a single regex alternation sorted by length (longest first) to + # prevent short names like "Al" matching inside "Alicia". + if not reverse: + return s + pattern = re.compile( + "|".join(re.escape(name) for name in sorted(reverse.keys(), key=len, reverse=True)), + re.IGNORECASE, + ) + + def replace(m: re.Match) -> str: + lower_matched = m.group(0).lower() + entry = reverse.get(lower_matched) + if entry: + var_str, aid = entry + count[0] += 1 + found[aid] = m.group(0) + return var_str + return m.group(0) + + return pattern.sub(replace, s) + + +def _walk(obj, actor_map, count, found, substitute: bool): + if isinstance(obj, str): + return _sub_string(obj, actor_map, count, found) + if isinstance(obj, list): + return [_walk(item, actor_map, count, found, substitute) for item in obj] + if isinstance(obj, dict): + return {k: _walk(v, actor_map, count, found, substitute) for k, v in obj.items()} + return obj + + +def _walk_restore(obj, reverse, count, found): + if isinstance(obj, str): + return _restore_string(obj, reverse, count, found) + if isinstance(obj, list): + return [_walk_restore(item, reverse, count, found) for item in obj] + if isinstance(obj, dict): + return {k: _walk_restore(v, reverse, count, found) for k, v in obj.items()} + return obj diff --git a/util/project_scanner.py b/util/project_scanner.py new file mode 100644 index 0000000..77e59a8 --- /dev/null +++ b/util/project_scanner.py @@ -0,0 +1,235 @@ +""" +Project Scanner - Detect game engine and import/export data files. + +RPGMaker layouts scanned (in priority order): + MV/MZ : /www/data/ or /data/ + Ace : /Data/ + XP/VX : /Data/ (rvdata/rxdata - not currently handled) +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Optional + +# --------------------------------------------------------------------------- +# File categories used to decide what to import by default +# --------------------------------------------------------------------------- + +# Core name/database files – almost always needed +CORE_FILES = { + "Actors.json", + "Armors.json", + "Classes.json", + "Enemies.json", + "Items.json", + "MapInfos.json", + "Skills.json", + "States.json", + "System.json", + "Troops.json", + "Weapons.json", + "CommonEvents.json", + "Animations.json", + "Tilesets.json", +} + +# Map files – large but contain the bulk of dialogue +MAP_PATTERN = "Map[0-9]*.json" + +# Engine detection markers +_MVMZ_MARKERS = { # files / dirs that hint at MV or MZ + "www", # MV web build + "package.json", # MZ desktop + "Game.rpgproject", # MV + "game.rmmzproject", # MZ +} +_ACE_MARKERS = { + "Game.rgss3a", + "Game.exe", # not conclusive but common +} +_ACE_DATA_SCRIPTS = {".rvdata2", ".rvdata"} + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def find_data_folder(game_root: str | Path) -> tuple[Optional[Path], str]: + """Return (data_path, engine_name) for *game_root*. + + engine_name is one of: "MVMZ", "ACE", "UNKNOWN". + data_path is None when nothing is found. + """ + root = Path(game_root) + if not root.is_dir(): + return None, "UNKNOWN" + + # ---- RPGMaker MV/MZ: www/data or data ---- + for candidate in (root / "www" / "data", root / "data", root / "Data"): + if candidate.is_dir() and _has_json_data(candidate): + # Confirm engine variant + engine = "MVMZ" + for m in _MVMZ_MARKERS: + if (root / m).exists(): + engine = "MVMZ" + break + return candidate, engine + + # ---- RPGMaker Ace: Data/ with .rvdata2 ---- + ace_data = root / "Data" + if ace_data.is_dir(): + rvdata = list(ace_data.glob("*.rvdata2")) + list(ace_data.glob("*.rvdata")) + if rvdata: + return ace_data, "ACE" + + # ---- Fallback: any sub-directory that holds .json files ---- + for child in root.iterdir(): + if child.is_dir() and _has_json_data(child): + return child, "UNKNOWN" + + return None, "UNKNOWN" + + +def list_data_files(data_path: str | Path, engine: str = "MVMZ") -> list[dict]: + """Return a sorted list of importable file descriptors. + + Each item: {"name": str, "path": Path, "size_kb": float, + "category": "core" | "map" | "other", "default": bool} + """ + data_path = Path(data_path) + results: list[dict] = [] + + if engine == "ACE": + # Ace uses binary rvdata2; not JSON-based, skip for now + return results + + seen: set[str] = set() + for fp in sorted(data_path.iterdir()): + if not fp.is_file(): + continue + if fp.suffix.lower() != ".json": + continue + name = fp.name + if name in seen: + continue + seen.add(name) + + size_kb = fp.stat().st_size / 1024 + if name in CORE_FILES: + cat = "core" + default = True + elif fp.match(MAP_PATTERN): + cat = "map" + default = True + else: + cat = "other" + default = False + + results.append({ + "name": name, + "path": fp, + "size_kb": round(size_kb, 1), + "category": cat, + "default": default, + }) + + # Sort: core first, then maps (numeric), then other + def _sort_key(item): + if item["category"] == "core": + return (0, item["name"]) + if item["category"] == "map": + # extract numeric part for natural order + digits = "".join(c for c in item["name"] if c.isdigit()) or "0" + return (1, int(digits)) + return (2, item["name"]) + + results.sort(key=_sort_key) + return results + + +def import_to_files( + file_items: list[dict], + dest_dir: str | Path = "files", + overwrite: bool = True, +) -> tuple[int, list[str]]: + """Copy *file_items* (from list_data_files) into *dest_dir*. + + Returns (count_copied, list_of_errors). + """ + dest = Path(dest_dir) + dest.mkdir(parents=True, exist_ok=True) + copied = 0 + errors: list[str] = [] + + for item in file_items: + src: Path = item["path"] + dst = dest / item["name"] + try: + if not overwrite and dst.exists(): + continue + shutil.copy2(src, dst) + copied += 1 + except Exception as exc: + errors.append(f"{item['name']}: {exc}") + + return copied, errors + + +def export_to_game( + translated_dir: str | Path, + game_data_path: str | Path, + filenames: Optional[list[str]] = None, + overwrite: bool = True, +) -> tuple[int, list[str]]: + """Copy translated files back into the game's data folder. + + If *filenames* is None, all .json files in *translated_dir* are copied. + Returns (count_copied, list_of_errors). + """ + src_dir = Path(translated_dir) + dst_dir = Path(game_data_path) + copied = 0 + errors: list[str] = [] + + if not src_dir.is_dir(): + return 0, [f"Translated folder not found: {src_dir}"] + if not dst_dir.is_dir(): + return 0, [f"Game data folder not found: {dst_dir}"] + + candidates = ( + [src_dir / fn for fn in filenames] + if filenames + else list(src_dir.glob("*.json")) + ) + + for fp in candidates: + if not fp.is_file(): + continue + dst = dst_dir / fp.name + try: + if not overwrite and dst.exists(): + continue + shutil.copy2(fp, dst) + copied += 1 + except Exception as exc: + errors.append(f"{fp.name}: {exc}") + + return copied, errors + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _has_json_data(folder: Path) -> bool: + """Return True if the folder contains at least one .json data file.""" + try: + for p in folder.iterdir(): + if p.suffix.lower() == ".json": + return True + except PermissionError: + pass + return False diff --git a/util/speaker_detector.py b/util/speaker_detector.py new file mode 100644 index 0000000..286f1ac --- /dev/null +++ b/util/speaker_detector.py @@ -0,0 +1,225 @@ +""" +Speaker Format Detector for RPGMaker MV/MZ + +Scans map / event files and scores three detection modes: + + INLINE401SPEAKERS – 401 lines contain Name「dialogue」 + FIRSTLINESPEAKERS – a short (< 40 char) 401 followed by 401/405 starting + with 「 " ( ( * [ + FACENAME101 – a 101 code (Show Text / face cmd) immediately precedes + a 401 block + +Returns the best mode and confidence scores for each. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +# Regex used to detect inline quote speaker: "Name「..." at start of 401 text +_INLINE_RE = re.compile(r"^([^\s「」。、!?…\\\n]{1,20})「") + +# Characters that signal the next 401 is dialogue (first-line speaker heuristic) +_DIALOGUE_STARTERS = ("「", '"', "(", "(", "*", "[") + +# Minimum number of qualifying events to consider a result reliable +_MIN_HITS = 3 + + +def detect_speaker_format( + files_dir: str | Path = "files", + sample_size: int = 20, +) -> dict: + """Scan up to *sample_size* map files and score speaker detection modes. + + Returns: + { + "best_mode": "INLINE401SPEAKERS" | "FIRSTLINESPEAKERS" | "FACENAME101" | "NONE", + "scores": { + "INLINE401SPEAKERS": int, # raw hit count + "FIRSTLINESPEAKERS": int, + "FACENAME101": int, + }, + "total_401_groups": int, # total dialogue groups examined + "files_scanned": int, + "recommended_config": { # ready to apply to rpgmakermvmz.py + "FIRSTLINESPEAKERS": bool, + "INLINE401SPEAKERS": bool, + "FACENAME101": bool, + }, + "confidence": "high" | "medium" | "low", + "note": str, # human-readable explanation + } + """ + files_dir = Path(files_dir) + scores = { + "INLINE401SPEAKERS": 0, + "FIRSTLINESPEAKERS": 0, + "FACENAME101": 0, + } + total_groups = 0 + files_scanned = 0 + + # Collect map files (Maps only, not MapInfos) + map_files = sorted( + [p for p in files_dir.glob("Map[0-9]*.json") if p.is_file()], + key=lambda p: p.stat().st_size, + reverse=True, # scan larger maps first for better signal + )[:sample_size] + + for fp in map_files: + try: + with open(fp, "r", encoding="utf-8-sig") as fh: + data = json.load(fh) + except Exception: + continue + + files_scanned += 1 + events = data.get("events") or [] + for evt in events: + if not evt: + continue + for page in evt.get("pages") or []: + cmd_list = (page or {}).get("list") or [] + _score_command_list(cmd_list, scores) + # Count 401 groups + i = 0 + while i < len(cmd_list): + if cmd_list[i].get("code") in (401, 405): + total_groups += 1 + while i < len(cmd_list) and cmd_list[i].get("code") in (401, 405, -1): + i += 1 + continue + i += 1 + + # Also scan CommonEvents + ce_path = files_dir / "CommonEvents.json" + if ce_path.is_file(): + try: + with open(ce_path, "r", encoding="utf-8-sig") as fh: + ce_data = json.load(fh) + files_scanned += 1 + for entry in ce_data or []: + if not entry: + continue + cmd_list = entry.get("list") or [] + _score_command_list(cmd_list, scores) + except Exception: + pass + + # Determine best mode + best = max(scores, key=lambda k: scores[k]) + best_score = scores[best] + second_best = sorted(scores.values(), reverse=True)[1] + + if best_score < _MIN_HITS: + best_mode = "NONE" + confidence = "low" + note = ( + f"No strong speaker pattern detected " + f"(scanned {files_scanned} file(s), {total_groups} dialogue group(s)). " + "Consider checking speaker settings manually." + ) + elif best_score > second_best * 2: + best_mode = best + confidence = "high" + note = ( + f"Strong signal for {best} " + f"({best_score} hits vs {second_best} for next best, " + f"{total_groups} dialogue group(s) in {files_scanned} file(s))." + ) + else: + best_mode = best + confidence = "medium" + note = ( + f"Moderate signal for {best} " + f"({best_score} hits, {second_best} for next-best mode). " + "Review a sample of dialogue manually to confirm." + ) + + recommended = { + "FIRSTLINESPEAKERS": best_mode == "FIRSTLINESPEAKERS", + "INLINE401SPEAKERS": best_mode == "INLINE401SPEAKERS", + "FACENAME101": best_mode == "FACENAME101", + } + + return { + "best_mode": best_mode, + "scores": scores, + "total_401_groups": total_groups, + "files_scanned": files_scanned, + "recommended_config": recommended, + "confidence": confidence, + "note": note, + } + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _score_command_list(cmd_list: list, scores: dict) -> None: + """Walk a command list and increment scores for detected patterns.""" + if not cmd_list: + return + + last_101_face: str | None = None # face name from most recent 101 cmd + + i = 0 + while i < len(cmd_list): + cmd = cmd_list[i] + code = cmd.get("code") + params = cmd.get("parameters") or [] + + # ---- 101: Show Text (face) ---- + if code == 101: + # params[0] = face name, params[1] = face index + last_101_face = (params[0] if params else None) or "" + i += 1 + continue + + # ---- 401 / 405: dialogue line ---- + if code in (401, 405): + text = (params[0] if params else "") or "" + + # Score INLINE401SPEAKERS + if _INLINE_RE.match(text): + scores["INLINE401SPEAKERS"] += 1 + + # Score FACENAME101 — if face was set right before this block + if last_101_face is not None: + scores["FACENAME101"] += 1 + last_101_face = None # only count once per block + + # Score FIRSTLINESPEAKERS — short line with no dialogue starters, + # followed by a 401/405 that starts with a dialogue starter + if ( + len(text) < 40 + and not any(text.lstrip().startswith(s) for s in _DIALOGUE_STARTERS) + and _has_japanese(text) + ): + j = i + 1 + while j < len(cmd_list) and cmd_list[j].get("code") == -1: + j += 1 + if j < len(cmd_list) and cmd_list[j].get("code") in (401, 405): + next_text = ((cmd_list[j].get("parameters") or [""])[0]) or "" + if next_text.lstrip().startswith(_DIALOGUE_STARTERS): + scores["FIRSTLINESPEAKERS"] += 1 + + i += 1 + continue + + # Any non-dialogue code resets the face context + if code not in (-1,): + last_101_face = None + + i += 1 + + +_JP_RE = re.compile(r"[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF\uFF61-\uFF9F]") + + +def _has_japanese(text: str) -> bool: + return bool(_JP_RE.search(text))