# Libraries import json import os import re import shutil import util.dazedwrap as dazedwrap import threading import time import traceback import openai from pathlib import Path from colorama import Fore from dotenv import load_dotenv from retry import retry from tqdm import tqdm from util.translation import TranslationConfig, translateAI as sharedtranslateAI, getPricingConfig, calculateCost # Open AI load_dotenv() if os.getenv("api").replace(" ", "") != "": openai.base_url = os.getenv("api") openai.organization = os.getenv("org") openai.api_key = os.getenv("key") # Globals MODEL = os.getenv("model") TIMEOUT = int(os.getenv("timeout")) LANGUAGE = os.getenv("language").capitalize() PROMPT = Path("prompt.txt").read_text(encoding="utf-8") VOCAB = Path("vocab.txt").read_text(encoding="utf-8") THREADS = int(os.getenv("threads")) LOCK = threading.Lock() VOCAB_LOCK = threading.Lock() # Dedicated lock for vocab.txt updates WIDTH = int(os.getenv("width")) LISTWIDTH = int(os.getenv("listWidth")) NOTEWIDTH = int(os.getenv("noteWidth")) MAXHISTORY = 10 ESTIMATE = "" TOKENS = [0, 0] MISMATCH = [] # Lists files that throw a mismatch error (Length of GPT list response is wrong) NAMESLIST = [] # List of speaker names and their translations PBAR = None FILENAME = None TIMETOTAL = 0 # Total Time Taken for all translations # Regex - Need to change this if you want to translate from/to other languages. Default is Japanese Regex LANGREGEX = r"[\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u31F0-\u31FF\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\uFF00-\uFF5D\uFF5F-\uFFEF]+" # Get pricing configuration based on the model PRICING_CONFIG = getPricingConfig(MODEL) INPUTAPICOST = PRICING_CONFIG["inputAPICost"] OUTPUTAPICOST = PRICING_CONFIG["outputAPICost"] BATCHSIZE = PRICING_CONFIG["batchSize"] FREQUENCY_PENALTY = PRICING_CONFIG["frequencyPenalty"] # tqdm Globals BAR_FORMAT = "{l_bar}{bar:10}{r_bar}{bar:-10b}" POSITION = 0 LEAVE = False # Initialize Translation Config TRANSLATION_CONFIG = TranslationConfig( model=MODEL, language=LANGUAGE, prompt=PROMPT, vocab=VOCAB, langRegex=LANGREGEX, batchSize=BATCHSIZE, maxHistory=MAXHISTORY, estimateMode=False # Will be set dynamically based on ESTIMATE ) # Config (Default) FIXTEXTWRAP = True # Rewrap text to WIDTH IGNORETLTEXT = False # Skip Translated Text # List of file patterns that use parseGeneric # Add more patterns here as needed GENERIC_FILES = [ "quests", "shops", "bonuses", "base", "battleprep", "manage", "mapcommands", "title", "placeevents", "talkevents", "characters", "glossary", "npc", "screens", "strings", "originalterrains", "runtimeterrains", "archers", "fighters", "items", "mages", # Add more file patterns here, e.g.: # "items", # "skills", # "classes", ] # List of file patterns that use parseMap MAP_FILES = [ "map", ] def update_vocab_section(category: str, pairs: list[tuple[str, str]]): """Update or insert a section in vocab.txt for the given category with provided pairs. Only writes when there's an actual translation (dst is non-empty and differs from src after normalization). - category: e.g., "Items", "Weapons", "Speakers", etc. Section header will be "# {category}". - pairs: list of (source, translated) strings. Duplicates by source are deduped (last wins). The existing section is replaced entirely; other sections are preserved. """ try: vocab_path = Path("vocab.txt") # Helper: normalized comparison to detect no-op translations def _norm(s: str) -> str: if s is None: return "" # Collapse whitespace and case-fold; leave punctuation to avoid over-matching return re.sub(r"\s+", " ", str(s)).strip().casefold() # Filter and deduplicate by source term (last mapping wins) dedup: dict[str, str] = {} for src, dst in pairs: if not src: continue # Skip when no destination or no actual change if dst is None or _norm(dst) == "" or _norm(dst) == _norm(src): continue dedup[src] = dst # If nothing to add after filtering, skip touching the file if not dedup: return # Guard the read-modify-write with a dedicated lock to avoid races with VOCAB_LOCK: existing = vocab_path.read_text(encoding="utf-8") if vocab_path.exists() else "" lines = [f"{src} ({dst})" for src, dst in dedup.items()] # Always terminate a section with a blank line to separate from next header new_block = f"# {category}\n" + "\n".join(lines) if not new_block.endswith("\n\n"): if not new_block.endswith("\n"): new_block += "\n" new_block += "\n" # Regex to find the specific section starting at the header for this category # and ending right before the next header (any number of '#') or EOF. # - Handles headers like '#Category', '# Category', '## Category', etc. # - Uses non-greedy matching for the body to avoid spanning multiple sections. pattern = re.compile( rf"^[\t ]*#+\s*{re.escape(category)}\s*$\r?\n.*?(?=^[\t ]*#|\Z)", re.MULTILINE | re.DOTALL, ) if pattern.search(existing): # Replace only the first matching section for this category. updated = pattern.sub(lambda m: new_block, existing, count=1) else: updated = existing if updated and not updated.endswith("\n\n"): # Ensure a blank line before appending new section if file not empty if not updated.endswith("\n"): updated += "\n" updated += "\n" updated += new_block # Avoid writing if nothing changed if updated == existing: return # Atomic write: write to unique temp and replace with retries on Windows tmp_path = vocab_path.with_suffix(vocab_path.suffix + f".{os.getpid()}.{threading.get_ident()}.tmp") tmp_path.write_text(updated, encoding="utf-8") attempts = 6 delay = 0.1 last_err = None for attempt in range(attempts): try: os.replace(tmp_path, vocab_path) last_err = None break except PermissionError as e: last_err = e # Try relaxing permissions then retry try: if vocab_path.exists(): os.chmod(vocab_path, 0o666) except Exception: pass time.sleep(delay) delay = min(1.0, delay * 2) except Exception as e: last_err = e break if last_err is not None: try: shutil.move(str(tmp_path), str(vocab_path)) except Exception: try: if tmp_path.exists(): tmp_path.unlink(missing_ok=True) except Exception: pass raise last_err except Exception: traceback.print_exc() def handleSRPG(filename, estimate): """ Main handler function for SRPG Studio files. Args: filename: Name of the file to translate estimate: Boolean indicating if this is an estimate run Returns: String with translation results or error message """ global ESTIMATE, TOKENS, FILENAME, TIMETOTAL ESTIMATE = estimate FILENAME = filename # Translate start = time.time() translatedData = openFiles(filename) # Write output file if not in estimate mode if not estimate: try: with open("translated/" + filename, "w", encoding="utf-8", newline="\n") as outFile: json.dump(translatedData[0], outFile, ensure_ascii=False, indent=4) except Exception: traceback.print_exc() return "Fail" # Print File end = time.time() tqdm.write(getResultString(translatedData, end - start, filename)) with LOCK: TOKENS[0] += translatedData[1][0] TOKENS[1] += translatedData[1][1] # Print Total totalString = getResultString(["", TOKENS, None], end - start, "TOTAL") # Print any errors if len(MISMATCH) > 0: return totalString + Fore.RED + f"\nMismatch Errors: {MISMATCH}" + Fore.RESET else: return totalString def openFiles(filename): """ Opens and routes SRPG Studio files to appropriate parsing functions. Args: filename: Name of the file to open and parse Returns: Tuple of (translated data, token counts, error) """ with open("files/" + filename, "r", encoding="utf-8-sig") as f: data = json.load(f) # Check if filename matches recollection pattern if "recollection" in filename.lower(): translatedData = parseRecollection(data, filename) # Check if filename matches map pattern elif any(pattern in filename.lower() for pattern in MAP_FILES): translatedData = parseMap(data, filename) # Check if filename matches any pattern in GENERIC_FILES elif any(pattern in filename.lower() for pattern in GENERIC_FILES): translatedData = parseGeneric(data, filename) # TODO: Add other SRPG Studio file types here else: raise NameError(filename + " Not Supported") return translatedData def parseGeneric(data, filename): """ Generic parser for SRPG Studio files with id, name, desc structure. Handles files like quests.json. Uses a two-pass approach: first pass collects all strings to translate, then batch translates them, then second pass applies translations. Args: data: Parsed JSON data (list of objects with id, name, desc) filename: Name of the file being parsed Returns: Tuple of (data, token counts, error) """ global PBAR totalTokens = [0, 0] pbar = None try: # Count work units (all translatable fields that need translation) total_units = 0 translatable_fields = ["name", "desc", "commandName", "command"] for entry in data: if entry: for field in translatable_fields: if field in entry and entry[field]: total_units += 1 # Handle pages array separately if "pages" in entry and entry["pages"] and isinstance(entry["pages"], list): for page in entry["pages"]: if page: total_units += 1 # Handle terrains array (nested structure) if "terrains" in entry and entry["terrains"] and isinstance(entry["terrains"], list): for terrain in entry["terrains"]: if terrain: for field in ["name", "desc"]: if field in terrain and terrain[field]: total_units += 1 # Setup progress bar (use a per-file instance to avoid cross-thread clashes) with LOCK: pbar = tqdm( desc=filename, total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, ) # Translate the data using two-pass approach result = translateGeneric(data, filename, pbar=pbar) totalTokens[0] += result[0] totalTokens[1] += result[1] return (data, totalTokens, None) except Exception as e: traceback.print_exc() return (data, totalTokens, e) finally: # Ensure progress bar is closed try: if pbar is not None: pbar.close() except Exception: pass def translateGeneric(data, filename, translatedDataList=None, pbar=None): """ Translates generic SRPG Studio data with id, name, desc, commandName, command, pages structure. Uses two-pass approach via recursion: - Pass 1 (translatedDataList=None): Collect strings and batch translate - Pass 2 (translatedDataList set): Apply translations back to the data Args: data: List of objects with id, name, desc, commandName, command, pages keys filename: Name of the file being translated translatedDataList: List containing [nameList, descList, commandNameList, commandList, pagesList] - Pass 1: Empty lists to collect originals - Pass 2: Filled lists with translations Returns: Tuple of [input tokens, output tokens] """ global PBAR totalTokens = [0, 0] # Initialize or extract lists if translatedDataList is None: # PASS 1: Create empty lists to collect strings nameList = [] descList = [] commandNameList = [] commandList = [] pagesList = [] else: # PASS 2: Use provided translated lists nameList = translatedDataList[0] descList = translatedDataList[1] commandNameList = translatedDataList[2] commandList = translatedDataList[3] pagesList = translatedDataList[4] # Single loop - behavior depends on which pass we're in for entry in data: if not entry: continue # Handle name field if "name" in entry and entry["name"]: # PASS 1: Collect original if translatedDataList is None: nameList.append(entry["name"]) # PASS 2: Apply translation else: if nameList: entry["name"] = nameList[0] nameList.pop(0) # Handle desc field if "desc" in entry and entry["desc"]: # PASS 1: Collect original if translatedDataList is None: descList.append(entry["desc"]) # PASS 2: Apply translation else: if descList: entry["desc"] = descList[0] descList.pop(0) # Handle commandName field if "commandName" in entry and entry["commandName"]: # PASS 1: Collect original if translatedDataList is None: commandNameList.append(entry["commandName"]) # PASS 2: Apply translation else: if commandNameList: entry["commandName"] = commandNameList[0] commandNameList.pop(0) # Handle command field if "command" in entry and entry["command"]: # PASS 1: Collect original if translatedDataList is None: commandList.append(entry["command"]) # PASS 2: Apply translation else: if commandList: entry["command"] = commandList[0] commandList.pop(0) # Handle pages field (array of strings) if "pages" in entry and entry["pages"] and isinstance(entry["pages"], list): for i, page in enumerate(entry["pages"]): if page: # PASS 1: Collect original if translatedDataList is None: # Nuke Wordwrap page = page.replace("\n", " ") pagesList.append(page) # PASS 2: Apply translation else: if pagesList: translatedText = pagesList[0] # Wordwrap translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) # Set Data entry["pages"][i] = translatedText pagesList.pop(0) # Handle terrains field (nested array with name and desc) if "terrains" in entry and entry["terrains"] and isinstance(entry["terrains"], list): for terrain in entry["terrains"]: if not terrain: continue # Handle terrain name if "name" in terrain and terrain["name"]: # PASS 1: Collect original if translatedDataList is None: nameList.append(terrain["name"]) # PASS 2: Apply translation else: if nameList: terrain["name"] = nameList[0] nameList.pop(0) # Handle terrain desc if "desc" in terrain and terrain["desc"]: # PASS 1: Collect original if translatedDataList is None: descList.append(terrain["desc"]) # PASS 2: Apply translation else: if descList: terrain["desc"] = descList[0] descList.pop(0) # If this was Pass 1, do the translation and recurse for Pass 2 if translatedDataList is None: # Store original counts for mismatch checking originalNameCount = len(nameList) originalDescCount = len(descList) originalCommandNameCount = len(commandNameList) originalCommandCount = len(commandList) originalPagesCount = len(pagesList) # Keep a copy of original names for vocab update (for characters.json) originalNameList = nameList.copy() if "characters" in filename.lower() else [] # Batch translate names if nameList: response = translateAI( nameList, "Reply with only the " + LANGUAGE + " translation of the quest name.", True, filename, pbar ) nameList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Update vocab.txt for characters.json if "characters" in filename.lower() and originalNameList and nameList: try: # Create pairs of (original, translated) for vocab vocab_pairs = list(zip(originalNameList, nameList)) update_vocab_section("Speakers", vocab_pairs) except Exception: traceback.print_exc() # Batch translate descriptions if descList: response = translateAI( descList, "Reply with only the " + LANGUAGE + " translation of the quest description.", True, filename, pbar ) descList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate command names if commandNameList: response = translateAI( commandNameList, "Reply with only the " + LANGUAGE + " translation of the command name.", True, filename, pbar ) commandNameList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate commands if commandList: response = translateAI( commandList, "Reply with only the " + LANGUAGE + " translation of the command.", True, filename, pbar ) commandList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate pages if pagesList: response = translateAI( pagesList, "Reply with only the " + LANGUAGE + " translation of the page content.", True, filename, pbar ) pagesList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Check for mismatch errors if len(nameList) != originalNameCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(descList) != originalDescCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(commandNameList) != originalCommandNameCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(commandList) != originalCommandCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(pagesList) != originalPagesCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) # PASS 2: Recursively call to apply translations translateGeneric(data, filename, [nameList, descList, commandNameList, commandList, pagesList], pbar) return totalTokens def parseRecollection(data, filename): """ Parser for SRPG Studio recollection.json files. Structure: List of entries, each with pages containing commands with data arrays and speakers. Args: data: Parsed JSON data (list of objects with id, pages structure) filename: Name of the file being parsed Returns: Tuple of (data, token counts, error) """ global PBAR totalTokens = [0, 0] pbar = None try: # Count work units (data entries and speakers that need translation) total_units = 0 # Iterate through structure and count for entry in data: if not entry or "pages" not in entry or not isinstance(entry["pages"], list): continue for page in entry["pages"]: if not page or "commands" not in page or not isinstance(page["commands"], list): continue for command in page["commands"]: if not command: continue # Count data array items if "data" in command and isinstance(command["data"], list): for text in command["data"]: if text and re.search(LANGREGEX, text): total_units += 1 # Count speaker field if "speaker" in command and command["speaker"]: if re.search(LANGREGEX, command["speaker"]): total_units += 1 # Setup progress bar (per-file instance) with LOCK: pbar = tqdm( desc=filename, total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, ) # Translate the data using two-pass approach result = translateRecollection(data, filename, pbar=pbar) totalTokens[0] += result[0] totalTokens[1] += result[1] return (data, totalTokens, None) except Exception as e: traceback.print_exc() return (data, totalTokens, e) finally: try: if pbar is not None: pbar.close() except Exception: pass def translateRecollection(data, filename, translatedDataList=None, pbar=None): """ Translates recollection.json data structure. Uses two-pass approach via recursion: - Pass 1 (translatedDataList=None): Collect strings and batch translate - Pass 2 (translatedDataList set): Apply translations back to the data Args: data: List of objects with pages->commands->data structure filename: Name of the file being translated translatedDataList: List containing [dataList, speakerList] - Pass 1: Empty lists to collect originals - Pass 2: Filled lists with translations Returns: Tuple of [input tokens, output tokens] """ global PBAR totalTokens = [0, 0] # Initialize or extract lists if translatedDataList is None: # PASS 1: Create empty lists to collect strings dataList = [] speakerList = [] originalSpeakerList = [] # For vocab update else: # PASS 2: Use provided translated lists dataList = translatedDataList[0] speakerList = translatedDataList[1] # Single loop - behavior depends on which pass we're in for entry in data: if not entry or "pages" not in entry: continue if not isinstance(entry["pages"], list): continue for page in entry["pages"]: if not page or "commands" not in page: continue if not isinstance(page["commands"], list): continue for command in page["commands"]: if not command: continue # Get the speaker for this command (if available) speaker = command.get("speaker", "") # Handle data array if "data" in command and isinstance(command["data"], list): for i, text in enumerate(command["data"]): if text: # PASS 1: Collect original with speaker prefix if translatedDataList is None: # Remove Wrap text = text.replace("\n", " ") # Attach speaker to data for translation if speaker: dataList.append(f"[{speaker}]: {text}") else: dataList.append(text) # PASS 2: Apply translation and strip speaker prefix else: if dataList: translated = dataList[0] # Remove speaker if speaker: match = re.search(r'(^\[.+?\]\s?[|:]\s?)', translated) if match: translated = translated.replace(match.group(1), "") # Textwrap translated = dazedwrap.wrapText(translated, width=WIDTH) # Set Data command["data"][i] = translated dataList.pop(0) # Handle speaker field if "speaker" in command and command["speaker"]: # PASS 1: Collect original if translatedDataList is None: originalSpeakerList.append(command["speaker"]) speakerList.append(command["speaker"]) # PASS 2: Apply translation else: if speakerList: command["speaker"] = speakerList[0] speakerList.pop(0) # If this was Pass 1, do the translation and recurse for Pass 2 if translatedDataList is None: # Store original counts for mismatch checking originalDataCount = len(dataList) originalSpeakerCount = len(speakerList) # Batch translate data text if dataList: response = translateAI( dataList, "Reply with only the " + LANGUAGE + " translation of the dialogue text.", True, filename, pbar ) dataList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate speakers if speakerList: response = translateAI( speakerList, "Reply with only the " + LANGUAGE + " translation of the speaker name.", True, filename, pbar ) speakerList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Check for mismatch errors if len(dataList) != originalDataCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(speakerList) != originalSpeakerCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) # PASS 2: Recursively call to apply translations translateRecollection(data, filename, [dataList, speakerList], pbar) return totalTokens def parseMap(data, filename): """ Parser for SRPG Studio map.json files. Structure: Single object with: - id, name, desc, mapName - victoryConds (array of strings) - defeatConds (array of strings) - EnemyUnits (array of objects with id, name, desc, events structure similar to recollection) Args: data: Parsed JSON data (single map object) filename: Name of the file being parsed Returns: Tuple of (data, token counts, error) """ global PBAR totalTokens = [0, 0] pbar = None try: # Count work units (all translatable fields) total_units = 0 # Count top-level fields: desc, mapName (name is an identifier and should not be translated) for field in ["desc", "mapName"]: if field in data and data[field]: total_units += 1 # Count victoryConds array items if "victoryConds" in data and isinstance(data["victoryConds"], list): for cond in data["victoryConds"]: if cond: total_units += 1 # Count defeatConds array items if "defeatConds" in data and isinstance(data["defeatConds"], list): for cond in data["defeatConds"]: if cond: total_units += 1 # Count units with events (EnemyUnits, EvEnemyUnits) for unitsKey in ["EnemyUnits", "EvEnemyUnits"]: if unitsKey in data and isinstance(data[unitsKey], list): for unit in data[unitsKey]: if not unit: continue # Count unit name and desc for field in ["name", "desc"]: if field in unit and unit[field]: total_units += 1 # Count events->pages->commands->data and speaker if "events" in unit and isinstance(unit["events"], list): for event in unit["events"]: if not event or "pages" not in event: continue if not isinstance(event["pages"], list): continue for page in event["pages"]: if not page or "commands" not in page: continue if not isinstance(page["commands"], list): continue for command in page["commands"]: if not command: continue # Count data array items if "data" in command and isinstance(command["data"], list): for text in command["data"]: if text: total_units += 1 # Count speaker field if "speaker" in command and command["speaker"]: total_units += 1 # Count events (placeEvents, autoEvents, openingEvents, communicationEvents) for eventsKey in ["placeEvents", "autoEvents", "openingEvents", "communicationEvents"]: if eventsKey in data and isinstance(data[eventsKey], list): for event in data[eventsKey]: if not event or "pages" not in event: continue if not isinstance(event["pages"], list): continue for page in event["pages"]: if not page or "commands" not in page: continue if not isinstance(page["commands"], list): continue for command in page["commands"]: if not command: continue # Count data array items if "data" in command and isinstance(command["data"], list): for text in command["data"]: if text: total_units += 1 # Count speaker field if "speaker" in command and command["speaker"]: total_units += 1 # Setup progress bar (per-file instance) with LOCK: pbar = tqdm( desc=filename, total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, ) # Translate the data using two-pass approach result = translateMap(data, filename, pbar=pbar) totalTokens[0] += result[0] totalTokens[1] += result[1] return (data, totalTokens, None) except Exception as e: traceback.print_exc() return (data, totalTokens, e) finally: try: if pbar is not None: pbar.close() except Exception: pass def translateMap(data, filename, translatedDataList=None, pbar=None): """ Translates map.json data structure. Uses two-pass approach via recursion: - Pass 1 (translatedDataList=None): Collect strings and batch translate - Pass 2 (translatedDataList set): Apply translations back to the data Args: data: Single map object with name, desc, mapName, victoryConds, defeatConds, and EnemyUnits filename: Name of the file being translated translatedDataList: List containing translation lists - Pass 1: Empty lists to collect originals - Pass 2: Filled lists with translations Returns: Tuple of [input tokens, output tokens] """ global PBAR totalTokens = [0, 0] # Initialize or extract lists if translatedDataList is None: # PASS 1: Create empty lists to collect strings descList = [] mapNameList = [] victoryCondsList = [] defeatCondsList = [] unitNameList = [] unitDescList = [] dataList = [] speakerList = [] originalSpeakerList = [] # For vocab update else: # PASS 2: Use provided translated lists descList = translatedDataList[0] mapNameList = translatedDataList[1] victoryCondsList = translatedDataList[2] defeatCondsList = translatedDataList[3] unitNameList = translatedDataList[4] unitDescList = translatedDataList[5] dataList = translatedDataList[6] speakerList = translatedDataList[7] # Note: name field is not translated as it's an identifier (e.g., "ch3_瘴気の森") # Handle desc field if "desc" in data and data["desc"]: # PASS 1: Collect original if translatedDataList is None: descList.append(data["desc"]) # PASS 2: Apply translation else: if descList: data["desc"] = descList[0] descList.pop(0) # Handle mapName field if "mapName" in data and data["mapName"]: # PASS 1: Collect original if translatedDataList is None: mapNameList.append(data["mapName"]) # PASS 2: Apply translation else: if mapNameList: data["mapName"] = mapNameList[0] mapNameList.pop(0) # Handle victoryConds array if "victoryConds" in data and isinstance(data["victoryConds"], list): for i, cond in enumerate(data["victoryConds"]): if cond: # PASS 1: Collect original if translatedDataList is None: victoryCondsList.append(cond) # PASS 2: Apply translation else: if victoryCondsList: data["victoryConds"][i] = victoryCondsList[0] victoryCondsList.pop(0) # Handle defeatConds array if "defeatConds" in data and isinstance(data["defeatConds"], list): for i, cond in enumerate(data["defeatConds"]): if cond: # PASS 1: Collect original if translatedDataList is None: defeatCondsList.append(cond) # PASS 2: Apply translation else: if defeatCondsList: data["defeatConds"][i] = defeatCondsList[0] defeatCondsList.pop(0) # Process all unit arrays (EnemyUnits, EvEnemyUnits) for unitsKey in ["EnemyUnits", "EvEnemyUnits"]: if unitsKey in data and isinstance(data[unitsKey], list): for unit in data[unitsKey]: if not unit: continue # Handle unit name if "name" in unit and unit["name"]: if translatedDataList is None: unitNameList.append(unit["name"]) else: if unitNameList: unit["name"] = unitNameList[0] unitNameList.pop(0) # Handle unit desc if "desc" in unit and unit["desc"]: if translatedDataList is None: unitDescList.append(unit["desc"]) else: if unitDescList: unit["desc"] = unitDescList[0] unitDescList.pop(0) # Process events within units if "events" in unit and isinstance(unit["events"], list): for event in unit["events"]: if not event or "pages" not in event: continue if not isinstance(event["pages"], list): continue for page in event["pages"]: if not page or "commands" not in page: continue if not isinstance(page["commands"], list): continue for command in page["commands"]: if not command: continue speaker = command.get("speaker", "") # Handle data array if "data" in command and isinstance(command["data"], list): for i, text in enumerate(command["data"]): if text: if translatedDataList is None: text = text.replace("\n", " ") if speaker: dataList.append(f"[{speaker}]: {text}") else: dataList.append(text) else: if dataList: translated = dataList[0] if speaker: match = re.search(r'(^\[.+?\]\s?[|:]\s?)', translated) if match: translated = translated.replace(match.group(1), "") translated = dazedwrap.wrapText(translated, width=WIDTH) command["data"][i] = translated dataList.pop(0) # Handle speaker field if "speaker" in command and command["speaker"]: if translatedDataList is None: originalSpeakerList.append(command["speaker"]) speakerList.append(command["speaker"]) else: if speakerList: command["speaker"] = speakerList[0] speakerList.pop(0) # Process all event arrays (placeEvents, autoEvents, openingEvents, communicationEvents) for eventsKey in ["placeEvents", "autoEvents", "openingEvents", "communicationEvents"]: if eventsKey in data and isinstance(data[eventsKey], list): for event in data[eventsKey]: if not event or "pages" not in event: continue if not isinstance(event["pages"], list): continue for page in event["pages"]: if not page or "commands" not in page: continue if not isinstance(page["commands"], list): continue for command in page["commands"]: if not command: continue speaker = command.get("speaker", "") # Handle data array if "data" in command and isinstance(command["data"], list): for i, text in enumerate(command["data"]): if text: if translatedDataList is None: text = text.replace("\n", " ") if speaker: dataList.append(f"[{speaker}]: {text}") else: dataList.append(text) else: if dataList: translated = dataList[0] if speaker: match = re.search(r'(^\[.+?\]\s?[|:]\s?)', translated) if match: translated = translated.replace(match.group(1), "") translated = dazedwrap.wrapText(translated, width=WIDTH) command["data"][i] = translated dataList.pop(0) # Handle speaker field if "speaker" in command and command["speaker"]: if translatedDataList is None: originalSpeakerList.append(command["speaker"]) speakerList.append(command["speaker"]) else: if speakerList: command["speaker"] = speakerList[0] speakerList.pop(0) # If this was Pass 1, do the translation and recurse for Pass 2 if translatedDataList is None: # Store original counts for mismatch checking originalDescCount = len(descList) originalMapNameCount = len(mapNameList) originalVictoryCondsCount = len(victoryCondsList) originalDefeatCondsCount = len(defeatCondsList) originalUnitNameCount = len(unitNameList) originalUnitDescCount = len(unitDescList) originalDataCount = len(dataList) originalSpeakerCount = len(speakerList) # Batch translate map descriptions if descList: response = translateAI( descList, "Reply with only the " + LANGUAGE + " translation of the map description.", True, filename, pbar ) descList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate map display names if mapNameList: response = translateAI( mapNameList, "Reply with only the " + LANGUAGE + " translation of the map display name.", True, filename, pbar ) mapNameList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate victory conditions if victoryCondsList: response = translateAI( victoryCondsList, "Reply with only the " + LANGUAGE + " translation of the victory condition.", True, filename, pbar ) victoryCondsList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate defeat conditions if defeatCondsList: response = translateAI( defeatCondsList, "Reply with only the " + LANGUAGE + " translation of the defeat condition.", True, filename, pbar ) defeatCondsList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate unit names if unitNameList: response = translateAI( unitNameList, "Reply with only the " + LANGUAGE + " translation of the enemy unit name.", True, filename, pbar ) unitNameList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate unit descriptions if unitDescList: response = translateAI( unitDescList, "Reply with only the " + LANGUAGE + " translation of the enemy unit description.", True, filename, pbar ) unitDescList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate dialogue data if dataList: response = translateAI( dataList, "Reply with only the " + LANGUAGE + " translation of the dialogue text.", True, filename, pbar ) dataList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate speakers if speakerList: response = translateAI( speakerList, "Reply with only the " + LANGUAGE + " translation of the speaker name.", True, filename, pbar ) speakerList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Check for mismatch errors if len(descList) != originalDescCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(mapNameList) != originalMapNameCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(victoryCondsList) != originalVictoryCondsCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(defeatCondsList) != originalDefeatCondsCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(unitNameList) != originalUnitNameCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(unitDescList) != originalUnitDescCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(dataList) != originalDataCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(speakerList) != originalSpeakerCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) # PASS 2: Recursively call to apply translations translateMap(data, filename, [descList, mapNameList, victoryCondsList, defeatCondsList, unitNameList, unitDescList, dataList, speakerList], pbar) return totalTokens def getResultString(translatedData, translationTime, filename): """ Formats the translation result string with token counts, cost, and time. Args: translatedData: Tuple of (data, tokens, error) translationTime: Time taken for translation filename: Name of the file Returns: Formatted result string """ global TIMETOTAL # Calculate cost cost = calculateCost(translatedData[1][0], translatedData[1][1], MODEL) totalTokenstring = ( Fore.YELLOW + "[Input: " + str(translatedData[1][0]) + "]" "[Output: " + str(translatedData[1][1]) + "]" "[Cost: ${:,.4f}".format(cost) + "]" ) # Format time string if filename != "TOTAL": timeString = Fore.BLUE + "[" + str(round(translationTime, 1)) + "s]" TIMETOTAL += round(translationTime, 1) else: timeString = Fore.BLUE + "[" + str(round(TIMETOTAL, 1)) + "s]" # Return success or failure string if translatedData[2] is None: # Success return filename + ": " + totalTokenstring + timeString + Fore.GREEN + " \u2713 " + Fore.RESET else: # Fail try: raise translatedData[2] except Exception as e: traceback.print_exc() return ( filename + ": " + totalTokenstring + timeString + Fore.RED + " \u2717 " + Fore.RESET ) def getSpeaker(speaker): """ Translates speaker/character names with caching to avoid redundant translations. Args: speaker: The original speaker name to translate Returns: List containing [translated name, [input tokens, output tokens]] """ if speaker == "": return ["", [0, 0]] # Check if speaker has already been translated for i in range(len(NAMESLIST)): if speaker == NAMESLIST[i][0]: return [NAMESLIST[i][1], [0, 0]] # Translate and Store Speaker response = translateAI( speaker, "Reply with the " + LANGUAGE + " translation of the NPC name.", False, ) response[0] = response[0].title() response[0] = response[0].replace("'S", "'s") response[0] = response[0].replace("Speaker: ", "") # Retry if name doesn't translate for some reason if re.search(r"([a-zA-Z??])", response[0]) is None: response = translateAI( speaker, "Reply with the " + LANGUAGE + " translation of the NPC name.", False, ) response[0] = response[0].title() response[0] = response[0].replace("'S", "'s") speakerList = [speaker, response[0]] NAMESLIST.append(speakerList) return response def translateAI(text, history, fullPromptFlag, filename_param=None, pbar_param=None): """ Legacy wrapper function for the new shared translation utility. This maintains compatibility with existing code while using the new shared implementation. Args: text: Text to translate (can be string or list) history: History/context for the translation fullPromptFlag: Whether to use the full prompt with vocab Returns: List containing [translated text, [input tokens, output tokens]] """ global PBAR, MISMATCH, FILENAME # Update config estimate mode based on global ESTIMATE TRANSLATION_CONFIG.estimateMode = bool(ESTIMATE) # Call the new shared translation function return sharedtranslateAI( text=text, history=history, fullPromptFlag=fullPromptFlag, config=TRANSLATION_CONFIG, filename=(filename_param if filename_param is not None else FILENAME), pbar=(pbar_param if pbar_param is not None else PBAR), lock=LOCK, mismatchList=MISMATCH )