diff --git a/gui/translation_tab.py b/gui/translation_tab.py index 333063a..e53229b 100644 --- a/gui/translation_tab.py +++ b/gui/translation_tab.py @@ -10,8 +10,10 @@ import subprocess import threading import sys from pathlib import Path -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor, as_completed, Future import traceback +import signal +import multiprocessing from colorama import Fore from tqdm import tqdm from dotenv import load_dotenv @@ -20,7 +22,7 @@ from PyQt5.QtWidgets import ( QTextEdit, QMessageBox, QListWidget, QListWidgetItem, QSplitter, QFileDialog, QComboBox, QCheckBox, QProgressBar ) -from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, QMutex +from PyQt5.QtCore import Qt, QTimer, pyqtSignal, QThread, QMutex, QProcess from PyQt5.QtGui import QFont @@ -38,12 +40,48 @@ class TranslationWorker(QThread): self.estimate_only = estimate_only self.should_stop = False self.mutex = QMutex() # For thread safety + self.executor = None # Store reference to executor for proper shutdown + self.running_processes = [] # Track running processes for termination def stop(self): """Stop the translation process.""" self.mutex.lock() try: + if self.should_stop: + # Already stopping, don't log again + return + self.should_stop = True + self.emit_log("🛑 Stopping translation worker and canceling pending tasks...") + + # Shutdown the executor if it exists + if self.executor: + # For older Python versions compatibility, use shutdown(wait=False) + # and manually cancel futures + try: + # Try to use cancel_futures parameter (Python 3.9+) + self.executor.shutdown(wait=False, cancel_futures=True) + except TypeError: + # Fallback for older Python versions + self.executor.shutdown(wait=False) + + # Terminate any running processes + if self.running_processes: + self.emit_log("🛑 Terminating running translation processes...") + for process in self.running_processes: + try: + if process.poll() is None: # Process is still running + process.terminate() + # Give it a moment to terminate gracefully + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + # Force kill if it doesn't terminate + process.kill() + process.wait() + except Exception as e: + self.emit_log(f"⚠️ Warning: Could not terminate process: {e}") + self.running_processes.clear() finally: self.mutex.unlock() @@ -55,6 +93,194 @@ class TranslationWorker(QThread): """Thread-safe progress emission.""" self.progress_signal.emit(current, total, filename) + def run_module_in_process(self, filename, estimate_only): + """Run a module handler in a separate process for better control.""" + try: + # Create a separate Python process to run the module + # This allows us to terminate it if needed + script_content = f''' +import sys +import os +from pathlib import Path +import io +from contextlib import redirect_stdout, redirect_stderr + +# Set UTF-8 encoding for stdout to handle Unicode characters +sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace') +sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace') + +# Add project root to path +project_root = Path(r"{self.project_root}") +sys.path.insert(0, str(project_root)) + +try: + # Change to project directory + os.chdir(str(project_root)) + + # Import and run the handler + {self.get_import_statement()} + + # Capture all output in a string buffer to avoid encoding issues + output_buffer = io.StringIO() + error_buffer = io.StringIO() + + with redirect_stdout(output_buffer), redirect_stderr(error_buffer): + handler_result = {self.get_handler_call()}(r"{filename}", {estimate_only}) + + # Print only the result, avoiding any Unicode characters from tqdm + if handler_result: + # Clean the result of any problematic Unicode characters + clean_result = str(handler_result).encode('ascii', 'ignore').decode('ascii') + print(f"RESULT:{{clean_result}}") + else: + print("RESULT:Fail") + +except Exception as e: + import traceback + error_msg = str(e).encode('ascii', 'ignore').decode('ascii') + print(f"ERROR:{{error_msg}}") + sys.exit(1) +''' + + # Write script to temporary file + temp_script = self.project_root / "temp_translation_script.py" + with open(temp_script, 'w', encoding='utf-8') as f: + f.write(script_content) + + # Run the script in a separate process + env = os.environ.copy() + env['PYTHONIOENCODING'] = 'utf-8' # Force UTF-8 encoding + + process = subprocess.Popen( + [sys.executable, str(temp_script)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding='utf-8', + errors='replace', + cwd=str(self.project_root), + env=env + ) + + # Track the process for potential termination + self.running_processes.append(process) + + # Wait for completion + stdout, stderr = process.communicate() + + # Remove from tracking + if process in self.running_processes: + self.running_processes.remove(process) + + # Clean up temp file + if temp_script.exists(): + temp_script.unlink() + + # Check if process was terminated by stop signal + if self.should_stop: + return "Stopped" + + # Parse result + if process.returncode == 0: + for line in stdout.strip().split('\n'): + if line.startswith('RESULT:'): + result_text = line[7:] # Remove 'RESULT:' prefix + # Clean up any Unicode issues in the result + try: + return result_text + except UnicodeError: + return result_text.encode('ascii', 'ignore').decode('ascii') + return "Success" + else: + error_msg = stderr.strip() if stderr.strip() else "Unknown error" + # Handle potential Unicode errors in error messages + try: + clean_error = error_msg.encode('ascii', 'ignore').decode('ascii') + except: + clean_error = "Unicode encoding error in process output" + self.emit_log(f"❌ Process error: {clean_error}") + return "Fail" + + except Exception as e: + self.emit_log(f"❌ Failed to run module process: {str(e)}") + return "Fail" + + def get_import_statement(self): + """Get the import statement for the selected module.""" + module_name = self.module_info[0] + if "RPG Maker MV/MZ" in module_name: + return "from modules.rpgmakermvmz import handleMVMZ" + elif "RPG Maker Ace" in module_name: + return "from modules.rpgmakerace import handleACE" + elif "CSV" in module_name: + return "from modules.csv import handleCSV" + elif "Tyrano" in module_name: + return "from modules.tyrano import handleTyrano" + elif "Kirikiri" in module_name: + return "from modules.kirikiri import handleKirikiri" + elif "JSON" in module_name: + return "from modules.json import handleJSON" + elif "Lune" in module_name: + return "from modules.lune import handleLune" + elif "NScript" in module_name: + return "from modules.nscript import handleOnscripter" + elif "Wolf RPG 2" in module_name: + return "from modules.wolf2 import handleWOLF2" + elif "Wolf RPG" in module_name: + return "from modules.wolf import handleWOLF" + elif "Regex" in module_name: + return "from modules.regex import handleRegex" + elif "Text" in module_name: + return "from modules.text import handleText" + elif "RenPy" in module_name: + return "from modules.renpy import handleRenpy" + elif "Unity" in module_name: + return "from modules.unity import handleUnity" + elif "Images" in module_name: + return "from modules.images import handleImages" + elif "Plugin" in module_name: + return "from modules.rpgmakerplugin import handlePlugin" + else: + return "# Unknown module" + + def get_handler_call(self): + """Get the handler function call for the selected module.""" + module_name = self.module_info[0] + if "RPG Maker MV/MZ" in module_name: + return "handleMVMZ" + elif "RPG Maker Ace" in module_name: + return "handleACE" + elif "CSV" in module_name: + return "handleCSV" + elif "Tyrano" in module_name: + return "handleTyrano" + elif "Kirikiri" in module_name: + return "handleKirikiri" + elif "JSON" in module_name: + return "handleJSON" + elif "Lune" in module_name: + return "handleLune" + elif "NScript" in module_name: + return "handleOnscripter" + elif "Wolf RPG 2" in module_name: + return "handleWOLF2" + elif "Wolf RPG" in module_name: + return "handleWOLF" + elif "Regex" in module_name: + return "handleRegex" + elif "Text" in module_name: + return "handleText" + elif "RenPy" in module_name: + return "handleRenpy" + elif "Unity" in module_name: + return "handleUnity" + elif "Images" in module_name: + return "handleImages" + elif "Plugin" in module_name: + return "handlePlugin" + else: + return "lambda f, e: 'Unknown module'" + def run(self): """Run the translation process.""" try: @@ -113,38 +339,61 @@ class TranslationWorker(QThread): os.chdir(str(self.project_root)) try: - with ThreadPoolExecutor(max_workers=threads) as executor: - # Submit all tasks first - future_to_filename = { - executor.submit(self.module_info[2], filename, self.estimate_only): filename - for filename in matching_files - } - - completed_count = 0 - total_files = len(matching_files) - - for future in as_completed(future_to_filename): - if self.should_stop: - self.emit_log("🛑 Translation stopped by user") - break - - filename = future_to_filename[future] - completed_count += 1 + # Use a simpler approach with limited parallelism + # to have better control over stopping + max_workers = min(threads, 2) # Limit to 2 concurrent processes max + self.executor = ThreadPoolExecutor(max_workers=max_workers) + + # Submit tasks to run modules in separate processes + future_to_filename = { + self.executor.submit(self.run_module_in_process, filename, self.estimate_only): filename + for filename in matching_files + } + + completed_count = 0 + total_files = len(matching_files) + + for future in as_completed(future_to_filename): + if self.should_stop: + # Don't log here, the stop() method already logged + # Cancel remaining futures + for remaining_future in future_to_filename: + if not remaining_future.done(): + remaining_future.cancel() + break - # Emit progress signal (less frequent updates) - self.emit_progress(completed_count, total_files, filename) - - try: - result = future.result() + filename = future_to_filename[future] + completed_count += 1 + + # Emit progress signal (less frequent updates) + self.emit_progress(completed_count, total_files, filename) + + try: + result = future.result() + if result and result != "Fail" and result != "Stopped": total_cost = result - # Only log every file completion, not internal progress self.emit_log(f"✅ Completed {filename} ({completed_count}/{total_files})") - except Exception as e: - tb_line = str(traceback.extract_tb(sys.exc_info()[2])[-1].lineno) - error_msg = f"❌ Error processing {filename}: {str(e)} | Line: {tb_line}" - self.emit_log(error_msg) - + elif result == "Stopped": + # Don't log here, already handled + break + else: + self.emit_log(f"❌ Failed processing {filename}") + except Exception as e: + tb_line = str(traceback.extract_tb(sys.exc_info()[2])[-1].lineno) + error_msg = f"❌ Error processing {filename}: {str(e)} | Line: {tb_line}" + self.emit_log(error_msg) + finally: + # Properly shutdown the executor + if self.executor: + try: + # Try to use cancel_futures parameter (Python 3.9+) + self.executor.shutdown(wait=False, cancel_futures=True) + except TypeError: + # Fallback for older Python versions + self.executor.shutdown(wait=False) + self.executor = None + # Change back to original directory os.chdir(old_cwd) # Clean up temporary files @@ -152,6 +401,22 @@ class TranslationWorker(QThread): if tmp_file.exists(): tmp_file.unlink() + # Clean up any remaining temporary scripts + temp_script = self.project_root / "temp_translation_script.py" + if temp_script.exists(): + temp_script.unlink() + + # Ensure all processes are terminated + if self.running_processes: + for process in self.running_processes: + try: + if process.poll() is None: + process.terminate() + process.wait(timeout=1) + except: + pass + self.running_processes.clear() + # Report results if total_cost != "Fail" and not self.should_stop: self.emit_log("") @@ -165,6 +430,10 @@ class TranslationWorker(QThread): if not self.should_stop: self.emit_log("❌ Translation failed!") self.finished_signal.emit(False, "Translation failed") + else: + # Only log the final stop message here + self.emit_log("🛑 Translation stopped by user") + self.finished_signal.emit(False, "Translation stopped") except Exception as e: error_msg = f"❌ Unexpected error: {str(e)}" @@ -714,8 +983,12 @@ class TranslationTab(QWidget): """Stop the translation process.""" if hasattr(self, 'translation_worker') and self.translation_worker.isRunning(): self.translation_worker.stop() - self.translation_worker.wait(3000) # Wait up to 3 seconds - self.append_log("🛑 Translation process stopped by user.") + + # Wait for the worker to stop gracefully + if not self.translation_worker.wait(5000): # Wait up to 5 seconds + self.append_log("⚠️ Worker didn't stop gracefully, forcing termination...") + self.translation_worker.terminate() + self.translation_worker.wait(2000) # Wait for termination self.translate_button.setEnabled(True) self.stop_button.setEnabled(False) @@ -741,5 +1014,7 @@ class TranslationTab(QWidget): self.log_timer.stop() if hasattr(self, 'translation_worker') and self.translation_worker.isRunning(): self.translation_worker.stop() - self.translation_worker.wait(3000) + if not self.translation_worker.wait(3000): + self.translation_worker.terminate() + self.translation_worker.wait(1000) event.accept()