diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 000000000..2e6f8e1c7 --- /dev/null +++ b/utils/README.md @@ -0,0 +1,18 @@ +# Utility scripts + +> collection of utility scripts for various tasks + +## Translate Helper + +> A script to help with translations by reading the target language, comparing with the base language, and going through missing keys. + +### Usage + +```bash +python translate_helper.py pt_BR [--check] +``` + +- `pt_BR` is the target language code (change as needed) +- `--check` is an optional flag to only check for missing keys without prompting for translations + +The script will read the base language file (`en_US.axaml`) and the target language file (e.g., `pt_BR.axaml`), identify missing keys, and prompt you to provide translations for those keys. If the `--check` flag is used, it will only list the missing keys without prompting for translations. diff --git a/utils/translate_helper.py b/utils/translate_helper.py new file mode 100644 index 000000000..7e294f8d7 --- /dev/null +++ b/utils/translate_helper.py @@ -0,0 +1,215 @@ +import sys +import io +import os +import xml.etree.ElementTree as ET +import re + +# Define namespaces URIs +XAML_NS = 'https://github.com/avaloniaui' +X_NS = 'http://schemas.microsoft.com/winfx/2006/xaml' + +def register_namespaces(): + """Registers namespaces for ElementTree to use when writing the XML file.""" + ET.register_namespace('', XAML_NS) + ET.register_namespace('x', X_NS) + +def get_locale_dir(): + """Constructs the absolute path for the locales files""" + try: + script_dir = os.path.dirname(os.path.realpath(__file__)) + project_root = os.path.abspath(os.path.join(script_dir, '..')) + locales_dir = os.path.join(project_root, 'src', 'Resources', 'Locales') + except NameError: + project_root = os.path.abspath(os.getcwd()) + locales_dir = os.path.join(project_root, 'src', 'Resources', 'Locales') + + return locales_dir + +def get_locale_files(lang_id): + """Constructs the absolute paths for the target and reference locale files.""" + + # get the locales dir + locales_dir = get_locale_dir() + + # get the target file absolute path + target_file = os.path.join(locales_dir, f"{lang_id}.axaml") + + if not os.path.exists(target_file): + print(f"Error: Target language file not found at {target_file}") + sys.exit(1) + + try: + tree = ET.parse(target_file) + root = tree.getroot() + merged_dict = root.find(f"{{{XAML_NS}}}ResourceDictionary.MergedDictionaries") + if merged_dict is None: + raise ValueError("Could not find MergedDictionaries tag.") + + resource_include = merged_dict.find(f"{{{XAML_NS}}}ResourceInclude") + if resource_include is None: + raise ValueError("Could not find ResourceInclude tag.") + + include_source = resource_include.get('Source') + ref_filename_match = re.search(r'([a-zA-Z]{2}_[a-zA-Z]{2}).axaml', include_source) + if not ref_filename_match: + raise ValueError("Could not parse reference filename from Source attribute.") + + ref_filename = f"{ref_filename_match.group(1)}.axaml" + ref_file = os.path.join(locales_dir, ref_filename) + except Exception as e: + print(f"Error parsing {target_file} to find reference file: {e}") + sys.exit(1) + + if not os.path.exists(ref_file): + print(f"Error: Reference language file '{ref_file}' not found.") + sys.exit(1) + + return target_file, ref_file + +def get_strings(root): + """Extracts all translation keys and their text values from an XML root.""" + strings = {} + for string_tag in root.findall(f"{{{X_NS}}}String"): + key = string_tag.get(f"{{{X_NS}}}Key") + if key: + strings[key] = string_tag.text if string_tag.text is not None else "" + return strings + +def add_new_string_tag(root, key, value): + """Adds a new tag to the XML root, maintaining some formatting.""" + + # create a new tag with Key<>Value + new_tag = ET.Element(f"{{{X_NS}}}String") + new_tag.set(f"{{{X_NS}}}Key", key) + new_tag.set("xml:space", "preserve") + new_tag.text = value + + # try to find the last tag in the + last_element_index = -1 + children = list(root) + + start = 0 + end = len(children) - 1 + middle = -1 + + # find the first String tag + while (children[start].tag != f"{{{X_NS}}}String"): + start = start + 1 + + # find where the insert the new tag + # so it always keeps the alphabetical order + while (end - start) > 1: + middle = int((start + end) / 2) + + if (children[middle].tag == f"{{{X_NS}}}String"): + middle_key = children[middle].get(f"{{{X_NS}}}Key") + + if key.lower() < middle_key.lower(): + end = middle + else: + start = middle + + # insert after the middle or at the end + if middle != -1: + new_tag.tail = root[middle].tail + root.insert(middle + 1, new_tag) + else: + new_tag.tail = "\n " + root.append(new_tag) + +def save_translations(tree, file_path): + """Saves the XML tree to the file, attempting to preserve formatting.""" + try: + ET.indent(tree, space=" ") + except AttributeError: + print("Warning: ET.indent not available. Output formatting may not be ideal.") + + tree.write(file_path, encoding='utf-8', xml_declaration=False) + print(f"\nSaved changes to {file_path}") + +def main(): + """Main function to run the translation helper script.""" + if len(sys.argv) < 2: + print("Usage: python utils/translate_helper.py [--check]") + sys.exit(1) + + # Force sys.stdin to use UTF-8 decoding + if sys.stdin.encoding.lower() != 'utf-8': + print("Changin input encoding to UTF-8") + sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8') + + # get arguments + lang_id = sys.argv[1] + is_check_mode = len(sys.argv) > 2 and sys.argv[2] == '--check' + + # setup XML parser + register_namespaces() + + # try to find XML files + target_file_path, ref_file_path = get_locale_files(lang_id) + + # parse files + parser = ET.XMLParser(target=ET.TreeBuilder(insert_comments=True)) + target_tree = ET.parse(target_file_path, parser) + target_root = target_tree.getroot() + + ref_tree = ET.parse(ref_file_path) + ref_root = ref_tree.getroot() + + # get all translation keys + target_strings = get_strings(target_root) + ref_strings = get_strings(ref_root) + + # compute the missing keys between the target and reference files + missing_keys = sorted([key for key in ref_strings.keys() if key not in target_strings]) + + if not missing_keys: + print("All keys are translated. Nothing to do.") + return + + print(f"Found {len(missing_keys)} missing keys for language '{lang_id}'.") + + # running in check mode will only display the missing keys + if is_check_mode: + print("Missing keys:") + for key in missing_keys: + print(f" - {key}") + return + + # running in normal mode will trigger the translation process + print("Starting interactive translation...\n") + changes_made = False + try: + # for each missing key + for i, key in enumerate(missing_keys): + + # show the original text + original_text = ref_strings.get(key, "") + print("-" * 40) + print(f"({i+1}/{len(missing_keys)}) Key: '{key}'") + print(f"Original: '{original_text}'") + + # asks for a translated version + user_input = input("Enter translation (or press Enter to skip, 'q' to save and quit): ") + + # if 'q' quit and save + if user_input.lower() == 'q': + print("\nQuitting and saving changes...") + break + # if valid input, save + elif user_input: + add_new_string_tag(target_root, key, user_input) + changes_made = True + print(f"Added translation for '{key}'") + + except (KeyboardInterrupt, EOFError): + print("\n\nProcess interrupted. Saving changes...") + finally: + # if there was any changes, save back to the target file + if changes_made: + save_translations(target_tree, target_file_path) + else: + print("\nNo changes were made.") + +if __name__ == "__main__": + main()