Move the pullTranslations script into the codebase as kotlin.

This commit is contained in:
Greyson Parrelli
2025-12-31 13:51:07 -05:00
committed by jeffrey-signal
parent fe1755f250
commit 3031d68863
4 changed files with 232 additions and 67 deletions

View File

@@ -17,7 +17,6 @@ plugins {
id("kotlin-parcelize")
id("com.squareup.wire")
id("translations")
id("translations-kotlin")
id("licenses")
}

View File

@@ -1,56 +0,0 @@
import org.signal.buildtools.SmartlingClient
import java.io.File
import java.util.Properties
/**
* Kotlin-based translation tasks.
*
* Requires the following properties in local.properties:
* smartling.userIdentifier - Smartling API user identifier
* smartling.userSecret - Smartling API user secret
* smartling.projectId - (optional) Smartling project ID, defaults to "3e5533321"
*/
tasks.register("pushTranslations") {
group = "Translations"
description = "Pushes the main strings.xml file to Smartling for translation"
doLast {
val localPropertiesFile = File(rootDir, "local.properties")
if (!localPropertiesFile.exists()) {
throw GradleException("local.properties not found at ${localPropertiesFile.absolutePath}")
}
val localProperties = Properties().apply {
localPropertiesFile.inputStream().use { load(it) }
}
val userIdentifier = localProperties.requireProperty("smartling.userIdentifier")
val userSecret = localProperties.requireProperty("smartling.userSecret")
val projectId = localProperties.requireProperty("smartling.projectId")
val stringsFile = File(rootDir, "app/src/main/res/values/strings.xml")
if (!stringsFile.exists()) {
throw GradleException("strings.xml not found at ${stringsFile.absolutePath}")
}
println("Using Signal-Android root directory of $rootDir")
val client = SmartlingClient(userIdentifier, userSecret, projectId)
println("Fetching auth...")
val authToken = client.authenticate()
println("> Done")
println()
println("Uploading file...")
val response = client.uploadFile(authToken, stringsFile, "strings.xml")
println(response)
println("> Done")
}
}
private fun Properties.requireProperty(name: String): String {
return getProperty(name) ?: throw GradleException("$name not found in local.properties")
}

View File

@@ -1,12 +1,104 @@
import groovy.util.Node
import groovy.xml.XmlParser
import org.signal.buildtools.SmartlingClient
import org.signal.buildtools.StaticIpResolver
import java.io.File
import java.util.Properties
import java.util.concurrent.Executors
import java.util.concurrent.Future
/**
* Tasks for managing translations and static files.
*
* Smartling tasks require the following properties in local.properties:
* smartling.userIdentifier - Smartling API user identifier
* smartling.userSecret - Smartling API user secret
* smartling.projectId - Smartling project ID
*/
// =====================
// Smartling Tasks
// =====================
tasks.register("pushTranslations") {
group = "Translations"
description = "Pushes the main strings.xml file to Smartling for translation"
doLast {
val client = createSmartlingClient()
val stringsFile = File(rootDir, "app/src/main/res/values/strings.xml")
if (!stringsFile.exists()) {
throw GradleException("strings.xml not found at ${stringsFile.absolutePath}")
}
println("Using Signal-Android root directory of $rootDir")
println("Fetching auth...")
val authToken = client.authenticate()
println("> Done")
println()
println("Uploading file...")
val response = client.uploadFile(authToken, stringsFile, "strings.xml")
println(response)
println("> Done")
}
}
tasks.register("pullTranslations") {
group = "Translations"
description = "Pulls translated strings.xml files from Smartling for all locales"
doLast {
val client = createSmartlingClient()
val resDir = File(rootDir, "app/src/main/res")
println("Using Signal-Android root directory of $rootDir")
println("Fetching auth...")
val authToken = client.authenticate()
println("> Done")
println()
println("Fetching locales...")
val locales = client.getLocales(authToken, "strings.xml")
println("Found ${locales.size} locales")
println("> Done")
println()
println("Fetching files...")
val executor = Executors.newFixedThreadPool(35)
val futures = mutableListOf<Future<Pair<String, String>>>()
for (locale in locales) {
if (locale in localeBlocklist) {
continue
}
futures += executor.submit<Pair<String, String>> {
val content = client.downloadFile(authToken, "strings.xml", locale)
println("Successfully pulled file for locale $locale")
locale to content
}
}
val results = futures.map { it.get() }
executor.shutdown()
println("> Done")
println()
println("Writing files...")
for ((locale, content) in results) {
val androidLocale = localeMap[locale] ?: locale
val localeDir = File(resDir, "values-$androidLocale")
localeDir.mkdirs()
File(localeDir, "strings.xml").writeText(content)
}
println("> Done")
}
}
tasks.register("replaceEllipsis") {
group = "Static Files"
description = "Process strings for ellipsis characters."
@@ -110,16 +202,17 @@ tasks.register("resolveStaticIps") {
description = "Fetches static IPs for core hosts and writes them to static-ips.gradle"
doLast {
val staticIpResolver = StaticIpResolver()
val tripleQuote = "\"\"\""
val content = """
rootProject.extra["service_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("chat.signal.org")}${"\"\"\""}
rootProject.extra["storage_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("storage.signal.org")}${"\"\"\""}
rootProject.extra["cdn_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("cdn.signal.org")}${"\"\"\""}
rootProject.extra["cdn2_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("cdn2.signal.org")}${"\"\"\""}
rootProject.extra["cdn3_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("cdn3.signal.org")}${"\"\"\""}
rootProject.extra["sfu_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("sfu.voip.signal.org")}${"\"\"\""}
rootProject.extra["content_proxy_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("contentproxy.signal.org")}${"\"\"\""}
rootProject.extra["svr2_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("svr2.signal.org")}${"\"\"\""}
rootProject.extra["cdsi_ips"] = ${"\"\"\""}${staticIpResolver.resolveToBuildConfig("cdsi.signal.org")}${"\"\"\""}
rootProject.extra["service_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("chat.signal.org")}$tripleQuote
rootProject.extra["storage_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("storage.signal.org")}$tripleQuote
rootProject.extra["cdn_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("cdn.signal.org")}$tripleQuote
rootProject.extra["cdn2_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("cdn2.signal.org")}$tripleQuote
rootProject.extra["cdn3_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("cdn3.signal.org")}$tripleQuote
rootProject.extra["sfu_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("sfu.voip.signal.org")}$tripleQuote
rootProject.extra["content_proxy_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("contentproxy.signal.org")}$tripleQuote
rootProject.extra["svr2_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("svr2.signal.org")}$tripleQuote
rootProject.extra["cdsi_ips"] = $tripleQuote${staticIpResolver.resolveToBuildConfig("cdsi.signal.org")}$tripleQuote
""".trimIndent() + "\n"
File(projectDir, "static-ips.gradle.kts").writeText(content)
}
@@ -137,3 +230,79 @@ private fun allStringsResourceFiles(action: (File) -> Unit) {
.filter { it.isFile && it.name == "strings.xml" }
.forEach(action)
}
private fun createSmartlingClient(): SmartlingClient {
val localPropertiesFile = File(rootDir, "local.properties")
if (!localPropertiesFile.exists()) {
throw GradleException("local.properties not found at ${localPropertiesFile.absolutePath}")
}
val localProperties = Properties().apply {
localPropertiesFile.inputStream().use { load(it) }
}
val userIdentifier = localProperties.requireProperty("smartling.userIdentifier")
val userSecret = localProperties.requireProperty("smartling.userSecret")
val projectId = localProperties.requireProperty("smartling.projectId")
return SmartlingClient(userIdentifier, userSecret, projectId)
}
private fun Properties.requireProperty(name: String): String {
return getProperty(name) ?: throw GradleException("$name not found in local.properties")
}
/**
* A mapping of smartling-locale => Android locale.
* Only needed when they differ.
*/
private val localeMap = mapOf(
"af-ZA" to "af",
"az-AZ" to "az",
"be-BY" to "be",
"bg-BG" to "bg",
"bn-BD" to "bn",
"bs-BA" to "bs",
"et-EE" to "et",
"fa-IR" to "fa",
"ga-IE" to "ga",
"gl-ES" to "gl",
"gu-IN" to "gu",
"he" to "iw",
"hi-IN" to "hi",
"hr-HR" to "hr",
"id" to "in",
"ka-GE" to "ka",
"kk-KZ" to "kk",
"km-KH" to "km",
"kn-IN" to "kn",
"ky-KG" to "ky",
"lt-LT" to "lt",
"lv-LV" to "lv",
"mk-MK" to "mk",
"ml-IN" to "ml",
"mr-IN" to "mr",
"pa-IN" to "pa",
"pt-BR" to "pt-rBR",
"pt-PT" to "pt",
"ro-RO" to "ro",
"sk-SK" to "sk",
"sl-SI" to "sl",
"sq-AL" to "sq",
"sr-RS" to "sr-rRS",
"sr-YR" to "sr",
"ta-IN" to "ta",
"te-IN" to "te",
"tl-PH" to "tl",
"uk-UA" to "uk",
"zh-CN" to "zh-rCN",
"zh-HK" to "zh-rHK",
"zh-TW" to "zh-rTW",
"zh-YU" to "yue"
)
/**
* Locales that should not be saved, even if present remotely.
* Typically for unfinished translations not ready to be public.
*/
val localeBlocklist = emptySet<String>()

View File

@@ -9,6 +9,7 @@ import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Client for interacting with the Smartling translation API.
@@ -19,7 +20,12 @@ class SmartlingClient(
private val projectId: String
) {
private val client = OkHttpClient()
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
private val jsonParser = JsonSlurper()
/**
@@ -82,5 +88,52 @@ class SmartlingClient(
return responseBody
}
/**
* Gets the list of locales that have translations for a given file.
*/
@Suppress("UNCHECKED_CAST")
fun getLocales(authToken: String, fileUri: String): List<String> {
val request = Request.Builder()
.url("https://api.smartling.com/files-api/v2/projects/$projectId/file/status?fileUri=$fileUri")
.header("Authorization", "Bearer $authToken")
.get()
.build()
val response = client.newCall(request).execute()
val responseBody = response.body.string()
if (!response.isSuccessful) {
throw SmartlingException("Failed to get locales with code ${response.code}: $responseBody")
}
val json = jsonParser.parseText(responseBody) as Map<String, Any>
val responseObj = json["response"] as? Map<String, Any>
val data = responseObj?.get("data") as? Map<String, Any>
val items = data?.get("items") as? List<Map<String, Any>>
return items?.mapNotNull { it["localeId"] as? String }
?: throw SmartlingException("Failed to extract locales from response: $responseBody")
}
/**
* Downloads the translated file for a specific locale.
*/
fun downloadFile(authToken: String, fileUri: String, locale: String): String {
val request = Request.Builder()
.url("https://api.smartling.com/files-api/v2/projects/$projectId/locales/$locale/file?fileUri=$fileUri")
.header("Authorization", "Bearer $authToken")
.get()
.build()
val response = client.newCall(request).execute()
val responseBody = response.body.string()
if (!response.isSuccessful) {
throw SmartlingException("Failed to download file for locale $locale with code ${response.code}: $responseBody")
}
return responseBody
}
class SmartlingException(message: String) : RuntimeException(message)
}