diff --git a/images/interactive_translation.gif b/images/interactive_translation.gif new file mode 100644 index 0000000..e4b6570 Binary files /dev/null and b/images/interactive_translation.gif differ diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..b26c84b --- /dev/null +++ b/tools/README.md @@ -0,0 +1,19 @@ +# Translation Manager + +If you are familiar with the command line, you might consider using the interactive translation manager for a much faster translation process! ⚡️ +![interactive_translation](../images/interactive_translation.gif) + +## How to use it + +Run the following command inside this folder to spawn an interactive translation shell: + +```bash +python translation_manager.py translate_interactive +``` + +You will be asked to submit the missing translation keys. +Replace `` with the language you want to translate to. +The provided language must be a [short letter code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (ISO 639-1). + +> Note: If you want to enforce recreating all keys, pass the flag `-recreate` to the end of the above command. +This might be useful if you want to proof-check keys, as it also shows you the English source sentence alongside the current translation. Translations that you consider good can be simply inherited when you leave the prompt empty. diff --git a/tools/translation_manager.py b/tools/translation_manager.py index 8a1f6e4..e713190 100644 --- a/tools/translation_manager.py +++ b/tools/translation_manager.py @@ -1,20 +1,57 @@ #!/usr/bin/env python3 +from functools import reduce +import operator import json import copy import glob - +import os +import sys import fire +# For better keyboard support, try importing 'readline' package (not required) +try: + import readline +except ImportError: + pass + LOCALE_DIR = "../locales" INDENT = 4 -def open_locale_file(fname): - """Opens a locale file""" - with open(fname) as json_file: - return json.load(json_file) +class InvalidLanguageError(Exception): + """Exception raised for invalid language inputs. + + Attributes + ---------- + `language` : input language which caused the error + """ + + def __init__(self, language=""): + self.language = language + super().__init__(self.language) + + def __str__(self): + if self.language: + return f"Invalid language {self.language}, or the {self.language}.json was not found!\nPlease check if it is an ISO 639-1 language flag! " + else: + return f"Invalid language, or the JSON was not found!\nPlease check if it is an ISO 639-1 language flag!" + + +def open_locale_file(fname) -> dict: + """Opens a locale file. + + Raises + ------ + `InvalidLanguageError` + If the input language flag is invalid. + """ + try: + with open(fname) as json_file: + return json.load(json_file) + except FileNotFoundError: + raise InvalidLanguageError() def write_json(data): @@ -87,6 +124,46 @@ def get_locale_files(path=LOCALE_DIR): return glob.glob(f'{path}/*.json') +def recurse_dict(d: dict, keys=()): + """Generator. + + Returns + ------- + Iterable of the nested dictionary as (compound_keys, value): + - compound_keys: cookie-trail + - values: nested value after following the compound_keys + """ + if type(d) == dict: + for key in d: + for value in recurse_dict(d[key], keys + (key, )): + yield value + else: + yield (keys, d) + +def nested_exists(d: dict, *keys): + """Checks if nested key exists.""" + try: + nested_get(d, *keys) + except KeyError: + return False + return True + +def nested_get(d: dict, *keys): + """Returns value of nested dict for given compound_keys.""" + return reduce(operator.getitem, keys, d) + +def nested_set(d: dict, value, *keys): + """Sets value in nested dictionary for given keys path.""" + #nested_get(d, *keys[:-1])[keys[-1]] = value + if len(keys) == 1: + d[keys[0]] = value + else: + try: + nested_set(d[keys[0]], value, *keys[1:]) + except KeyError: + d[keys[0]] = {} + nested_set(d[keys[0]], value, *keys[1:]) + class TranslationManager(object): """Command line tool for managing i18n files""" def alphabetize(self): @@ -136,6 +213,79 @@ def trim_dead_keys(self, locale="en"): trim_dead_keys(primary_dict, dest_dict) export(dest_dict, fname) + def translate_interactive(self, dest: str, recreate=False): + """Spawns interactive translating session. + + User is asked to submit missing key-translations for a given language. + + Parameters + ---------- + `dest` : str + The destination language flag [ISO 639-1]. + `recreate` : bool + If true, all keys will be re-translated (not only missing keys!). + """ + # Load locale + source_fname = f"{LOCALE_DIR}/en.json" + dest_fname = f"{LOCALE_DIR}/{dest}.json" + source_dict = open_locale_file(source_fname) + dest_dict = open_locale_file(dest_fname) + print("Selected translation language:", dest) + if recreate: + print("WARNING: Entered mode for re-translating all keys!") + + # Check which keys do not exist in target dict + queue = [] + for compound_key, _ in recurse_dict(source_dict): + if not nested_exists(dest_dict, *compound_key) or recreate: + queue.append(compound_key) + + # Return if nothing to translate + n_keys = len(queue) + if n_keys == 0: + print(">>>> All keys are set, nothing to translate! 🥳") + return + + # Start interactive translation session + print("Enter a translation for the given key.") + print("Leave it empty if the current translation is good enough, or you are unsure.") + cols, _ = os.get_terminal_size() + print("-"*cols) + for i, compound_key in enumerate(queue): + # Prompt user to input new translation + counter = f"## {i+1} out of {n_keys} keys to translate ##" + key_trail = "## Key: " + '->'.join(compound_key) + " ##" + string_source = f"en: {nested_get(source_dict, *compound_key)}" + string_dest = f"{dest}: {nested_get(dest_dict, *compound_key) if nested_exists(dest_dict, *compound_key) else '???'}" + prompt = ">> " + print(counter) + print(key_trail) + print(string_source) + print(string_dest) + string_translated = input(prompt) + + # Clear terminal + cols, _ = os.get_terminal_size() + for i in range(int(len(counter)/cols) + + int(len(key_trail)/cols) + + int(len(string_source)/cols) + + int(len(string_dest)/cols) + + int((len(prompt)+len(string_translated))/cols) + + 5): + sys.stdout.write("\033[F") # back to previous line + sys.stdout.write("\033[K") # clear line + + # Save user input to dict + if string_translated: + nested_set(dest_dict, string_translated, *compound_key) + + # export modified dict and save + export(dest_dict, dest_fname) + print("Done! 🎉") + if __name__ == '__main__': - fire.Fire(TranslationManager) + try: + fire.Fire(TranslationManager) + except KeyboardInterrupt: + print("\nAbort.")