|
| 1 | +"""Keep the locale files in sync with the identifiers used in the code. |
| 2 | +
|
| 3 | +Strings live in the locale files, never in the code: a call site only names a |
| 4 | +string (``i18n("ui.chat.model_select")``), and ``locale/zh_CN.json`` holds the |
| 5 | +source wording for every identifier. This script reports identifiers that are |
| 6 | +used but not yet translated, and identifiers a locale still carries that the |
| 7 | +code no longer references. |
| 8 | +
|
| 9 | + python locale/extract_locale.py # sync keys only |
| 10 | + python locale/extract_locale.py --auto # also machine-translate the gaps |
| 11 | +""" |
1 | 12 | import asyncio |
2 | 13 | import logging |
3 | 14 | import os |
4 | 15 | import re |
5 | 16 | import sys |
6 | 17 |
|
7 | 18 | import aiohttp |
8 | | -import commentjson |
9 | 19 | import commentjson as json |
10 | 20 |
|
11 | 21 | asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy()) |
12 | 22 |
|
13 | | -with open("config.json", "r", encoding="utf-8") as f: |
14 | | - config = commentjson.load(f) |
15 | | -api_key = config["openai_api_key"] |
16 | | -url = config["openai_api_base"] + "/v1/chat/completions" if "openai_api_base" in config else "https://api.openai.com/v1/chat/completions" |
| 23 | +SOURCE_LANGUAGE = "zh_CN" |
| 24 | +LOCALE_DIR = "locale" |
| 25 | +UNTRANSLATED = "" |
| 26 | +NOT_USED = "(🔴NOT USED)" |
| 27 | +REVIEW_NEEDED = "(🟡REVIEW NEEDED)" |
17 | 28 |
|
| 29 | +# any quoted string that looks like a dotted identifier |
| 30 | +IDENTIFIER = re.compile(r"""["']([a-z][a-z0-9_]*(?:\.[a-z0-9_]+)+)["']""", re.IGNORECASE) |
18 | 31 |
|
19 | | -def get_current_strings(): |
20 | | - pattern = r'i18n\s*\(\s*["\']([^"\']*(?:\)[^"\']*)?)["\']\s*\)' |
21 | 32 |
|
22 | | - # Load the .py files |
23 | | - contents = "" |
24 | | - for dirpath, dirnames, filenames in os.walk("."): |
25 | | - for filename in filenames: |
26 | | - if filename.endswith(".py"): |
27 | | - filepath = os.path.join(dirpath, filename) |
28 | | - with open(filepath, 'r', encoding='utf-8') as f: |
29 | | - contents += f.read() |
30 | | - # Matching with regular expressions |
31 | | - matches = re.findall(pattern, contents, re.DOTALL) |
32 | | - data = {match.strip('()"'): '' for match in matches} |
33 | | - fixed_data = {} # fix some keys |
34 | | - for key, value in data.items(): |
35 | | - if "](" in key and key.count("(") != key.count(")"): |
36 | | - fixed_data[key+")"] = value |
| 33 | +def flatten(tree, prefix=""): |
| 34 | + flat = {} |
| 35 | + for key, value in tree.items(): |
| 36 | + dotted = f"{prefix}.{key}" if prefix else key |
| 37 | + if isinstance(value, dict): |
| 38 | + flat.update(flatten(value, dotted)) |
37 | 39 | else: |
38 | | - fixed_data[key] = value |
| 40 | + flat[dotted] = value |
| 41 | + return flat |
| 42 | + |
39 | 43 |
|
40 | | - return fixed_data |
| 44 | +def nest(flat): |
| 45 | + root = {} |
| 46 | + for key in sorted(flat): |
| 47 | + parts = key.split(".") |
| 48 | + node = root |
| 49 | + for part in parts[:-1]: |
| 50 | + node = node.setdefault(part, {}) |
| 51 | + node[parts[-1]] = flat[key] |
| 52 | + return root |
41 | 53 |
|
42 | 54 |
|
43 | | -def get_locale_strings(filename): |
| 55 | +def load_locale(language): |
| 56 | + path = os.path.join(LOCALE_DIR, f"{language}.json") |
44 | 57 | try: |
45 | | - with open(filename, "r", encoding="utf-8") as f: |
46 | | - locale_strs = json.load(f) |
| 58 | + with open(path, "r", encoding="utf-8") as f: |
| 59 | + return flatten(json.load(f)) |
47 | 60 | except FileNotFoundError: |
48 | | - locale_strs = {} |
49 | | - return locale_strs |
50 | | - |
51 | | - |
52 | | -def sort_strings(existing_translations): |
53 | | - # Sort the merged data |
54 | | - sorted_translations = {} |
55 | | - # Add entries with (NOT USED) in their values |
56 | | - for key, value in sorted(existing_translations.items(), key=lambda x: x[0]): |
57 | | - if "(🔴NOT USED)" in value: |
58 | | - sorted_translations[key] = value |
59 | | - # Add entries with empty values |
60 | | - for key, value in sorted(existing_translations.items(), key=lambda x: x[0]): |
61 | | - if value == "": |
62 | | - sorted_translations[key] = value |
63 | | - # Add the rest of the entries |
64 | | - for key, value in sorted(existing_translations.items(), key=lambda x: x[0]): |
65 | | - if value != "" and "(NOT USED)" not in value: |
66 | | - sorted_translations[key] = value |
67 | | - |
68 | | - return sorted_translations |
69 | | - |
70 | | - |
71 | | -async def auto_translate(str, language): |
72 | | - headers = { |
73 | | - "Content-Type": "application/json", |
74 | | - "Authorization": f"Bearer {api_key}", |
75 | | - "temperature": f"{0}", |
76 | | - } |
| 61 | + return {} |
| 62 | + |
| 63 | + |
| 64 | +def save_locale(language, strings): |
| 65 | + path = os.path.join(LOCALE_DIR, f"{language}.json") |
| 66 | + with open(path, "w", encoding="utf-8") as f: |
| 67 | + json.dump(nest(strings), f, ensure_ascii=False, indent=4, sort_keys=True) |
| 68 | + |
| 69 | + |
| 70 | +def identifiers_in_code(known): |
| 71 | + """Identifiers referenced from the code, whether through i18n() or a table. |
| 72 | +
|
| 73 | + Model metadata names its strings indirectly (``"description": "model.gpt4.description"``), |
| 74 | + so any quoted identifier that the source language defines counts as in use. |
| 75 | + """ |
| 76 | + used = set() |
| 77 | + for dirpath, dirnames, filenames in os.walk("."): |
| 78 | + if ".git" in dirpath or dirpath.startswith("./" + LOCALE_DIR): |
| 79 | + continue |
| 80 | + for filename in filenames: |
| 81 | + if not filename.endswith(".py"): |
| 82 | + continue |
| 83 | + with open(os.path.join(dirpath, filename), "r", encoding="utf-8") as f: |
| 84 | + contents = f.read() |
| 85 | + for match in IDENTIFIER.findall(contents): |
| 86 | + if match in known: |
| 87 | + used.add(match) |
| 88 | + for match in re.findall(r'i18n\s*\(\s*["\']([^"\']+)["\']\s*\)', contents): |
| 89 | + used.add(match) |
| 90 | + return used |
| 91 | + |
| 92 | + |
| 93 | +def sort_strings(strings): |
| 94 | + """Untranslated first, then unused, then the rest - so gaps are easy to find.""" |
| 95 | + def rank(item): |
| 96 | + value = item[1] |
| 97 | + if value == UNTRANSLATED: |
| 98 | + return 0 |
| 99 | + if NOT_USED in value: |
| 100 | + return 1 |
| 101 | + return 2 |
| 102 | + return dict(sorted(strings.items(), key=lambda kv: (rank(kv), kv[0]))) |
| 103 | + |
| 104 | + |
| 105 | +async def auto_translate(session, url, headers, text, language): |
77 | 106 | payload = { |
78 | | - "model": "gpt-3.5-turbo", |
| 107 | + "model": "gpt-4o-mini", |
79 | 108 | "messages": [ |
80 | 109 | { |
81 | 110 | "role": "system", |
82 | | - "content": f"You are a translation program;\nYour job is to translate user input into {language};\nThe content you are translating is a string in the App;\nDo not explain emoji;\nIf input is only a emoji, please simply return origin emoji;\nPlease ensure that the translation results are concise and easy to understand." |
| 111 | + "content": ( |
| 112 | + f"You are a translation program;\n" |
| 113 | + f"Your job is to translate user input into {language};\n" |
| 114 | + f"The content you are translating is a string in an app;\n" |
| 115 | + f"Keep any {{placeholder}} and markdown syntax exactly as-is;\n" |
| 116 | + f"Do not explain emoji; if the input is only an emoji, return it unchanged;\n" |
| 117 | + f"Please ensure that the translation is concise and easy to understand." |
| 118 | + ), |
83 | 119 | }, |
84 | | - {"role": "user", "content": f"{str}"} |
| 120 | + {"role": "user", "content": text}, |
85 | 121 | ], |
86 | 122 | } |
87 | | - |
88 | | - async with aiohttp.ClientSession() as session: |
89 | | - async with session.post(url, headers=headers, json=payload) as response: |
90 | | - data = await response.json() |
91 | | - return data["choices"][0]["message"]["content"] |
| 123 | + async with session.post(url, headers=headers, json=payload) as response: |
| 124 | + data = await response.json() |
| 125 | + return data["choices"][0]["message"]["content"] |
92 | 126 |
|
93 | 127 |
|
94 | 128 | async def main(auto=False): |
95 | | - current_strs = get_current_strings() |
96 | | - locale_files = [] |
97 | | - # 遍历locale目录下的所有json文件 |
98 | | - for dirpath, dirnames, filenames in os.walk("locale"): |
99 | | - for filename in filenames: |
100 | | - if filename.endswith(".json"): |
101 | | - locale_files.append(os.path.join(dirpath, filename)) |
102 | | - |
103 | | - |
104 | | - for locale_filename in locale_files: |
105 | | - if "zh_CN" in locale_filename: |
| 129 | + source = load_locale(SOURCE_LANGUAGE) |
| 130 | + if not source: |
| 131 | + logging.error(f"{SOURCE_LANGUAGE}.json is missing or empty; nothing to sync.") |
| 132 | + return |
| 133 | + |
| 134 | + used = identifiers_in_code(set(source)) |
| 135 | + undefined = sorted(i for i in used if i not in source) |
| 136 | + if undefined: |
| 137 | + print(f"⚠️ used in code but not defined in {SOURCE_LANGUAGE}.json:") |
| 138 | + for identifier in undefined: |
| 139 | + print(f" {identifier}") |
| 140 | + |
| 141 | + api_key = url = None |
| 142 | + if auto: |
| 143 | + with open("config.json", "r", encoding="utf-8") as f: |
| 144 | + config = json.load(f) |
| 145 | + api_key = config["openai_api_key"] |
| 146 | + base = config.get("openai_api_base", "https://api.openai.com") |
| 147 | + url = base.rstrip("/") + "/v1/chat/completions" |
| 148 | + |
| 149 | + for filename in sorted(os.listdir(LOCALE_DIR)): |
| 150 | + if not filename.endswith(".json"): |
| 151 | + continue |
| 152 | + language = filename[:-5] |
| 153 | + if language == SOURCE_LANGUAGE: |
106 | 154 | continue |
107 | 155 | try: |
108 | | - locale_strs = get_locale_strings(locale_filename) |
109 | | - except json.decoder.JSONDecodeError: |
110 | | - import traceback |
111 | | - traceback.print_exc() |
112 | | - logging.error(f"Error decoding {locale_filename}") |
| 156 | + strings = load_locale(language) |
| 157 | + except json.JSONLibraryException: |
| 158 | + logging.error(f"Error decoding {filename}") |
113 | 159 | continue |
114 | 160 |
|
115 | | - # Add new keys |
116 | | - new_keys = [] |
117 | | - for key in current_strs: |
118 | | - if key not in locale_strs: |
119 | | - new_keys.append(key) |
120 | | - locale_strs[key] = "" |
121 | | - print(f"{locale_filename[7:-5]}'s new str: {len(new_keys)}") |
122 | | - # Add (NOT USED) to invalid keys |
123 | | - for key in locale_strs: |
124 | | - if key not in current_strs: |
125 | | - locale_strs[key] = "(🔴NOT USED)" + locale_strs[key] |
126 | | - print(f"{locale_filename[7:-5]}'s invalid str: {len(locale_strs) - len(current_strs)}") |
127 | | - |
128 | | - locale_strs = sort_strings(locale_strs) |
129 | | - |
130 | | - if auto: |
131 | | - tasks = [] |
132 | | - non_translated_keys = [] |
133 | | - for key in locale_strs: |
134 | | - if locale_strs[key] == "": |
135 | | - non_translated_keys.append(key) |
136 | | - tasks.append(auto_translate(key, locale_filename[7:-5])) |
137 | | - results = await asyncio.gather(*tasks) |
138 | | - for key, result in zip(non_translated_keys, results): |
139 | | - locale_strs[key] = "(🟡REVIEW NEEDED)" + result |
140 | | - print(f"{locale_filename[7:-5]}'s auto translated str: {len(non_translated_keys)}") |
141 | | - |
142 | | - with open(locale_filename, 'w', encoding='utf-8') as f: |
143 | | - json.dump(locale_strs, f, ensure_ascii=False, indent=4) |
| 161 | + new_keys = [i for i in sorted(used) if i not in strings] |
| 162 | + for identifier in new_keys: |
| 163 | + strings[identifier] = UNTRANSLATED |
| 164 | + stale = [i for i in strings if i not in used and i in source] |
| 165 | + for identifier in stale: |
| 166 | + if NOT_USED not in strings[identifier]: |
| 167 | + strings[identifier] = NOT_USED + strings[identifier] |
| 168 | + print(f"{language}: {len(new_keys)} new, {len(stale)} no longer used") |
| 169 | + |
| 170 | + if auto and new_keys: |
| 171 | + headers = {"Content-Type": "application/json", |
| 172 | + "Authorization": f"Bearer {api_key}"} |
| 173 | + async with aiohttp.ClientSession() as session: |
| 174 | + results = await asyncio.gather(*[ |
| 175 | + auto_translate(session, url, headers, source[i], language) |
| 176 | + for i in new_keys |
| 177 | + ]) |
| 178 | + for identifier, result in zip(new_keys, results): |
| 179 | + strings[identifier] = REVIEW_NEEDED + result |
| 180 | + print(f"{language}: {len(new_keys)} auto-translated, review needed") |
| 181 | + |
| 182 | + save_locale(language, sort_strings(strings)) |
144 | 183 |
|
145 | 184 |
|
146 | 185 | if __name__ == "__main__": |
147 | | - auto = False |
148 | | - if len(sys.argv) > 1 and sys.argv[1] == "--auto": |
149 | | - auto = True |
150 | | - asyncio.run(main(auto)) |
| 186 | + asyncio.run(main(auto=len(sys.argv) > 1 and sys.argv[1] == "--auto")) |
0 commit comments