#!/usr/bin/env python3 # -*- coding: utf-8 -*- # # Script to convert Minetest *.tr files to *.po and vice-versa. # # Copyright (C) 2023 Wuzzy # License: LGPLv2.1 or later (see LICENSE file for details) from __future__ import print_function import os, fnmatch, re, shutil, errno from sys import argv as _argv from sys import stderr as _stderr # Name of directory to export *.po files into DIRNAME = "poconvert" SCRIPTNAME = "mtt_convert" VERSION = "0.1.0" MODE_PO2TR = 0 MODE_TR2PO = 1 # comment to mark the section of old/unused strings comment_unused = "##### not used anymore #####" # Running params params = {"recursive": False, "help": False, "verbose": False, "po2tr": False, "tr2po": False, "folders": [], } # Available CLI options options = { "po2tr": ['--po2tr', '-P'], "tr2po": ['--tr2po', '-T'], "recursive": ['--recursive', '-r'], "help": ['--help', '-h'], "verbose": ['--verbose', '-v'], } # Strings longer than this will have extra space added between # them in the translation files to make it easier to distinguish their # beginnings and endings at a glance doublespace_threshold = 80 pattern_tr = re.compile(r'(.*?[^@])=(.*)') pattern_name = re.compile(r'^name[ ]*=[ ]*([^ \n]*)') pattern_tr_filename = re.compile(r'\.tr$') pattern_tr_language_code = re.compile(r'.*\.([a-zA-Z]+)\.tr$') pattern_po_language_code = re.compile(r'(.*)\.po$') def set_params_folders(tab: list): '''Initialize params["folders"] from CLI arguments.''' # Discarding argument 0 (tool name) for param in tab[1:]: stop_param = False for option in options: if param in options[option]: stop_param = True break if not stop_param: params["folders"].append(os.path.abspath(param)) def set_params(tab: list): '''Initialize params from CLI arguments.''' for option in options: for option_name in options[option]: if option_name in tab: params[option] = True break def print_help(name): '''Prints some help message.''' print(f'''SYNOPSIS {name} [OPTIONS] [PATHS...] DESCRIPTION {', '.join(options["help"])} prints this help message {', '.join(options["po2tr"])} convert from *.po to *.tr files {', '.join(options["tr2po"])} convert from *.tr to *.po files {', '.join(options["recursive"])} run on all subfolders of paths given {', '.join(options["verbose"])} add output information''') def main(): '''Main function''' set_params(_argv) set_params_folders(_argv) if params["help"]: print_help(_argv[0]) else: mode = None if params["po2tr"] and not params["tr2po"]: mode = MODE_PO2TR elif params["tr2po"] and not params["po2tr"]: mode = MODE_TR2PO else: print("You must select a conversion mode (--po2tr or --tr2po)") exit(1) # Add recursivity message print("Running ", end='') if params["recursive"]: print("recursively ", end='') # Running if len(params["folders"]) >= 2: print("on folder list:", params["folders"]) for f in params["folders"]: if params["recursive"]: run_all_subfolders(mode, f) else: update_folder(mode, f) elif len(params["folders"]) == 1: print("on folder", params["folders"][0]) if params["recursive"]: run_all_subfolders(mode, params["folders"][0]) else: update_folder(mode, params["folders"][0]) else: print("on folder", os.path.abspath("./")) if params["recursive"]: run_all_subfolders(mode, os.path.abspath("./")) else: update_folder(mode, os.path.abspath("./")) #attempt to read the mod's name from the mod.conf file or folder name. Returns None on failure def get_modname(folder): try: with open(os.path.join(folder, "mod.conf"), "r", encoding='utf-8') as mod_conf: for line in mod_conf: match = pattern_name.match(line) if match: return match.group(1) except FileNotFoundError: if not os.path.isfile(os.path.join(folder, "modpack.txt")): folder_name = os.path.basename(folder) # Special case when run in Minetest's builtin directory if folder_name == "builtin": return "__builtin" else: return folder_name else: return None return None # A series of search and replaces that massage a .po file's contents into # a .tr file's equivalent def process_po_file(text): if params["verbose"]: print(f"Processing PO file ...") # escape '@' signs except those followed by digit 1-9 text = re.sub(r'(@)(?![1-9])', "@@", text) # escape equals signs text = re.sub(r'=', "@=", text) # The first three items are for unused matches text = re.sub(r'^#~ msgid "', "", text, flags=re.MULTILINE) text = re.sub(r'"\n#~ msgstr ""\n"', "=", text) text = re.sub(r'"\n#~ msgstr "', "=", text) # clear comment lines text = re.sub(r'^#.*\n', "", text, flags=re.MULTILINE) # converting msg pairs into "=" pairs text = re.sub(r'^msgid "', "", text, flags=re.MULTILINE) text = re.sub(r'"\nmsgstr ""\n"', "=", text) text = re.sub(r'"\nmsgstr "', "=", text) # various line breaks and escape codes text = re.sub(r'"\n"', "", text) text = re.sub(r'"\n', "\n", text) text = re.sub(r'\\"', '"', text) text = re.sub(r'\\n', '@n', text) # remove header text text = re.sub(r'=Project-Id-Version:.*\n', "", text) # remove leading whitespace and double-spaced lines text = text.lstrip() oldtext = '' while text != oldtext: oldtext = text text = re.sub(r'\n\n', '\n', text) return text def generate_po_header(textdomain, language): if textdomain == "__builtin": project_id = "Minetest builtin component" else: project_id = "Minetest textdomain " + textdomain # fake version number project_version = "x.x.x" project_id_version = project_id + " " + project_version header = """msgid "" msgstr "" "Project-Id-Version: """+project_id_version+"""\\n" "Report-Msgid-Bugs-To: \\n" "POT-Creation-Date: \\n" "PO-Revision-Date: \\n" "Last-Translator: \\n" "Language-Team: \\n" "Language: """ + language + """\\n" "MIME-Version: 1.0\\n" "Content-Type: text/plain; charset=UTF-8\\n" "Content-Transfer-Encoding: 8bit\\n" "Plural-Forms: \\n" "X-Generator: """+SCRIPTNAME+" "+VERSION+"""\\n" """ return header def escape_for_tr(text): # Temporarily replace " and @@ with ASCII ESC char + another character # so they don't conflict with the *.tr escape codes text = re.sub(r'"', "\033q", text) text = re.sub(r'@@', "\033d", text) # unescape *.tr special chars text = re.sub(r'@n', '\\\\n\"\n\"', text) text = re.sub(r'@=', "=", text) # Undo the ASCII escapes # Restore \033d to @, not @@ because that's another *.tr escape text = re.sub("\033d", "@", text) text = re.sub("\033q", '\\"', text) return text # Convert .tr to .po or .pot # If language is the empty string, will create a template def process_tr_file(text, textdomain, language): if params["verbose"]: print(f"Processing TR file ... (textdomain={textdomain}; language={language})") stext = "" # write header stext = generate_po_header(textdomain, language) + stext # ignore everything after the special line marking unused strings unusedMatch = re.search("\n" + comment_unused, text) if (unusedMatch != None): text = text[0:unusedMatch.start()] # match strings and write in PO-style strings = re.findall(r'^(.*(?2.5 if exc.errno == errno.EEXIST and os.path.isdir(path): pass else: raise # Gets strings from an existing translation file # returns both a dictionary of translations # and the full original source text so that the new text # can be compared to it for changes. # Returns also header comments in the third return value. def import_tr_file(tr_file): dOut = {} text = None header_comment = None if os.path.exists(tr_file): with open(tr_file, "r", encoding='utf-8') as existing_file : # save the full text to allow for comparison # of the old version with the new output text = existing_file.read() existing_file.seek(0) # a running record of the current comment block # we're inside, to allow preceeding multi-line comments # to be retained for a translation line latest_comment_block = None for line in existing_file.readlines(): line = line.rstrip('\n') if line.startswith("###"): if header_comment is None and not latest_comment_block is None: # Save header comments header_comment = latest_comment_block # Strip textdomain line tmp_h_c = "" for l in header_comment.split('\n'): if not l.startswith("# textdomain:"): tmp_h_c += l + '\n' header_comment = tmp_h_c # Reset comment block if we hit a header latest_comment_block = None continue elif line.startswith("#"): # Save the comment we're inside if not latest_comment_block: latest_comment_block = line else: latest_comment_block = latest_comment_block + "\n" + line continue match = pattern_tr.match(line) if match: # this line is a translated line outval = {} outval["translation"] = match.group(2) if latest_comment_block: # if there was a comment, record that. outval["comment"] = latest_comment_block latest_comment_block = None dOut[match.group(1)] = outval return (dOut, text, header_comment) # Updates translation files for the mod in the given folder def update_mod(mode, folder): modname = get_modname(folder) if modname is not None: if mode == MODE_TR2PO: print(f"Converting TR files for {modname}") process_tr_files(folder, modname) elif mode == MODE_PO2TR: print(f"Converting PO files for {modname}") process_po_files(folder, modname) else: print("ERROR: Invalid mode provided in update_mod()") exit(1) else: print(f"\033[31mUnable to find modname in folder {folder}.\033[0m", file=_stderr) exit(1) # Determines if the folder being pointed to is a mod or a mod pack # and then runs update_mod accordingly def update_folder(mode, folder): is_modpack = os.path.exists(os.path.join(folder, "modpack.txt")) or os.path.exists(os.path.join(folder, "modpack.conf")) if is_modpack: subfolders = [f.path for f in os.scandir(folder) if f.is_dir() and not f.name.startswith('.')] for subfolder in subfolders: update_mod(mode, subfolder) else: update_mod(mode, folder) print("Done.") def run_all_subfolders(mode, folder): for modfolder in [f.path for f in os.scandir(folder) if f.is_dir() and not f.name.startswith('.')]: update_folder(mode, modfolder) main()