|
| 1 | +''' |
| 2 | +Name: translate-en-messages |
| 3 | +Version: 2026.2.10.29 |
| 4 | +Author: Adam Lui |
| 5 | +Description: Translate en/messages.json to other locales |
| 6 | +Homepage: https://github.com/adamlui/python-utils |
| 7 | +Support: https://github.com/adamlui/python-utils/issues |
| 8 | +Sponsor: https://github.com/sponsors/adamlui |
| 9 | +Notes: Use --help to print CLI args. |
| 10 | +''' |
| 11 | + |
| 12 | +import argparse, os, json |
| 13 | +import lib.init as init |
| 14 | +from sys import stdout |
| 15 | +from translate import Translator |
| 16 | +from urllib.request import urlopen |
| 17 | + |
| 18 | +cli = init.cli() ; init.configFile(cli, __file__) |
| 19 | + |
| 20 | +# Parse CLI args |
| 21 | +parser = argparse.ArgumentParser(description='Translate en/messages.json to other locales') |
| 22 | +parser.add_argument('--include-langs', type=str, help='Languages to include (e.g. "en,es,fr")') |
| 23 | +parser.add_argument('--exclude-langs', type=str, help='Languages to exclude (e.g. "en,es")') |
| 24 | +parser.add_argument('--ignore-keys', type=str, help='Keys to ignore (e.g. "appName,author")') |
| 25 | +parser.add_argument('--locales-dir', type=str, help='Name of folder containing locales') |
| 26 | +parser.add_argument('--provider', type=str, help='Name of provider to use for translation') |
| 27 | +parser.add_argument('--init', action='store_true', help='Create .config.json file to store defaults') |
| 28 | +args = parser.parse_args() |
| 29 | +locales_dir = args.locales_dir or cli.config_data.get('locales_dir', '') or '_locales' |
| 30 | +provider = args.provider or cli.config_data.get('provider', '') |
| 31 | + |
| 32 | +if args.init: # create config file |
| 33 | + if os.path.exists(cli.config_path): |
| 34 | + print(f'Config already exists at {cli.config_path}') |
| 35 | + else: |
| 36 | + try: # try to fetch template from jsDelivr |
| 37 | + jsd_url = f'{cli.urls.jsdelivr}/{cli.name}/{cli.config_filename}' |
| 38 | + with urlopen(jsd_url) as resp: |
| 39 | + if resp.status == 200 : cli.config_data = json.loads(resp.read().decode('utf-8')) |
| 40 | + except Exception : pass |
| 41 | + with open(cli.config_path, 'w', encoding='utf-8') as configFile: |
| 42 | + json.dump(cli.config_data, configFile, indent=2) |
| 43 | + print(f'Default config created at {cli.config_path}') |
| 44 | + exit() |
| 45 | + |
| 46 | +# Init target_locales |
| 47 | +def parse_csv_val(val) : return [item.strip() for item in val.split(',') if item.strip()] |
| 48 | +include_arg = args.include_langs or cli.config_data.get('include_langs', '') |
| 49 | +exclude_arg = args.exclude_langs or cli.config_data.get('exclude_langs', '') |
| 50 | +target_locales = parse_csv_val(include_arg) or cli.default_target_locales |
| 51 | +exclude_langs = set(parse_csv_val(exclude_arg)) |
| 52 | +target_locales = [lang for lang in target_locales if lang not in exclude_langs] |
| 53 | + |
| 54 | +# UI initializations |
| 55 | +try: |
| 56 | + terminal_width = os.get_terminal_size()[0] |
| 57 | +except OSError: |
| 58 | + terminal_width = 80 |
| 59 | +def print_trunc(msg, end='\n'): |
| 60 | + truncated_lines = [ |
| 61 | + line if len(line) < terminal_width else line[:terminal_width -4] + '...' for line in msg.splitlines() ] |
| 62 | + print('\n'.join(truncated_lines), end=end) |
| 63 | +def overwrite_print(msg) : stdout.write('\r' + msg.ljust(terminal_width)[:terminal_width]) |
| 64 | + |
| 65 | +print('') |
| 66 | + |
| 67 | +# Prompt user for keys to ignore |
| 68 | +ignore_keys = parse_csv_val(args.ignore_keys or cli.config_data.get('ignore_keys', '')) |
| 69 | +while True: |
| 70 | + if ignore_keys : print('Ignored key(s):', ignore_keys) |
| 71 | + key = input('Enter key to ignore (or ENTER if done): ') |
| 72 | + if not key : break |
| 73 | + ignore_keys.append(key) |
| 74 | + |
| 75 | +# Determine closest locales dir |
| 76 | +print_trunc(f'\nSearching for { locales_dir }...') |
| 77 | +script_dir = os.path.abspath(os.path.dirname(__file__)) |
| 78 | +for root, dirs, files in os.walk(script_dir): # search script dir recursively |
| 79 | + if locales_dir in dirs: |
| 80 | + locales_dir = os.path.join(root, locales_dir) ; break |
| 81 | +else: # search script parent dirs recursively |
| 82 | + parent_dir = os.path.dirname(script_dir) |
| 83 | + while parent_dir and parent_dir != script_dir: |
| 84 | + for root, dirs, files in os.walk(parent_dir): |
| 85 | + if locales_dir in dirs: |
| 86 | + locales_dir = os.path.join(root, locales_dir) ; break |
| 87 | + if locales_dir : break |
| 88 | + parent_dir = os.path.dirname(parent_dir) |
| 89 | + else : locales_dir = None |
| 90 | + |
| 91 | +# Print result |
| 92 | +if locales_dir : print_trunc(f'_locales directory found!\n\n>> { locales_dir }\n') |
| 93 | +else : print_trunc(f'Unable to locate a { locales_dir } directory.') ; exit() |
| 94 | + |
| 95 | +# Load en/messages.json |
| 96 | +msgs_filename = 'messages.json' |
| 97 | +en_msgs_path = os.path.join(locales_dir, 'en', msgs_filename) |
| 98 | +with open(en_msgs_path, 'r', encoding='utf-8') as en_file: |
| 99 | + en_messages = json.load(en_file) |
| 100 | + |
| 101 | +# Combine [target_locales] w/ languages discovered in _locales |
| 102 | +output_langs = list(set(target_locales)) # remove duplicates |
| 103 | +for root, dirs, files in os.walk(locales_dir): |
| 104 | + for folder in dirs: |
| 105 | + folder_path = os.path.join(root, folder) |
| 106 | + msgs_path = os.path.join(folder_path, msgs_filename) |
| 107 | + discovered_lang = folder.replace('_', '-') |
| 108 | + if os.path.exists(msgs_path) and discovered_lang not in output_langs : output_langs.append(discovered_lang) |
| 109 | +output_langs.sort() # alphabetize languages |
| 110 | + |
| 111 | +# Create/update/translate [[output_langs]/messages.json] |
| 112 | +langs_added, langs_skipped, langs_translated, langs_not_translated = [], [], [], [] |
| 113 | +for lang_code in output_langs: |
| 114 | + lang_added, lang_skipped, lang_translated = False, False, False |
| 115 | + folder = lang_code.replace('-', '_') ; translated_msgs = {} |
| 116 | + if '-' in lang_code: # cap suffix |
| 117 | + sep_idx = folder.index('_') |
| 118 | + folder = folder[:sep_idx] + '_' + folder[sep_idx+1:].upper() |
| 119 | + |
| 120 | + # Skip English locales |
| 121 | + if lang_code.startswith('en'): |
| 122 | + print_trunc(f'Skipped {folder}/messages.json...') |
| 123 | + langs_skipped.append(lang_code) ; langs_not_translated.append(lang_code) ; continue |
| 124 | + |
| 125 | + # Initialize target locale folder |
| 126 | + folder_path = os.path.join(locales_dir, folder) |
| 127 | + if not os.path.exists(folder_path): # if missing, create folder |
| 128 | + os.makedirs(folder_path) ; langs_added.append(lang_code) ; lang_added = True |
| 129 | + |
| 130 | + # Initialize target messages |
| 131 | + msgs_path = os.path.join(folder_path, msgs_filename) |
| 132 | + if os.path.exists(msgs_path): |
| 133 | + with open(msgs_path, 'r', encoding='utf-8') as messages_file : messages = json.load(messages_file) |
| 134 | + else : messages = {} |
| 135 | + |
| 136 | + # Attempt translations |
| 137 | + print_trunc(f"{ 'Adding' if not messages else 'Updating' } { folder }/messages.json...", end='') |
| 138 | + stdout.flush() |
| 139 | + en_keys = list(en_messages.keys()) |
| 140 | + fail_flags = ['INVALID TARGET LANGUAGE', 'TOO MANY REQUESTS', 'MYMEMORY'] |
| 141 | + for key in en_keys: |
| 142 | + if key in ignore_keys: |
| 143 | + translated_msg = en_messages[key]['message'] |
| 144 | + translated_msgs[key] = { 'message': translated_msg } |
| 145 | + continue |
| 146 | + if key not in messages: |
| 147 | + original_msg = translated_msg = en_messages[key]['message'] |
| 148 | + try: |
| 149 | + translator = Translator(provider=provider, to_lang=lang_code) |
| 150 | + translated_msg = translator.translate(original_msg).replace('"', "'").replace(''', "'") |
| 151 | + if any(flag in translated_msg for flag in fail_flags): |
| 152 | + translated_msg = original_msg |
| 153 | + except Exception as e: |
| 154 | + print_trunc(f'Translation failed for key "{key}" in {lang_code}/messages.json: {e}') |
| 155 | + translated_msg = original_msg |
| 156 | + translated_msgs[key] = { 'message': translated_msg } |
| 157 | + else : translated_msgs[key] = messages[key] |
| 158 | + |
| 159 | + # Format messages |
| 160 | + formatted_msgs = '{\n' |
| 161 | + for idx, (key, message_data) in enumerate(translated_msgs.items()): |
| 162 | + formatted_msg = json.dumps(message_data, ensure_ascii=False) \ |
| 163 | + .replace('{', '{ ').replace('}', ' }') # add spacing |
| 164 | + formatted_msgs += ( f' "{key}": {formatted_msg}' |
| 165 | + + ( ',\n' if idx < len(translated_msgs) -1 else '\n' )) # terminate line |
| 166 | + formatted_msgs += '}' |
| 167 | + with open(msgs_path, 'w', encoding='utf-8') as output_file : output_file.write(formatted_msgs + '\n') |
| 168 | + |
| 169 | + # Print file summary |
| 170 | + if translated_msgs == messages : langs_skipped.append(lang_code) ; lang_skipped = True |
| 171 | + elif translated_msgs != messages : langs_translated.append(lang_code) ; lang_translated = True |
| 172 | + if not lang_translated : langs_not_translated.append(lang_code) |
| 173 | + overwrite_print(f"{ 'Added' if lang_added else 'Skipped' if lang_skipped else 'Updated' } { folder }/messages.json") |
| 174 | + |
| 175 | +# Print final summary |
| 176 | +print_trunc('\nAll messages.json files updated successfully!\n') |
| 177 | +lang_data = [langs_translated, langs_skipped, langs_added, langs_not_translated] |
| 178 | +for data in lang_data: |
| 179 | + if data: |
| 180 | + list_name = next(name for name, value in globals().items() if value is data) |
| 181 | + status = list_name.split('langs_')[-1].replace('_', ' ') |
| 182 | + print(f'Languages {status}: {len(data)}\n') # print tally |
| 183 | + print('[ ' + ', '.join(data) + ' ]\n') # list languages |
0 commit comments