# Libraries import json import os import re import shutil import util.dazedwrap as dazedwrap import threading import time import traceback from dotenv import load_dotenv 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 # 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") 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\uFF61-\uFF9F]+" # 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", "shoplayout", "bonuses", "base", "battleprep", "manage", "mapcommands", "title", "classes", "classesgroups", "classtypes", "races", "skills", "weapons", "states", "difficulties", "fonts", "fusionsettings", "transformations", "characters", "glossary", "npc", "screens", "strings", "originalterrains", "runtimeterrains", "archers", "fighters", "mages", "items", ] # List of file patterns that use parseMap # Be specific to avoid catching non-map files like CommandLayout/mapcommands.json # SRPG Studio map files in this project follow the pattern Maps/map_XXX.json MAP_FILES = [ "map_", # e.g., Maps/map_000.json ] 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) # mapcommonevents is a list of events like recollection elif "mapcommonevents" in filename.lower(): translatedData = parseRecollection(data, filename) # Bookmark events use the same structure as recollection (list of events with pages/commands) elif "bookmarkevents" in filename.lower(): translatedData = parseRecollection(data, filename) # Event files (autoevents, placeevents, talkevents, communicationevents, openingevents, endingevents) # all have name, desc, and pages->commands structure like recollection elif any(event_type in filename.lower() for event_type in ["autoevents", "placeevents", "talkevents", "communicationevents", "openingevents", "endingevents"]): translatedData = parseRecollection(data, filename) # Check if filename matches bookmark pattern (top-level entries with name/desc + events) elif "bookmark" in filename.lower(): translatedData = parseBookmark(data, filename) # Players have the same shape as bookmark entries elif "players" in filename.lower(): translatedData = parseBookmark(data, filename) # Titles.json is a small dict of title strings elif os.path.basename(filename).lower() == "titles.json": translatedData = parseTitles(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 parseBookmark(data, filename): """ Parser for SRPG Studio bookmark.json files. Structure: List of entries, each with id, name, desc, and events containing pages -> commands where commands have data (array of strings) and optional speaker. Args: data: Parsed JSON data (list of bookmark entries) filename: Name of the file being parsed Returns: Tuple of (data, token counts, error) """ totalTokens = [0, 0] pbar = None try: # Count work units: names, descs, dialogue lines, and speakers total_units = 0 for entry in data: if not entry: continue # name and desc for field in ["name", "desc"]: if field in entry and entry[field]: total_units += 1 # events -> pages -> commands if "events" in entry and isinstance(entry["events"], list): for event in entry["events"]: if not event: continue # Count event-level name and desc fields for field in ["name", "desc"]: if field in event and event[field]: total_units += 1 if "pages" not in event or not isinstance(event["pages"], list): continue for page in event["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 if "data" in command and isinstance(command["data"], list): for text in command["data"]: if text: total_units += 1 if "speaker" in command and command["speaker"]: total_units += 1 # Setup progress bar with LOCK: pbar = tqdm( desc=filename, total=total_units, bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, ) # Translate using two-pass approach result = translateBookmark(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 translateBookmark(data, filename, translatedDataList=None, pbar=None): """ Translates bookmark.json data structure. Two-pass approach via recursion: - Pass 1: Collect strings (names, descs, dialogue data with speaker prefix, speakers) - Pass 2: Apply translations back into data Returns: [input tokens, output tokens] """ totalTokens = [0, 0] # Initialize or extract lists if translatedDataList is None: nameList = [] descList = [] dataList = [] speakerList = [] else: nameList = translatedDataList[0] descList = translatedDataList[1] dataList = translatedDataList[2] speakerList = translatedDataList[3] for entry in data: if not entry: continue # name if "name" in entry and entry["name"]: if translatedDataList is None: nameList.append(entry["name"]) else: if nameList: entry["name"] = nameList[0] nameList.pop(0) # desc if "desc" in entry and entry["desc"]: if translatedDataList is None: # Remove newlines for translation descList.append(entry["desc"].replace("\n", " ")) else: if descList: translatedText = descList[0] # Apply text wrapping translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) entry["desc"] = translatedText descList.pop(0) # events -> pages -> commands if "events" in entry and isinstance(entry["events"], list): for event in entry["events"]: if not event: continue # Handle event-level name field if "name" in event and event["name"]: if translatedDataList is None: nameList.append(event["name"]) else: if nameList: event["name"] = nameList[0] nameList.pop(0) # Handle event-level desc field if "desc" in event and event["desc"]: if translatedDataList is None: # Remove newlines for translation descList.append(event["desc"].replace("\n", " ")) else: if descList: translatedText = descList[0] # Apply text wrapping translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) event["desc"] = translatedText descList.pop(0) if "pages" not in event or not isinstance(event["pages"], list): continue for page in event["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 speaker = command.get("speaker", "") # 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) # speaker field if "speaker" in command and command["speaker"]: if translatedDataList is None: speakerList.append(command["speaker"]) else: if speakerList: command["speaker"] = speakerList[0] speakerList.pop(0) # If this was Pass 1, perform translations and recurse if translatedDataList is None: originalNameCount = len(nameList) originalDescCount = len(descList) originalDataCount = len(dataList) originalSpeakerCount = len(speakerList) if nameList: response = translateAI( nameList, "Reply with only the " + LANGUAGE + " translation of the bookmark name.", True, filename, pbar, ) nameList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] if descList: response = translateAI( descList, "Reply with only the " + LANGUAGE + " translation of the bookmark description.", True, filename, pbar, ) descList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] 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] 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] # Mismatch checks 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(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 translateBookmark(data, filename, [nameList, descList, dataList, speakerList], pbar) return totalTokens 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]: # If command is a list (e.g., fusionsettings), count per element if field == "command" and isinstance(entry[field], list): total_units += sum(1 for x in entry[field] if x) # Otherwise simple increment elif not isinstance(entry[field], list): 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 msg arrays (e.g., shoplayout) if "msg" in entry and isinstance(entry["msg"], list): total_units += sum(1 for m in entry["msg"] if m) # Handle rewardData arrays (e.g., quests) if "rewardData" in entry and isinstance(entry["rewardData"], list): total_units += sum(1 for r in entry["rewardData"] if r) # Handle customParameters name field (e.g., {name:'シャルロット強制売春'}) if "customParameters" in entry and entry["customParameters"]: match = re.search(r"name:\s*['\"]([^'\"]+)['\"]", entry["customParameters"]) if match: 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 = [] msgList = [] commandArrayList = [] rewardDataList = [] customParametersNameList = [] else: # PASS 2: Use provided translated lists nameList = translatedDataList[0] descList = translatedDataList[1] commandNameList = translatedDataList[2] commandList = translatedDataList[3] pagesList = translatedDataList[4] msgList = translatedDataList[5] commandArrayList = translatedDataList[6] rewardDataList = translatedDataList[7] customParametersNameList = translatedDataList[8] # 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: # Nuke Wordwrap descList.append(entry["desc"].replace("\n", " ")) # PASS 2: Apply translation else: if descList: translatedText = descList[0] # Wordwrap translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) # Set Data entry["desc"] = translatedText 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 (string or list) if "command" in entry and entry["command"] is not None: # If list of strings if isinstance(entry["command"], list): for i, val in enumerate(entry["command"]): if val: if translatedDataList is None: # Nuke Wrap commandArrayList.append(val.replace("\n", " ")) else: if commandArrayList: translatedText = dazedwrap.wrapText(commandArrayList[0], width=WIDTH) entry["command"][i] = translatedText commandArrayList.pop(0) # If simple string elif isinstance(entry["command"], str): if translatedDataList is None: commandList.append(entry["command"]) 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 msg field (array of strings) if "msg" in entry and isinstance(entry["msg"], list): for i, val in enumerate(entry["msg"]): if val is not None: if translatedDataList is None: msgList.append(str(val).replace("\n", " ")) else: if msgList: translatedText = dazedwrap.wrapText(msgList[0], width=WIDTH) entry["msg"][i] = translatedText msgList.pop(0) # Handle rewardData field (array of strings) if "rewardData" in entry and isinstance(entry["rewardData"], list): for i, val in enumerate(entry["rewardData"]): if val: # PASS 1: Collect original if translatedDataList is None: rewardDataList.append(str(val).replace("\n", " ")) # PASS 2: Apply translation else: if rewardDataList: translatedText = rewardDataList[0] # Set Data (no wordwrap for reward data) entry["rewardData"][i] = translatedText rewardDataList.pop(0) # Handle customParameters name field (e.g., {name:'シャルロット強制売春'}) if "customParameters" in entry and entry["customParameters"]: match = re.search(r"name:\s*['\"]([^'\"]+)['\"]", entry["customParameters"]) if match: # PASS 1: Collect original if translatedDataList is None: customParametersNameList.append(match.group(1)) # PASS 2: Apply translation else: if customParametersNameList: translatedName = customParametersNameList[0] # Replace the name value in customParameters entry["customParameters"] = re.sub( r"(name:\s*['\"])([^'\"]+)(['\"])", r"\1" + translatedName.replace("\\", "\\\\") + r"\3", entry["customParameters"] ) customParametersNameList.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"]).replace("\n", " ") # PASS 2: Apply translation else: if descList: translatedText = descList[0] # Wordwrap translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) # Set Data terrain["desc"] = translatedText 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) originalMsgCount = len(msgList) originalCommandArrayCount = len(commandArrayList) originalRewardDataCount = len(rewardDataList) originalCustomParametersNameCount = len(customParametersNameList) # Keep a copy of original names for vocab update (for characters/items/skills/classes/weapons) vocab_name_files = ["characters", "items", "skills", "classes", "weapons"] originalNameList = nameList.copy() if any(tag in filename.lower() for tag in vocab_name_files) 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 name-bearing files if originalNameList and nameList: try: file_lower = filename.lower() section = None if "characters" in file_lower: section = "Speakers" elif "items" in file_lower: section = "Items" elif "skills" in file_lower: section = "Skills" elif "classes" in file_lower: section = "Classes" elif "weapons" in file_lower: section = "Weapons" if section: vocab_pairs = list(zip(originalNameList, nameList)) update_vocab_section(section, 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] # Batch translate msg arrays if msgList: response = translateAI( msgList, "Reply with only the " + LANGUAGE + " translation of the message text.", True, filename, pbar ) msgList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate command arrays if commandArrayList: response = translateAI( commandArrayList, "Reply with only the " + LANGUAGE + " translation of the command text.", True, filename, pbar ) commandArrayList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate rewardData arrays if rewardDataList: response = translateAI( rewardDataList, "Reply with only the " + LANGUAGE + " translation of the reward data text.", True, filename, pbar ) rewardDataList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate customParameters name fields if customParametersNameList: response = translateAI( customParametersNameList, "Reply with only the " + LANGUAGE + " translation of the custom parameter name.", True, filename, pbar ) customParametersNameList = 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) if len(msgList) != originalMsgCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(commandArrayList) != originalCommandArrayCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(rewardDataList) != originalRewardDataCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(customParametersNameList) != originalCustomParametersNameCount: 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, msgList, commandArrayList, rewardDataList, customParametersNameList], pbar, ) return totalTokens def parseTitles(data, filename): """Parser for titles.json (small dict of title strings).""" totalTokens = [0, 0] pbar = None try: # Collect fields to translate keys = [k for k in ["windowTitle", "gameTitle", "saveFileTitle"] if k in data and data[k]] values = [data[k] for k in keys] with LOCK: pbar = tqdm( desc=filename, total=len(values), bar_format=BAR_FORMAT, position=POSITION, leave=LEAVE, ) if values: response = translateAI( values, "Reply with only the " + LANGUAGE + " translation of the title text.", True, filename, pbar, ) translations = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Apply for i, k in enumerate(keys): data[k] = translations[i] 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 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: continue # Count name and desc fields at entry level for field in ["name", "desc"]: if field in entry and entry[field]: total_units += 1 # Count customParameters hints if "customParameters" in entry and entry["customParameters"]: match = re.search(r'hint:"((?:[^"\\]|\\.)*)"', entry["customParameters"]) if match: total_units += 1 if "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 (count any non-empty text; do not gate on LANGREGEX) if "data" in command and isinstance(command["data"], list): for text in command["data"]: if text: total_units += 1 # Count speaker field (count any non-empty speaker; do not gate on LANGREGEX) 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 = 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 name, desc, customParameters, and pages->commands->data structure filename: Name of the file being translated translatedDataList: List containing [nameList, descList, dataList, speakerList, customParamsList] - 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 = [] dataList = [] speakerList = [] customParamsList = [] originalSpeakerList = [] # For vocab update else: # PASS 2: Use provided translated lists nameList = translatedDataList[0] descList = translatedDataList[1] dataList = translatedDataList[2] speakerList = translatedDataList[3] customParamsList = 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: # Remove newlines for translation descList.append(entry["desc"].replace("\n", " ")) # PASS 2: Apply translation else: if descList: translatedText = descList[0] # Apply text wrapping translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) entry["desc"] = translatedText descList.pop(0) # Handle customParameters at entry level if "customParameters" in entry and entry["customParameters"]: # PASS 1: Collect hint text if translatedDataList is None: # Extract hint value using regex (captures content between hint:" and ") match = re.search(r'hint:"((?:[^"\\]|\\.)*)"', entry["customParameters"]) if match: hintText = match.group(1) hintText = hintText.replace("\\n", " ") customParamsList.append(hintText) else: # No hint found, add empty string as placeholder customParamsList.append("") # PASS 2: Apply translation else: if customParamsList: translatedHint = customParamsList[0] customParamsList.pop(0) if translatedHint: # Only replace if we translated something # Replace double quotes with single quotes since \" is used as delimiter translatedHint = translatedHint.replace('"', "'") # Wrap text using dazedwrap with WIDTH and replace newlines with \\n translatedHint = dazedwrap.wrapText(translatedHint, width=WIDTH) translatedHint = translatedHint.replace("\n", "\\\\n") # Replace the hint value in customParameters entry["customParameters"] = re.sub( r'(hint:")((?:[^"\\]|\\.)*)"', r'\1' + translatedHint + '"', entry["customParameters"] ) # Check if pages exists before processing if "pages" not in entry or 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 originalNameCount = len(nameList) originalDescCount = len(descList) originalDataCount = len(dataList) originalSpeakerCount = len(speakerList) originalCustomParamsCount = len(customParamsList) # Batch translate names if nameList: response = translateAI( nameList, "Reply with only the " + LANGUAGE + " translation of the name.", True, filename, pbar ) nameList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate descriptions if descList: response = translateAI( descList, "Reply with only the " + LANGUAGE + " translation of the description.", True, filename, pbar ) descList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # 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] # Batch translate customParameters hints (only non-empty ones) if customParamsList: # Filter out empty strings for translation hintsToTranslate = [h for h in customParamsList if h] if hintsToTranslate: response = translateAI( hintsToTranslate, "Reply with only the " + LANGUAGE + " translation of the hint text.", True, filename, pbar ) translatedHints = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Reconstruct customParamsList with translations in place translatedIndex = 0 for i, hint in enumerate(customParamsList): if hint: # If it was non-empty, use the translation customParamsList[i] = translatedHints[translatedIndex] translatedIndex += 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(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) if len(customParamsList) != originalCustomParamsCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) # PASS 2: Recursively call to apply translations translateRecollection(data, filename, [nameList, descList, dataList, speakerList, customParamsList], 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: continue # Count event-level name and desc fields for field in ["name", "desc"]: if field in event and event[field]: total_units += 1 if "pages" not in event or 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 = [] eventNameList = [] eventDescList = [] 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] eventNameList = translatedDataList[6] eventDescList = translatedDataList[7] dataList = translatedDataList[8] speakerList = translatedDataList[9] # 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"].replace("\n", " ")) # PASS 2: Apply translation else: if descList: translatedText = descList[0] # Wordwrap translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) data["desc"] = translatedText 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"].replace("\n", " ")) else: if unitDescList: translatedText = unitDescList[0] # Wordwrap translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) unit["desc"] = translatedText 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: continue # Handle event-level name field if "name" in event and event["name"]: if translatedDataList is None: eventNameList.append(event["name"]) else: if eventNameList: event["name"] = eventNameList[0] eventNameList.pop(0) # Handle event-level desc field if "desc" in event and event["desc"]: if translatedDataList is None: # Remove newlines for translation eventDescList.append(event["desc"].replace("\n", " ")) else: if eventDescList: translatedText = eventDescList[0] # Apply text wrapping translatedText = dazedwrap.wrapText(translatedText, width=WIDTH) event["desc"] = translatedText eventDescList.pop(0) if "pages" not in event or 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) originalEventNameCount = len(eventNameList) originalEventDescCount = len(eventDescList) 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 event names if eventNameList: response = translateAI( eventNameList, "Reply with only the " + LANGUAGE + " translation of the event name.", True, filename, pbar ) eventNameList = response[0] totalTokens[0] += response[1][0] totalTokens[1] += response[1][1] # Batch translate event descriptions if eventDescList: response = translateAI( eventDescList, "Reply with only the " + LANGUAGE + " translation of the event description.", True, filename, pbar ) eventDescList = 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(eventNameList) != originalEventNameCount: with LOCK: if filename not in MISMATCH: MISMATCH.append(filename) if len(eventDescList) != originalEventDescCount: 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, eventNameList, eventDescList, 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 )