DazedTL/modules/srpg.py
2026-03-01 11:27:57 -06:00

2251 lines
88 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
)