├── requirements.txt ├── LICENSE ├── README.zh.md ├── README.md ├── .gitignore ├── xcstrings.py ├── xcstrings_DeepLX.py └── xcstrings_Gemini.py /requirements.txt: -------------------------------------------------------------------------------- 1 | googletrans==4.0.0rc1 2 | opencc-python-reimplemented 3 | google-generativeai -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 borrring 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.zh.md: -------------------------------------------------------------------------------- 1 | # XCStrings本地化工具 2 | 3 | **注意:谷歌提供的 Gemini 的免费服务近期负载巨大,请求失败率非常高。如频繁遇到错误,建议切换到其他翻译服务,或者等待谷歌未来开放 Gemini 付费接口后再使用。** 4 | 5 | > 20240322 最新更新 6 | > 支持使用[DeepLX](https://github.com/OwO-Network/DeepLX)翻译 7 | > 使用`xcstrings_DeepLX.py`前需要配置 8 | > ``` 9 | > brew tap owo-network/brew 10 | > brew install deeplx 11 | > brew services start owo-network/brew/deeplx 12 | > 13 | > # Update to the latest version 14 | > brew update 15 | > brew upgrade deeplx 16 | > brew services restart owo-network/brew/deeplx 17 | > 18 | > # View the currently installed version 19 | > brew list --versions deeplx 20 | > ``` 21 | 22 | 此工具帮助自动编译iOS应用的语言。它使用了Google的翻译服务。主要有两种脚本:`xcstrings.py`和`xcstrings_Gemini.py`. 23 | 24 | ## 安装 25 | 26 | 要设置和运行此项目,您需要安装一些要求。使用以下命令: 27 | 28 | `pip3 install -r requirements.txt` 29 | 30 | 其中requirements.txt文件应包含以下内容: 31 | > googletrans==4.0.0rc1 32 | > opencc-python-reimplemented 33 | > google-generativeai 34 | 35 | 36 | 1. 设置Google API 密钥: 37 | 38 | 您可以在这里 [Google AI Studio](https://makersuite.google.com) 找到相关的设置引导。 39 | 40 | 对于“The caller does not have permission”的错误,也许您可以在[Gemini, MakerSuite, API Keys, and "The caller does not have permission"](https://freedium.cfd/https://medium.com/@afirstenberg/gemini-makersuite-api-keys-and-the-caller-does-not-have-permission-c75bedcbe886)找到帮助 41 | 42 | 替代 `xcstrings_Gemini.py` 中的 `GOOGLE_API_KEY` 为你的API密钥。 43 | 44 | 45 | ## 运行脚本 46 | 47 | 首先,导航至您所选择的文件夹,然后使用git克隆项目: 48 | 49 | git clone 50 | cd 51 | 52 | 53 | 运行脚本: 54 | 55 | ```bash 56 | python3 xcstrings.py 57 | ``` 58 | 或 59 | ```bash 60 | python3 xcstrings_Gemini.py 61 | ``` 62 | 63 | ## 使用 64 | 65 | 当程序要求您输入 .xcstrings 文件的路径时,按提示操作: 66 | 67 | > Enter the string Catalog (.xcstrings) file path: 68 | 69 | 然后,它将会自动的加载字符串的键值,检测源语言,将字符串编译为目标语言,然后保存在JSON文件中的“本地化”项目中。 70 | 71 | ## 参与贡献 72 | 73 | 欢迎参与贡献! 请创建问题或打开PR。 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # XCStrings Localization Tool 2 | 3 | This document is also available in [Chinese (点击此处查看中文版本)](README.zh.md). 4 | 5 | **Note: The free Gemini service provided by Google has been under heavy load recently, with a very high request failure rate. If you encounter frequent errors, we recommend switching to another translation service or waiting for Google to open the paid Gemini interface in the future.** 6 | 7 | > Latest update in 20240322 8 | > Support the use of [DeepLX](https://github.com/OwO-Network/DeepLX) translation 9 | > Configuration is required before using `xcstrings_DeepLX.py` 10 | > ``` 11 | > brew tap owo-network/brew 12 | > brew install deeplx 13 | > brew services start owo-network/brew/deeplx 14 | > 15 | > # Update to the latest version 16 | > brew update 17 | > brew upgrade deeplx 18 | > brew services restart owo-network/brew/deeplx 19 | > 20 | > # View the currently installed version 21 | > brew list --versions deeplx 22 | > ``` 23 | 24 | This tool helps automate the translation of iOS apps. It uses Google Translation for this purpose. There are two main scripts: `xcstrings.py` and `xcstrings_Gemini.py`. 25 | 26 | ## Installation 27 | 28 | To setup and run this project, you need to install some requirements. Use the following command: 29 | 30 | `pip3 install -r requirements.txt` 31 | 32 | The requirements.txt file should contain: 33 | > googletrans==4.0.0rc1 34 | > opencc-python-reimplemented 35 | > google-generativeai 36 | 37 | 38 | 1. Setup Google API Key: 39 | 40 | You can find the setup guide here [Google AI Studio](https://makersuite.google.com). 41 | 42 | For the error of "The caller does not have permission", maybe you can find help at [Gemini, MakerSuite, API Keys, and "The caller does not have permission"](https://freedium.cfd/https://medium.com/@afirstenberg/gemini-makersuite-api-keys-and-the-caller-does-not-have-permission-c75bedcbe886).(对于“The caller does not have permission”的错误,也许您可以在[Gemini, MakerSuite, API Keys, and "The caller does not have permission"](https://freedium.cfd/https://medium.com/@afirstenberg/gemini-makersuite-api-keys-and-the-caller-does-not-have-permission-c75bedcbe886)找到帮助) 43 | 44 | Replace `GOOGLE_API_KEY` in `xcstrings_Gemini.py` with your API key. 45 | 46 | 47 | ## Running The Scripts 48 | 49 | First, navigate to your preferred directory and clone the project using git: 50 | 51 | git clone 52 | cd 53 | 54 | 55 | Run the scripts: 56 | 57 | ```bash 58 | python3 xcstrings.py 59 | ``` 60 | or 61 | ```bash 62 | python3 xcstrings_Gemini.py 63 | ``` 64 | 65 | ## Usage 66 | 67 | The programs will ask you to enter the path to your .xcstrings file. 68 | 69 | > Enter the string Catalog (.xcstrings) file path: 70 | 71 | Then, it will automatically load the keys of strings, detect the source language, translate the strings to the target language, and save the "localizations" in the JSON file. 72 | 73 | ## Contributing 74 | 75 | Contributions are welcome! Please create an issue or open a PR. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | .DS_Store 162 | -------------------------------------------------------------------------------- /xcstrings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import datetime 4 | import time 5 | 6 | # pip install --upgrade googletrans==4.0.0rc1 7 | from googletrans import Translator 8 | # pip install opencc-python-reimplemented 9 | from opencc import OpenCC 10 | 11 | openCC = OpenCC('s2t') 12 | 13 | # Global variables 14 | is_info_plist = False 15 | LANGUAGE_IDENTIFIERS = ['en', 'zh-Hans', 'zh-Hant'] 16 | LANGUAGE_IDENTIFIERS_FOR_GOOGLE = { 17 | 'zh-Hans': 'zh-CN', 18 | 'zh-Hant': 'zh-TW', 19 | 'zh-HK': 'zh-TW', 20 | 'pt-PT': 'pt' 21 | } 22 | 23 | # Use automatic detection source language for translation 24 | def translate_string(string, target_language): 25 | translator = Translator() 26 | 27 | if target_language not in LANGUAGE_IDENTIFIERS_FOR_GOOGLE: 28 | dest = target_language 29 | else: 30 | dest = LANGUAGE_IDENTIFIERS_FOR_GOOGLE[target_language] 31 | 32 | try: 33 | source_language = translator.detect(string).lang 34 | if source_language == dest: 35 | return string 36 | 37 | translation = translator.translate(string, dest=dest) 38 | except Exception as e: 39 | print(e) 40 | print("Translation timeout, retrying after 1 seconds...") 41 | time.sleep(1) 42 | return translate_string(string, target_language) 43 | 44 | print(f"{target_language}: {translation.text}") 45 | return translation.text 46 | 47 | def main(): 48 | # Get all the keys of strings 49 | with open(json_path, "r", encoding="utf-8") as f: 50 | json_data = json.load(f) 51 | strings_keys = list(json_data["strings"].keys()) 52 | 53 | print(f"\nFound {len(strings_keys)} keys\n") 54 | 55 | # Traverse all keys 56 | for key_index, key in enumerate(strings_keys): 57 | if not key: 58 | continue 59 | # Get the current time 60 | now = datetime.datetime.now() 61 | # Format the current time 62 | now_str = now.strftime("%Y-%m-%d %H:%M:%S") 63 | print(f"[{now_str}]\n", f"🔥{key_index + 1}/{len(strings_keys)}: {key}") 64 | 65 | strings = json_data["strings"][key] 66 | 67 | # The strings field is empty. 68 | if not strings: 69 | strings = {"extractionState": "manual", "localizations": {}} 70 | 71 | # The localizations field is empty 72 | if "localizations" not in strings: 73 | strings["localizations"] = {} 74 | 75 | localizations = strings["localizations"] 76 | 77 | for language in LANGUAGE_IDENTIFIERS: 78 | # Determine whether localizations contains the corresponding language key 79 | if language not in localizations: 80 | if not is_info_plist: 81 | source_language = json_data["sourceLanguage"] 82 | # If not included, use Google Gemini to fill in "localizations" after translation. 83 | if source_language == "zh-Hans": 84 | source_string = key 85 | else: 86 | source_string = ( 87 | localizations["en"]["stringUnit"]["value"] 88 | if "en" in localizations 89 | else key 90 | ) 91 | if language == source_language: 92 | translated_string = source_string 93 | else: 94 | if source_language == "zh-Hans" and language == "zh-Hant": 95 | translated_string = openCC.convert(source_string) 96 | else: 97 | translated_string = translate_string(source_string, language) 98 | 99 | localizations[language] = { 100 | "stringUnit": { 101 | "state": "translated", 102 | "value": translated_string, 103 | } 104 | } 105 | else: 106 | source_language = json_data["sourceLanguage"] 107 | if source_language not in localizations: 108 | print("String is empty in source language") 109 | continue 110 | else: 111 | if source_language == "zh-Hans": 112 | source_string = localizations[source_language]["stringUnit"]["value"] 113 | else: 114 | source_string = ( 115 | localizations["en"]["stringUnit"]["value"] 116 | if "en" in localizations 117 | else key 118 | ) 119 | if source_language == "zh-Hans" and language == "zh-Hant": 120 | translated_string = openCC.convert(source_string) 121 | else: 122 | translated_string = translate_string(source_string, language) 123 | localizations[language] = { 124 | "stringUnit": { 125 | "state": "translated", 126 | "value": translated_string, 127 | } 128 | } 129 | else: 130 | print(f"{language} has been translated") 131 | 132 | strings["localizations"] = localizations 133 | json_data["strings"][key] = strings 134 | 135 | # Save the modified JSON file every time to prevent flashback. 136 | with open(json_path, "w", encoding='utf-8') as f: 137 | json.dump(json_data, ensure_ascii=False, fp=f, indent=4) 138 | 139 | 140 | def is_infoplist(json_path): 141 | filename, _ = os.path.splitext(os.path.basename(json_path)) 142 | return filename == 'InfoPlist' 143 | 144 | if __name__ == "__main__": 145 | # Input json_path from terminal 146 | json_path = input("Enter the string Catalog (.xcstrings) file path:\n").strip(' "\'') 147 | is_info_plist = is_infoplist(json_path) 148 | main() -------------------------------------------------------------------------------- /xcstrings_DeepLX.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import datetime 4 | import time 5 | import re 6 | from argparse import ArgumentParser 7 | import requests 8 | 9 | # pip install opencc-python-reimplemented 10 | from opencc import OpenCC 11 | 12 | LANGUAGE_IDENTIFIERS = [ 13 | "en", 14 | "zh-Hans", 15 | "zh-Hant", 16 | ] # , 'ar', 'de', 'es', 'fr', 'ja', 'ko', 'pt-PT', 'ru', 'tr'] 17 | 18 | 19 | openCC = OpenCC("s2t") 20 | 21 | deeplx_api = "http://127.0.0.1:1188/translate" 22 | 23 | # Global variables 24 | is_info_plist = False 25 | 26 | LANGUAGE_IDENTIFIERS_FOR_DEEPL = { 27 | "zh-Hans": "ZH", 28 | "pt-PT": "PT", 29 | "en": "EN", 30 | "ar": "AR", 31 | "de": "DE", 32 | "bg": "BG", 33 | "cs": "CS", 34 | "da": "DA", 35 | "el": "EL", 36 | "en-GB": "EN-GB", 37 | "en-US": "EN-US", 38 | "es": "ES", 39 | "et": "ET", 40 | "fi": "FI", 41 | "fr": "FR", 42 | "hu": "HU", 43 | "id": "ID", 44 | "it": "IT", 45 | "ja": "JA", 46 | "ko": "KO", 47 | "lt": "LT", 48 | "lv": "LV", 49 | "nb": "NB", 50 | "nl": "NL", 51 | "pl": "PL", 52 | "pt-BR": "PT-BR", 53 | "ro": "RO", 54 | "ru": "RU", 55 | "sk": "SK", 56 | "sl": "SL", 57 | "sv": "SV", 58 | "tr": "TR", 59 | "uk": "UK", 60 | } 61 | 62 | 63 | # Use automatic detection source language for translation 64 | def translate_string(string, target_language): 65 | if target_language not in LANGUAGE_IDENTIFIERS_FOR_DEEPL: 66 | dest = target_language 67 | else: 68 | dest = LANGUAGE_IDENTIFIERS_FOR_DEEPL[target_language] 69 | 70 | json_data = {"text": string, "target_lang": dest} 71 | 72 | try: 73 | response = requests.post( 74 | url=deeplx_api, 75 | json=json_data 76 | ) 77 | response.raise_for_status() # Raise an error for HTTP error responses 78 | data_parsed = response.json() 79 | translated_text = data_parsed.get("data") 80 | if not translated_text: # Check if translated_text is empty or None 81 | raise ValueError("No translation data found in the response.") 82 | print(f"{target_language}: {translated_text}") 83 | return translated_text 84 | except Exception as e: 85 | print(f"{type(e).__name__}: {e}") 86 | print("Translation timeout, retrying after 1 seconds...") 87 | time.sleep(1) 88 | return translate_string(string, target_language) 89 | 90 | 91 | def main(): 92 | # Get all the keys of strings 93 | with open(json_path, "r", encoding="utf-8") as f: 94 | json_data = json.load(f) 95 | strings_keys = list(json_data["strings"].keys()) 96 | 97 | print(f"\nFound {len(strings_keys)} keys\n") 98 | 99 | # Traverse all keys 100 | for key_index, key in enumerate(strings_keys): 101 | if not key: 102 | continue 103 | # Get the current time 104 | now = datetime.datetime.now() 105 | # Format the current time 106 | now_str = now.strftime("%Y-%m-%d %H:%M:%S") 107 | print(f"[{now_str}]\n", f"🔥{key_index + 1}/{len(strings_keys)}: {key}") 108 | 109 | strings = json_data["strings"][key] 110 | 111 | # The strings field is empty. 112 | if not strings: 113 | strings = {"extractionState": "manual", "localizations": {}} 114 | 115 | # The localizations field is empty 116 | if "localizations" not in strings: 117 | strings["localizations"] = {} 118 | 119 | localizations = strings["localizations"] 120 | 121 | for language in LANGUAGE_IDENTIFIERS: 122 | # Determine whether localizations contains the corresponding language key 123 | if language not in localizations: 124 | if not is_info_plist: 125 | source_language = json_data["sourceLanguage"] 126 | # If not included, use Google Gemini to fill in "localizations" after translation. 127 | if source_language == "zh-Hans": 128 | source_string = key 129 | else: 130 | source_string = ( 131 | localizations["en"]["stringUnit"]["value"] 132 | if "en" in localizations 133 | else key 134 | ) 135 | if language == source_language: 136 | translated_string = source_string 137 | else: 138 | if source_language == "zh-Hans" and language == "zh-Hant": 139 | translated_string = openCC.convert(source_string) 140 | else: 141 | translated_string = translate_string( 142 | source_string, language 143 | ) 144 | 145 | localizations[language] = { 146 | "stringUnit": { 147 | "state": "translated", 148 | "value": translated_string, 149 | } 150 | } 151 | else: 152 | source_language = json_data["sourceLanguage"] 153 | if source_language not in localizations: 154 | print("String is empty in source language") 155 | continue 156 | else: 157 | if source_language == "zh-Hans": 158 | source_string = localizations[source_language][ 159 | "stringUnit" 160 | ]["value"] 161 | else: 162 | source_string = ( 163 | localizations["en"]["stringUnit"]["value"] 164 | if "en" in localizations 165 | else key 166 | ) 167 | if source_language == "zh-Hans" and language == "zh-Hant": 168 | translated_string = openCC.convert(source_string) 169 | else: 170 | translated_string = translate_string( 171 | source_string, language 172 | ) 173 | localizations[language] = { 174 | "stringUnit": { 175 | "state": "translated", 176 | "value": translated_string, 177 | } 178 | } 179 | else: 180 | print(f"{language} has been translated") 181 | 182 | # strings["localizations"] = {} 183 | strings["localizations"] = localizations 184 | json_data["strings"][key] = strings 185 | 186 | # Save the modified JSON file every time to prevent flashback. 187 | with open(json_path, "w", encoding="utf-8") as f: 188 | json.dump(json_data, ensure_ascii=False, fp=f, indent=4) 189 | 190 | 191 | def is_infoplist(json_path): 192 | filename, _ = os.path.splitext(os.path.basename(json_path)) 193 | return filename == "InfoPlist" 194 | 195 | 196 | if __name__ == "__main__": 197 | # Input json_path from terminal 198 | json_path = input("Enter the string Catalog (.xcstrings) file path:\n").strip( 199 | " \"'" 200 | ) 201 | is_info_plist = is_infoplist(json_path) 202 | main() 203 | -------------------------------------------------------------------------------- /xcstrings_Gemini.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import datetime 4 | import time 5 | import threading 6 | import re 7 | from collections import defaultdict 8 | import requests 9 | import random 10 | 11 | # pip install opencc-python-reimplemented 12 | from opencc import OpenCC 13 | 14 | GOOGLE_API_KEY='' 15 | 16 | if not GOOGLE_API_KEY: 17 | GOOGLE_API_KEY = input("Enter your Google API Key for Gemini translations:\n").strip() 18 | if not GOOGLE_API_KEY: 19 | raise ValueError("Don't forget to set your GOOGLE_API_KEY in Google AI Studio (https://makersuite.google.com) for Gemini translations!") 20 | 21 | openCC = OpenCC('s2t') 22 | 23 | # Global variables 24 | is_info_plist = False 25 | LANGUAGE_IDENTIFIERS = ['en', 'zh-Hans', 'zh-Hant']#, 'ja', 'ko', 'ar', 'de', 'es', 'fr', 'ja', 'ko', 'pt-PT', 'ru', 'tr'] 26 | BATCH_SIZE = 4000 27 | SEPARATOR = "||" 28 | APPCATEGORY = "" 29 | 30 | def exponential_backoff(retry_count, base_delay=1, max_delay=60): 31 | exponential_delay = min(base_delay * (2 ** retry_count), max_delay) 32 | actual_delay = exponential_delay + random.uniform(0, 1) # Add jitter 33 | return actual_delay 34 | 35 | def print_elapsed_time(start_time, stop_event): 36 | while not stop_event.is_set(): 37 | elapsed_time = time.time() - start_time 38 | print(f"Elapsed time: {elapsed_time:.2f} seconds") 39 | time.sleep(1) 40 | 41 | # Use automatic detection source language for translation 42 | def translate_batch(strings, target_language): 43 | time.sleep(1) 44 | prompt = f"""You are a professional localization service provider specializing in translating content for specific languages, cultures, and categories. 45 | For example: 46 | 47 | Hello{SEPARATOR}World{SEPARATOR}谷歌 48 | 49 | The translation is: 50 | 51 | 你好{SEPARATOR}世界{SEPARATOR}谷歌 52 | 53 | 54 | Translate the following content to {target_language} Language""" 55 | 56 | if APPCATEGORY: 57 | prompt += f" for the app categorized as a {APPCATEGORY}." 58 | 59 | prompt += f""" 60 | Each item is separated by {SEPARATOR}. Please keep the same structure in your response. 61 | 62 | {SEPARATOR.join(strings)}""" 63 | 64 | headers = { 65 | 'Content-Type': 'application/json', 66 | } 67 | 68 | params = { 69 | 'key': GOOGLE_API_KEY, 70 | } 71 | 72 | json_data = { 73 | 'contents': [ 74 | { 75 | 'parts': [ 76 | { 77 | 'text': prompt, 78 | }, 79 | ], 80 | }, 81 | ], 82 | } 83 | 84 | retry_count = 0 85 | while True: 86 | try: 87 | start_time = time.time() 88 | stop_event = threading.Event() 89 | timer_thread = threading.Thread(target=print_elapsed_time, args=(start_time, stop_event)) 90 | timer_thread.start() 91 | print("Starting translation request...") 92 | response = requests.post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent', 93 | params=params, headers=headers, json=json_data) 94 | stop_event.set() 95 | timer_thread.join() 96 | response.raise_for_status() 97 | print("Request successful!") 98 | data_parsed = response.json() 99 | print("Response data:", data_parsed) 100 | result = get_text_from_json(data_parsed) 101 | match = re.search('(.*?)', result, re.DOTALL) 102 | if match: 103 | translated_text = match.group(1).strip() 104 | return translated_text.split(SEPARATOR) 105 | else: 106 | continue 107 | except Exception as e: 108 | stop_event.set() 109 | timer_thread.join() 110 | print(f'{type(e).__name__}: {e}') 111 | retry_count += 1 112 | delay = exponential_backoff(retry_count) 113 | print(f"Translation timeout, retrying after {delay:.2f} seconds...") 114 | time.sleep(delay) 115 | 116 | # Function to safely get the 'text' from parsed JSON data 117 | def get_text_from_json(data): 118 | try: 119 | # Ensure 'candidates' is a list and not empty 120 | if (isinstance(data.get('candidates'), list) and 121 | len(data['candidates']) > 0): 122 | 123 | content = data['candidates'][0].get('content') 124 | # Ensure 'content' has a 'parts' list and it's not empty 125 | if content and isinstance(content.get('parts'), list) and len(content['parts']) > 0: 126 | 127 | text = content['parts'][0].get('text') 128 | # Return text if it's a string, otherwise, return a default string or raise an error 129 | return text if isinstance(text, str) else 'No text found' 130 | # If checks fail, return a default value or raise an error 131 | return 'No text found' 132 | except Exception as e: 133 | print(f'Error retrieving text: {e}') 134 | # Handle the exception as needed (e.g., return a default value, raise an error, log the issue, etc.) 135 | return 'No text found' 136 | 137 | def process_others_translations(json_data, language, keys, strings_to_translate_list): 138 | translated_strings = translate_batch(strings_to_translate_list, language) 139 | for key, translated in zip(keys, translated_strings): 140 | print(f"{language}: {key} ==> {translated}") 141 | json_data["strings"][key]["localizations"][language] = { 142 | "stringUnit": { 143 | "state": "translated", 144 | "value": translated, 145 | } 146 | } 147 | 148 | def process_english_translations(json_data, english_strings, strings_needing_english, strings_to_translate): 149 | english_translations = translate_batch(english_strings, "en") 150 | for (key, original), translated in zip(strings_needing_english, english_translations): 151 | print(f"en: {key} ==> {translated}") 152 | json_data["strings"][key]["localizations"]["en"] = { 153 | "stringUnit": { 154 | "state": "translated", 155 | "value": translated, 156 | } 157 | } 158 | # Add the English translation to other language queues 159 | for language in LANGUAGE_IDENTIFIERS: 160 | if language not in ["en", "zh-Hans", "zh-Hant"]: 161 | strings_to_translate[(language, key)] = translated 162 | 163 | def clear(): 164 | # for windows 165 | if os.name == 'nt': 166 | _ = os.system('cls') 167 | # for mac and linux(here, os.name is 'posix') 168 | else: 169 | _ = os.system('clear') 170 | 171 | def is_info_plist(file_path): 172 | return os.path.basename(file_path).lower() == 'infoplist.xcstrings' 173 | 174 | def main(): 175 | try: 176 | # Get all the keys of strings 177 | with open(json_path, "r", encoding="utf-8") as f: 178 | json_data = json.load(f) 179 | except Exception as e: 180 | print(f"Error decoding JSON data: {e}") 181 | return 182 | 183 | # Clearing the Screen 184 | clear() 185 | 186 | if not APPCATEGORY: 187 | print(f"Begin the localization process at path: \n{json_path}") 188 | else: 189 | print(f"Begin the localization process for the app categorized as a {APPCATEGORY} at path: \n{json_path}") 190 | 191 | global LANGUAGE_IDENTIFIERS 192 | # Get language identifiers from the user 193 | language_input = input("Enter the language codes to translate into (comma-separated), e.g., 'en, zh-Hans, zh-Hant' (default is ['en', 'zh-Hans', 'zh-Hant']):\n").strip() 194 | if language_input: 195 | LANGUAGE_IDENTIFIERS = [lang.strip() for lang in language_input.split(',')] 196 | else: 197 | print("No languages entered. Using default languages ['en', 'zh-Hans', 'zh-Hant'].") 198 | LANGUAGE_IDENTIFIERS = ['en', 'zh-Hans', 'zh-Hant'] 199 | 200 | global is_info_plist 201 | is_info_plist_file = is_info_plist(json_path) 202 | strings_to_translate = {} 203 | strings_needing_english = [] 204 | source_language = json_data["sourceLanguage"] 205 | 206 | mark_untranslated_manual = input("Do you want to mark untranslated parts as 'extractionState': 'manual'? (y/n, default is 'n'):\n").strip().lower() 207 | if mark_untranslated_manual == 'y': 208 | add_extraction_state = True 209 | else: 210 | add_extraction_state = False 211 | 212 | for key, strings in json_data["strings"].items(): 213 | if "comment" in strings and "ignore xcstrings" in strings["comment"] or \ 214 | ("shouldTranslate" in strings and strings["shouldTranslate"] == False): 215 | continue 216 | if not strings: 217 | if add_extraction_state: 218 | strings = {"extractionState": "manual", "localizations": {}} 219 | else: 220 | strings = {"localizations": {}} 221 | 222 | if "localizations" not in strings: 223 | strings["localizations"] = {} 224 | 225 | json_data["strings"][key] = strings 226 | 227 | localizations = strings["localizations"] 228 | 229 | if is_info_plist_file: 230 | # if key == "CFBundleName": 231 | # continue 232 | # else: 233 | if source_language not in localizations: 234 | print(f"Error: Source language '{source_language}' not found in InfoPlist.xcstrings") 235 | return 236 | else: 237 | if source_language == "zh-Hans": 238 | source_string = localizations[source_language]["stringUnit"]["value"] 239 | else: 240 | source_string = ( 241 | localizations["en"]["stringUnit"]["value"] 242 | if "en" in localizations 243 | else key 244 | ) 245 | 246 | strings_needing_english.append((key, source_string)) 247 | else: 248 | if "en" in localizations: 249 | source_string = localizations["en"]["stringUnit"]["value"] 250 | elif source_language == "zh-Hans": 251 | source_string = key 252 | strings_needing_english.append((key, source_string)) 253 | else: 254 | source_string = localizations[source_language]["stringUnit"]["value"] if source_language in localizations else key 255 | strings_needing_english.append((key, source_string)) 256 | 257 | for language in LANGUAGE_IDENTIFIERS: 258 | if language not in localizations: 259 | if language == source_language: 260 | translated_string = source_string 261 | elif source_language == "zh-Hans" and language == "zh-Hant": 262 | translated_string = OpenCC('s2t').convert(source_string) 263 | elif language == "en" and source_language != "en": 264 | continue # We'll handle English translations separately 265 | else: 266 | strings_to_translate[(language, key)] = source_string 267 | continue 268 | 269 | localizations[language] = { 270 | "stringUnit": { 271 | "state": "translated", 272 | "value": translated_string, 273 | } 274 | } 275 | else: 276 | print(f"{language}: {{{key}: {source_string}}} has been translated") 277 | 278 | # Handle English translations first 279 | if strings_needing_english: 280 | english_strings = [s[1] for s in strings_needing_english] 281 | 282 | # Loop through the strings in chunks 283 | start_index = 0 284 | while start_index < len(english_strings): 285 | # Determine the end index for the current chunk 286 | combined_string = "" 287 | end_index = start_index 288 | while end_index < len(english_strings) and len(combined_string + english_strings[end_index] + SEPARATOR) <= BATCH_SIZE: 289 | combined_string += english_strings[end_index] + SEPARATOR 290 | end_index += 1 291 | 292 | # Remove the trailing separator 293 | combined_string = combined_string.rstrip(SEPARATOR) 294 | # Process the current chunk of translations 295 | process_english_translations(json_data, english_strings[start_index:end_index], strings_needing_english[start_index:end_index], strings_to_translate) 296 | # Update the start index for the next chunk 297 | start_index = end_index 298 | 299 | 300 | # Process any remaining strings for each language 301 | if strings_to_translate: 302 | languages = set(lang for lang, _ in strings_to_translate.keys()) 303 | for language in languages: 304 | lang_strings = {key: value for (lang, key), value in strings_to_translate.items() if lang == language} 305 | if not lang_strings: 306 | continue 307 | 308 | keys = list(lang_strings.keys()) 309 | strings_to_translate_list = list(lang_strings.values()) 310 | # Loop through the strings in chunks 311 | start_index = 0 312 | while start_index < len(strings_to_translate_list): 313 | # Determine the end index for the current chunk 314 | combined_string = "" 315 | end_index = start_index 316 | while end_index < len(strings_to_translate_list) and len(combined_string + strings_to_translate_list[end_index] + SEPARATOR) <= BATCH_SIZE: 317 | combined_string += strings_to_translate_list[end_index] + SEPARATOR 318 | end_index += 1 319 | 320 | # Remove the trailing separator 321 | combined_string = combined_string.rstrip(SEPARATOR) 322 | # Process the current chunk of translations 323 | process_others_translations(json_data, language, keys[start_index:end_index], strings_to_translate_list[start_index:end_index]) 324 | # Update the start index for the next chunk 325 | start_index = end_index 326 | 327 | # Save the modified JSON file 328 | with open(json_path, "w", encoding='utf-8') as f: 329 | json.dump(json_data, ensure_ascii=False, fp=f, indent=4) 330 | 331 | if __name__ == "__main__": 332 | # Input json_path from terminal 333 | json_path = input("Enter the string Catalog (.xcstrings) file path:\n").strip(' "\'') 334 | json_path = json_path.replace('\\ ', ' ') 335 | # 检查文件是否存在 336 | if os.path.exists(json_path): 337 | print(f"File found at: {json_path}") 338 | # 继续处理文件 339 | APPCATEGORY = input("Enter the app category or name for precise Gemini translations:\n").strip(' "\'') 340 | main() 341 | else: 342 | print(f"Error: No such file or directory: '{json_path}'") 343 | --------------------------------------------------------------------------------