feat(scripts): add Argos Translate script for JSON localization and new APK signing script

This commit is contained in:
Ivan
2026-04-17 23:28:53 -05:00
parent 250ea36e4c
commit 034a0a6ede
3 changed files with 444 additions and 6 deletions
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
"""Argos Translate JSON localization script.
This script provides an automated workflow to translate JSON localization files
(such as `en.json`) to target languages using Argos Translate. It ensures that
interpolated variables (e.g., `{count}`, `{status}`) are preserved and not
altered during the translation process.
Requirements:
- argostranslate (pip install argostranslate)
Usage:
python scripts/argos_translate.py --from en --to zh --input locales/en.json --output locales/zh.json
You can also use environment variables instead of CLI flags:
ARGOS_FROM_LANG="en"
ARGOS_TO_LANG="zh"
ARGOS_INPUT_FILE="locales/en.json"
ARGOS_OUTPUT_FILE="locales/zh.json"
"""
import argparse
import json
import os
import re
import sys
import time
# ANSI color codes for terminal output
class Colors:
HEADER = "\033[95m"
OKBLUE = "\033[94m"
OKCYAN = "\033[96m"
OKGREEN = "\033[92m"
WARNING = "\033[93m"
FAIL = "\033[91m"
ENDC = "\033[0m"
BOLD = "\033[1m"
UNDERLINE = "\033[4m"
def print_info(msg):
print(f"{Colors.OKCYAN}[INFO]{Colors.ENDC} {msg}")
def print_success(msg):
print(f"{Colors.OKGREEN}[SUCCESS]{Colors.ENDC} {msg}")
def print_warning(msg):
print(f"{Colors.WARNING}[WARNING]{Colors.ENDC} {msg}")
def print_error(msg):
print(f"{Colors.FAIL}[ERROR]{Colors.ENDC} {msg}")
try:
import argostranslate.package
import argostranslate.translate
except ImportError:
print_error("The 'argostranslate' module is not installed.")
print_info("Please install it using: pip install argostranslate")
sys.exit(1)
def ensure_package_installed(from_code, to_code):
"""Ensure the translation package from `from_code` to `to_code` is installed.
If not installed, attempts to download and install it automatically.
"""
installed = argostranslate.translate.get_installed_languages()
installed_dict = {lang.code: lang for lang in installed}
from_lang = installed_dict.get(from_code)
to_lang = installed_dict.get(to_code)
if not from_lang or not to_lang or to_lang not in from_lang.translations_from:
print_warning(
f"Translation package {from_code} -> {to_code} not found. Attempting to install..."
)
try:
argostranslate.package.update_package_index()
available_packages = argostranslate.package.get_available_packages()
pkg_to_install = None
for pkg in available_packages:
if pkg.from_code == from_code and pkg.to_code == to_code:
pkg_to_install = pkg
break
if pkg_to_install:
print_info(f"Downloading package: {pkg_to_install}")
argostranslate.package.install_from_path(pkg_to_install.download())
print_success(
f"Successfully installed package: {from_code} -> {to_code}"
)
# Refresh installed languages
installed = argostranslate.translate.get_installed_languages()
installed_dict = {lang.code: lang for lang in installed}
else:
print_error(
f"Could not find a translation package for {from_code} -> {to_code}"
)
sys.exit(1)
except Exception as e:
print_error(f"Failed to install language package: {e}")
sys.exit(1)
return installed_dict.get(from_code), installed_dict.get(to_code)
def get_translation_func(from_code, to_code):
"""Returns a translation function for the specified language pair."""
from_lang, to_lang = ensure_package_installed(from_code, to_code)
translator = from_lang.get_translation(to_lang)
if not translator:
print_error(f"No translation available from {from_code} to {to_code}")
sys.exit(1)
return translator.translate
def replace_vars_with_tokens(text):
"""Replaces `{variable}` patterns with a standard token like `XVAR0X`.
So the translation engine doesn't attempt to translate variable names.
Returns the modified text and the list of found variables.
"""
vars_found = []
def replacer(match):
vars_found.append(match.group(0))
return f" XVAR{len(vars_found) - 1}X "
replaced_text = re.sub(r"\{+.*?\}+", replacer, text)
return replaced_text, vars_found
def restore_vars_from_tokens(text, vars_found):
"""Restores the original `{variable}` patterns back into the translated text.
Looks for the `XVAR0X` tokens.
"""
for i, var in enumerate(vars_found):
# The translation engine might change case or spacing around the token
text = re.sub(rf"\s*[xX][vV][aA][rR]{i}[xX]\s*", var, text)
return text.strip()
def translate_dict(data, translate_func, target_name=None):
"""Recursively iterates over a dictionary and translates all string values.
Skips the `_languageName` key, which can be explicitly set.
"""
if isinstance(data, dict):
new_dict = {}
for k, v in data.items():
if k == "_languageName" and target_name:
new_dict[k] = target_name
continue
elif k == "_languageName":
# Keep original if no target name provided
new_dict[k] = v
continue
new_dict[k] = translate_dict(v, translate_func, target_name)
return new_dict
elif isinstance(data, list):
return [translate_dict(item, translate_func, target_name) for item in data]
elif isinstance(data, str):
if not data.strip():
return data
temp_text, vars_found = replace_vars_with_tokens(data)
try:
translated_temp = translate_func(temp_text)
return restore_vars_from_tokens(translated_temp, vars_found)
except Exception as e:
print_warning(
f"Failed to translate '{data}': {e}. Falling back to original."
)
return data
else:
return data
def main():
parser = argparse.ArgumentParser(
description="Translate JSON localization files using Argos Translate."
)
parser.add_argument(
"--from", dest="from_lang", help="Source language code (e.g. 'en')"
)
parser.add_argument("--to", dest="to_lang", help="Target language code (e.g. 'zh')")
parser.add_argument("--input", dest="input_file", help="Path to input JSON file")
parser.add_argument("--output", dest="output_file", help="Path to output JSON file")
parser.add_argument(
"--name",
dest="target_name",
help="Native name of the target language (e.g. '中文' for Chinese)",
)
args = parser.parse_args()
# Fallback to Environment Variables if arguments aren't provided
from_lang = args.from_lang or os.environ.get("ARGOS_FROM_LANG")
to_lang = args.to_lang or os.environ.get("ARGOS_TO_LANG")
input_file = args.input_file or os.environ.get("ARGOS_INPUT_FILE")
output_file = args.output_file or os.environ.get("ARGOS_OUTPUT_FILE")
target_name = args.target_name or os.environ.get("ARGOS_TARGET_NAME")
if not all([from_lang, to_lang, input_file, output_file]):
parser.print_help()
print_error(
"Missing required arguments. Please provide --from, --to, --input, and --output."
)
sys.exit(1)
if not os.path.exists(input_file):
print_error(f"Input file not found: {input_file}")
sys.exit(1)
print_info(f"Source Language: {Colors.BOLD}{from_lang}{Colors.ENDC}")
print_info(f"Target Language: {Colors.BOLD}{to_lang}{Colors.ENDC}")
print_info(f"Input File: {Colors.BOLD}{input_file}{Colors.ENDC}")
print_info(f"Output File: {Colors.BOLD}{output_file}{Colors.ENDC}")
# Load JSON
try:
with open(input_file, "r", encoding="utf-8") as f:
source_data = json.load(f)
except json.JSONDecodeError as e:
print_error(f"Invalid JSON in input file: {e}")
sys.exit(1)
start_time = time.time()
# Get Translator
translate_func = get_translation_func(from_lang, to_lang)
print_info(
"Starting translation. This may take a moment depending on the file size..."
)
translated_data = translate_dict(source_data, translate_func, target_name)
# Ensure output directory exists
os.makedirs(os.path.dirname(os.path.abspath(output_file)), exist_ok=True)
# Save translated JSON
try:
with open(output_file, "w", encoding="utf-8") as f:
json.dump(translated_data, f, ensure_ascii=False, indent=4)
f.write("\n")
except IOError as e:
print_error(f"Could not write to output file: {e}")
sys.exit(1)
elapsed_time = time.time() - start_time
print_success(f"Translation complete in {elapsed_time:.2f} seconds!")
print_success(f"Output saved to {Colors.BOLD}{output_file}{Colors.ENDC}")
if __name__ == "__main__":
main()
+44 -6
View File
@@ -19,7 +19,7 @@ Options:
--python-minor X.Y Python minor for target wheels (default: 3.11)
--target-version V Explicit Chaquopy target version (default: auto latest for python minor)
--chaquopy-ref REF Chaquopy git ref/commit to checkout (default: master)
--abis LIST Comma-separated ABIs (default: arm64-v8a,x86_64)
--abis LIST Comma-separated ABIs (default: arm64-v8a,x86_64,armeabi-v7a)
--api-level N Android API level for wheel tag (default: 24)
--pycodec2-version V pycodec2 version to build (default: 4.1.1)
--numpy-version V NumPy version used during pycodec2 build (default: 1.26.2)
@@ -36,7 +36,7 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PYTHON_MINOR="3.11"
TARGET_VERSION=""
CHAQUOPY_REF="${CHAQUOPY_REF:-master}"
ABI_LIST="arm64-v8a,x86_64"
ABI_LIST="arm64-v8a,x86_64,armeabi-v7a"
API_LEVEL="24"
PYCODEC2_VERSION="4.1.1"
LIBCODEC2_VERSION="1.2.0"
@@ -145,10 +145,26 @@ require_cmd sed
require_cmd awk
require_cmd sort
PYTHON_BIN="python${PYTHON_MINOR}"
case ",${ABI_LIST}," in
*,armeabi-v7a,*)
if [[ -z "${ANDROID_HOME:-}" && -z "${ANDROID_SDK_ROOT:-}" ]]; then
echo "armeabi-v7a: NumPy is built from source and needs the Android SDK/NDK (Chaquopy android-env.sh)." >&2
echo "Set ANDROID_HOME or ANDROID_SDK_ROOT to your SDK root (with cmdline-tools/latest/bin/sdkmanager)." >&2
exit 1
fi
export ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT}}"
export ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-${ANDROID_HOME}}"
if [[ ! -x "${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" ]]; then
echo "armeabi-v7a: expected sdkmanager at ${ANDROID_HOME}/cmdline-tools/latest/bin/sdkmanager" >&2
exit 1
fi
;;
esac
PYTHON_BIN="${PYTHON_BIN:-python${PYTHON_MINOR}}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "Required interpreter not found on PATH: ${PYTHON_BIN}" >&2
echo "Install Python ${PYTHON_MINOR} locally before running this script." >&2
echo "Required interpreter not found: ${PYTHON_BIN}" >&2
echo "Install Python ${PYTHON_MINOR} (e.g. uv python install ${PYTHON_MINOR}) or set PYTHON_BIN." >&2
exit 1
fi
@@ -198,6 +214,28 @@ fi
NUMPY_DIST_DIR="${PYPIDIR}/dist/numpy"
mkdir -p "${NUMPY_DIST_DIR}"
PYTHON_ABI_TAG="cp${PYTHON_MINOR/./}"
# Chaquopy's numpy recipe lists chaquopy-openblas as a host requirement. build-wheel.py
# only loads it from ${PYPIDIR}/dist/chaquopy-openblas/ (it does not fetch from the index).
# Official wheels: https://chaquo.com/pypi-13.1/chaquopy-openblas/
cache_chaquopy_openblas_for_abi() {
local abi="$1"
local name url
mkdir -p "${PYPIDIR}/dist/chaquopy-openblas"
case "${abi}" in
armeabi-v7a) name="chaquopy_openblas-0.2.20-5-py3-none-android_16_armeabi_v7a.whl" ;;
arm64-v8a) name="chaquopy_openblas-0.2.20-5-py3-none-android_21_arm64_v8a.whl" ;;
x86_64) name="chaquopy_openblas-0.2.20-5-py3-none-android_21_x86_64.whl" ;;
*) return 0 ;;
esac
url="https://chaquo.com/pypi-13.1/chaquopy-openblas/${name}"
if [[ -f "${PYPIDIR}/dist/chaquopy-openblas/${name}" ]]; then
return 0
fi
echo "Caching Chaquopy OpenBLAS wheel for ${abi}"
curl -fsSL -o "${PYPIDIR}/dist/chaquopy-openblas/${name}" "${url}"
}
for abi in ${ABI_LIST//,/ }; do
platform_tag="$(abi_to_platform_tag "${abi}")"
echo "Resolving NumPy wheel for ABI ${abi} (${platform_tag})"
@@ -213,12 +251,12 @@ for abi in ${ABI_LIST//,/ }; do
--extra-index-url https://chaquo.com/pypi-13.1 \
--dest "${NUMPY_DIST_DIR}"; then
echo "No prebuilt NumPy wheel for ${abi}; building locally via Chaquopy recipe"
cache_chaquopy_openblas_for_abi "${abi}"
"${VENV_DIR}/bin/python" "${PYPIDIR}/build-wheel.py" \
--python "${PYTHON_MINOR}" \
--api-level "${API_LEVEL}" \
--abi "${abi}" \
"${PYPIDIR}/packages/numpy"
cp -f "${PYPIDIR}/dist/numpy"/numpy-"${NUMPY_VERSION}"-*.whl "${NUMPY_DIST_DIR}/"
fi
done
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Sign Android release APKs (and optionally add SourceStamp).
Required env vars:
SIGNING_KEYSTORE_PATH Path to signing keystore (.jks/.keystore)
SIGNING_KEY_ALIAS Alias of signing key
SIGNING_KEYSTORE_PASSWORD Keystore password
SIGNING_KEY_PASSWORD Key password (defaults to SIGNING_KEYSTORE_PASSWORD)
Optional env vars:
ANDROID_HOME Android SDK root (or ANDROID_SDK_ROOT)
APK_GLOB Glob for unsigned APKs
(default: android/app/build/outputs/apk/release/*-unsigned.apk)
ENABLE_SOURCESTAMP true/false (default: false)
SOURCESTAMP_KEYSTORE_PATH Path to SourceStamp keystore
SOURCESTAMP_KEY_ALIAS Alias in SourceStamp keystore
SOURCESTAMP_KEYSTORE_PASSWORD SourceStamp keystore password
SOURCESTAMP_KEY_PASSWORD SourceStamp key password
Output:
Creates sibling files for each unsigned APK:
*-aligned.apk
*-signed.apk
Examples:
SIGNING_KEYSTORE_PATH=android/keystore/meshchatx-release.jks \
SIGNING_KEY_ALIAS=meshchatx-release \
SIGNING_KEYSTORE_PASSWORD='...' \
SIGNING_KEY_PASSWORD='...' \
bash scripts/sign-android-apks.sh
SIGNING_KEYSTORE_PATH=android/keystore/meshchatx-release.jks \
SIGNING_KEY_ALIAS=meshchatx-release \
SIGNING_KEYSTORE_PASSWORD='...' \
SIGNING_KEY_PASSWORD='...' \
ENABLE_SOURCESTAMP=true \
SOURCESTAMP_KEYSTORE_PATH=android/keystore/meshchatx-stamp.jks \
SOURCESTAMP_KEY_ALIAS=meshchatx-stamp \
SOURCESTAMP_KEYSTORE_PASSWORD='...' \
SOURCESTAMP_KEY_PASSWORD='...' \
bash scripts/sign-android-apks.sh
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
echo "Missing required env var: ${name}" >&2
exit 1
fi
}
require_env SIGNING_KEYSTORE_PATH
require_env SIGNING_KEY_ALIAS
require_env SIGNING_KEYSTORE_PASSWORD
SIGNING_KEY_PASSWORD="${SIGNING_KEY_PASSWORD:-${SIGNING_KEYSTORE_PASSWORD}}"
ANDROID_HOME="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -z "${ANDROID_HOME}" ]]; then
echo "Set ANDROID_HOME (or ANDROID_SDK_ROOT) to your Android SDK path." >&2
exit 1
fi
BT_DIR="$(ls -d "${ANDROID_HOME}"/build-tools/* 2>/dev/null | sort -V | tail -n 1)"
if [[ -z "${BT_DIR}" ]]; then
echo "No Android build-tools found under ${ANDROID_HOME}/build-tools." >&2
exit 1
fi
if [[ ! -x "${BT_DIR}/zipalign" || ! -x "${BT_DIR}/apksigner" ]]; then
echo "Missing zipalign/apksigner in ${BT_DIR}. Install build-tools via sdkmanager." >&2
exit 1
fi
APK_GLOB="${APK_GLOB:-android/app/build/outputs/apk/release/*-unsigned.apk}"
shopt -s nullglob
APKS=( ${APK_GLOB} )
shopt -u nullglob
if [[ ${#APKS[@]} -eq 0 ]]; then
echo "No unsigned APKs matched: ${APK_GLOB}" >&2
exit 1
fi
ENABLE_SOURCESTAMP="${ENABLE_SOURCESTAMP:-false}"
if [[ "${ENABLE_SOURCESTAMP}" == "true" ]]; then
require_env SOURCESTAMP_KEYSTORE_PATH
require_env SOURCESTAMP_KEY_ALIAS
require_env SOURCESTAMP_KEYSTORE_PASSWORD
SOURCESTAMP_KEY_PASSWORD="${SOURCESTAMP_KEY_PASSWORD:-${SOURCESTAMP_KEYSTORE_PASSWORD}}"
fi
for apk in "${APKS[@]}"; do
base="${apk%-unsigned.apk}"
aligned="${base}-aligned.apk"
signed="${base}-signed.apk"
echo "Aligning ${apk}"
"${BT_DIR}/zipalign" -p -f 4 "${apk}" "${aligned}"
sign_args=(
sign
--ks "${SIGNING_KEYSTORE_PATH}"
--ks-key-alias "${SIGNING_KEY_ALIAS}"
--ks-pass "pass:${SIGNING_KEYSTORE_PASSWORD}"
--key-pass "pass:${SIGNING_KEY_PASSWORD}"
--out "${signed}"
)
if [[ "${ENABLE_SOURCESTAMP}" == "true" ]]; then
sign_args+=(
--stamp-signer
--stamp-ks "${SOURCESTAMP_KEYSTORE_PATH}"
--stamp-key-alias "${SOURCESTAMP_KEY_ALIAS}"
--stamp-ks-pass "pass:${SOURCESTAMP_KEYSTORE_PASSWORD}"
--stamp-key-pass "pass:${SOURCESTAMP_KEY_PASSWORD}"
)
fi
echo "Signing ${aligned}"
"${BT_DIR}/apksigner" "${sign_args[@]}" "${aligned}"
echo "Verifying ${signed}"
"${BT_DIR}/apksigner" verify --verbose --print-certs "${signed}"
done
echo "Done."