Skip to content

Commit 2c7d530

Browse files
committed
replaced hard-coded text in source with identifiers and updated localization throughout
1 parent d5b9d65 commit 2c7d530

29 files changed

Lines changed: 4597 additions & 1863 deletions

‎ChuanhuChatbot.py‎

Lines changed: 104 additions & 116 deletions
Large diffs are not rendered by default.

‎locale/en_US.json‎

Lines changed: 568 additions & 286 deletions
Large diffs are not rendered by default.

‎locale/extract_locale.py‎

Lines changed: 149 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,150 +1,186 @@
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+
"""
112
import asyncio
213
import logging
314
import os
415
import re
516
import sys
617

718
import aiohttp
8-
import commentjson
919
import commentjson as json
1020

1121
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
1222

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)"
1728

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)
1831

19-
def get_current_strings():
20-
pattern = r'i18n\s*\(\s*["\']([^"\']*(?:\)[^"\']*)?)["\']\s*\)'
2132

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))
3739
else:
38-
fixed_data[key] = value
40+
flat[dotted] = value
41+
return flat
42+
3943

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
4153

4254

43-
def get_locale_strings(filename):
55+
def load_locale(language):
56+
path = os.path.join(LOCALE_DIR, f"{language}.json")
4457
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))
4760
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):
77106
payload = {
78-
"model": "gpt-3.5-turbo",
107+
"model": "gpt-4o-mini",
79108
"messages": [
80109
{
81110
"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+
),
83119
},
84-
{"role": "user", "content": f"{str}"}
120+
{"role": "user", "content": text},
85121
],
86122
}
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"]
92126

93127

94128
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:
106154
continue
107155
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}")
113159
continue
114160

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))
144183

145184

146185
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

Comments
 (0)