mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-14 23:18:43 +00:00
Move the pushTranslations script into the codebase as kotlin.
This commit is contained in:
committed by
jeffrey-signal
parent
e162eb27c7
commit
fe1755f250
@@ -0,0 +1,56 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import groovy.io.FileType
|
||||
import groovy.transform.stc.ClosureParams
|
||||
import groovy.transform.stc.SimpleType
|
||||
import org.signal.buildtools.StaticIpResolver
|
||||
|
||||
def allStringsResourceFiles(@ClosureParams(value = SimpleType.class, options = ['java.io.File']) Closure c) {
|
||||
file('src/main/res').eachFileRecurse(FileType.FILES) { f ->
|
||||
if (f.name == 'strings.xml') {
|
||||
c(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task replaceEllipsis {
|
||||
group 'Static Files'
|
||||
description 'Process strings for ellipsis characters.'
|
||||
doLast {
|
||||
allStringsResourceFiles { f ->
|
||||
def before = f.text
|
||||
def after = f.text.replace('...', '…')
|
||||
if (before != after) {
|
||||
f.text = after
|
||||
logger.info("$f.parentFile.name/$f.name...updated")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task cleanApostropheErrors {
|
||||
group 'Static Files'
|
||||
description 'Fix transifex apostrophe string errors.'
|
||||
doLast {
|
||||
allStringsResourceFiles { f ->
|
||||
def before = f.text
|
||||
def after = before.replaceAll(/([^\\=08])(')/, '$1\\\\\'')
|
||||
if (before != after) {
|
||||
f.text = after
|
||||
logger.info("$f.parentFile.name/$f.name...updated")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task excludeNonTranslatables {
|
||||
group 'Static Files'
|
||||
description 'Remove strings that are marked "translatable"="false" or are ExtraTranslations.'
|
||||
doLast {
|
||||
def englishFile = file('src/main/res/values/strings.xml')
|
||||
|
||||
def english = new XmlParser().parse(englishFile)
|
||||
def nonTranslatable = english
|
||||
.findAll { it['@translatable'] == 'false' }
|
||||
.collect { it['@name'] }
|
||||
.toSet()
|
||||
def all = english.collect { it['@name'] }.toSet()
|
||||
def translatable = all - nonTranslatable
|
||||
def inMultiline = false
|
||||
def endBlockName = ""
|
||||
|
||||
allStringsResourceFiles { f ->
|
||||
if (f != englishFile) {
|
||||
def newLines = f.readLines()
|
||||
.collect { line ->
|
||||
if (!inMultiline) {
|
||||
def singleLineMatcher = line =~ /name="([^"]*)".*(<\/|\/>)/
|
||||
if (singleLineMatcher.find()) {
|
||||
def name = singleLineMatcher.group(1)
|
||||
if (!line.contains('excludeNonTranslatables') && !translatable.contains(name)) {
|
||||
return " <!-- Removed by excludeNonTranslatables ${line.trim()} -->"
|
||||
}
|
||||
} else {
|
||||
def multilineStartMatcher = line =~ /<(.*) .?name="([^"]*)".*/
|
||||
if (multilineStartMatcher.find()) {
|
||||
endBlockName = multilineStartMatcher.group(1)
|
||||
def name = multilineStartMatcher.group(2)
|
||||
if (!line.contains('excludeNonTranslatables') && !translatable.contains(name)) {
|
||||
inMultiline = true;
|
||||
return " <!-- Removed by excludeNonTranslatables ${line.trim()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
def multilineEndMatcher = line =~ /<\/${endBlockName}/
|
||||
if (multilineEndMatcher.find()) {
|
||||
inMultiline = false
|
||||
return "${line} -->"
|
||||
}
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
f.write(newLines.join("\n") + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
task postTranslateQa {
|
||||
group 'Static Files'
|
||||
description 'Runs QA to check validity of updated strings, and ensure presence of any new languages in internal lists.'
|
||||
dependsOn ':qa'
|
||||
}
|
||||
|
||||
task resolveStaticIps {
|
||||
group 'Static Files'
|
||||
description 'Fetches static IPs for core hosts and writes them to static-ips.gradle'
|
||||
doLast {
|
||||
def staticIpResolver = new StaticIpResolver()
|
||||
new File(projectDir, "static-ips.gradle.kts").text = """
|
||||
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")}\"\"\"
|
||||
""".stripIndent().trim() + "\n"
|
||||
}
|
||||
}
|
||||
|
||||
task updateStaticFilesAndQa {
|
||||
group 'Static Files'
|
||||
description 'Runs tasks to update static files. This includes translations, static IPs, and licenses. Runs QA afterwards to verify all went well. Intended to be run before cutting a release.'
|
||||
dependsOn replaceEllipsis, cleanApostropheErrors, excludeNonTranslatables, resolveStaticIps, postTranslateQa
|
||||
}
|
||||
139
build-logic/plugins/src/main/java/translations.gradle.kts
Normal file
139
build-logic/plugins/src/main/java/translations.gradle.kts
Normal file
@@ -0,0 +1,139 @@
|
||||
import groovy.util.Node
|
||||
import groovy.xml.XmlParser
|
||||
import org.signal.buildtools.StaticIpResolver
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Tasks for managing translations and static files.
|
||||
*/
|
||||
|
||||
tasks.register("replaceEllipsis") {
|
||||
group = "Static Files"
|
||||
description = "Process strings for ellipsis characters."
|
||||
doLast {
|
||||
allStringsResourceFiles { f ->
|
||||
val before = f.readText()
|
||||
val after = before.replace("...", "…")
|
||||
if (before != after) {
|
||||
f.writeText(after)
|
||||
logger.info("${f.parentFile.name}/${f.name}...updated")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("cleanApostropheErrors") {
|
||||
group = "Static Files"
|
||||
description = "Fix smartling apostrophe string errors."
|
||||
doLast {
|
||||
val pattern = Regex("""([^\\=08])(')""")
|
||||
allStringsResourceFiles { f ->
|
||||
val before = f.readText()
|
||||
val after = pattern.replace(before) { match ->
|
||||
"${match.groupValues[1]}\\'"
|
||||
}
|
||||
if (before != after) {
|
||||
f.writeText(after)
|
||||
logger.info("${f.parentFile.name}/${f.name}...updated")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("excludeNonTranslatables") {
|
||||
group = "Static Files"
|
||||
description = "Remove strings that are marked \"translatable\"=\"false\" or are ExtraTranslations."
|
||||
doLast {
|
||||
val englishFile = file("src/main/res/values/strings.xml")
|
||||
|
||||
val english = XmlParser().parse(englishFile)
|
||||
val nonTranslatable = english.children()
|
||||
.filterIsInstance<Node>()
|
||||
.filter { it.attribute("translatable") == "false" }
|
||||
.mapNotNull { it.attribute("name") as? String }
|
||||
.toSet()
|
||||
val all = english.children()
|
||||
.filterIsInstance<Node>()
|
||||
.mapNotNull { it.attribute("name") as? String }
|
||||
.toSet()
|
||||
val translatable = all - nonTranslatable
|
||||
|
||||
allStringsResourceFiles { f ->
|
||||
if (f != englishFile) {
|
||||
var inMultiline = false
|
||||
var endBlockName = ""
|
||||
|
||||
val newLines = f.readLines().map { line ->
|
||||
if (!inMultiline) {
|
||||
val singleLineMatcher = Regex("""name="([^"]*)".*(<\/|\/>)""").find(line)
|
||||
if (singleLineMatcher != null) {
|
||||
val name = singleLineMatcher.groupValues[1]
|
||||
if (!line.contains("excludeNonTranslatables") && name !in translatable) {
|
||||
return@map " <!-- Removed by excludeNonTranslatables ${line.trim()} -->"
|
||||
}
|
||||
} else {
|
||||
val multilineStartMatcher = Regex("""<(.*) .?name="([^"]*)".*""").find(line)
|
||||
if (multilineStartMatcher != null) {
|
||||
endBlockName = multilineStartMatcher.groupValues[1]
|
||||
val name = multilineStartMatcher.groupValues[2]
|
||||
if (!line.contains("excludeNonTranslatables") && name !in translatable) {
|
||||
inMultiline = true
|
||||
return@map " <!-- Removed by excludeNonTranslatables ${line.trim()}"
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val multilineEndMatcher = Regex("""</$endBlockName""").find(line)
|
||||
if (multilineEndMatcher != null) {
|
||||
inMultiline = false
|
||||
return@map "$line -->"
|
||||
}
|
||||
}
|
||||
|
||||
line
|
||||
}
|
||||
|
||||
f.writeText(newLines.joinToString("\n") + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("postTranslateQa") {
|
||||
group = "Static Files"
|
||||
description = "Runs QA to check validity of updated strings, and ensure presence of any new languages in internal lists."
|
||||
dependsOn(":qa")
|
||||
}
|
||||
|
||||
tasks.register("resolveStaticIps") {
|
||||
group = "Static Files"
|
||||
description = "Fetches static IPs for core hosts and writes them to static-ips.gradle"
|
||||
doLast {
|
||||
val staticIpResolver = StaticIpResolver()
|
||||
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")}${"\"\"\""}
|
||||
""".trimIndent() + "\n"
|
||||
File(projectDir, "static-ips.gradle.kts").writeText(content)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("updateStaticFilesAndQa") {
|
||||
group = "Static Files"
|
||||
description = "Runs tasks to update static files. This includes translations, static IPs, and licenses. Runs QA afterwards to verify all went well. Intended to be run before cutting a release."
|
||||
dependsOn("replaceEllipsis", "cleanApostropheErrors", "excludeNonTranslatables", "resolveStaticIps", "postTranslateQa")
|
||||
}
|
||||
|
||||
private fun allStringsResourceFiles(action: (File) -> Unit) {
|
||||
val resDir = file("src/main/res")
|
||||
resDir.walkTopDown()
|
||||
.filter { it.isFile && it.name == "strings.xml" }
|
||||
.forEach(action)
|
||||
}
|
||||
@@ -25,6 +25,8 @@ dependencies {
|
||||
implementation(gradleApi())
|
||||
|
||||
implementation(libs.dnsjava)
|
||||
implementation(libs.square.okhttp3)
|
||||
|
||||
testImplementation(testLibs.junit.junit)
|
||||
testImplementation(testLibs.mockk)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package org.signal.buildtools
|
||||
|
||||
import groovy.json.JsonBuilder
|
||||
import groovy.json.JsonSlurper
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Client for interacting with the Smartling translation API.
|
||||
*/
|
||||
class SmartlingClient(
|
||||
private val userIdentifier: String,
|
||||
private val userSecret: String,
|
||||
private val projectId: String
|
||||
) {
|
||||
|
||||
private val client = OkHttpClient()
|
||||
private val jsonParser = JsonSlurper()
|
||||
|
||||
/**
|
||||
* Authenticates with Smartling and returns an access token.
|
||||
*/
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun authenticate(): String {
|
||||
val jsonBody = JsonBuilder(
|
||||
mapOf(
|
||||
"userIdentifier" to userIdentifier,
|
||||
"userSecret" to userSecret
|
||||
)
|
||||
).toString()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("https://api.smartling.com/auth-api/v2/authenticate")
|
||||
.post(jsonBody.toRequestBody("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val responseBody = response.body.string()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw SmartlingException("Authentication failed 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 accessToken = data?.get("accessToken") as? String
|
||||
|
||||
return accessToken
|
||||
?: throw SmartlingException("Failed to extract access token from response: $responseBody")
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a file to Smartling for translation.
|
||||
*/
|
||||
fun uploadFile(authToken: String, file: File, fileUri: String): String {
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("file", file.name, file.asRequestBody("application/xml".toMediaType()))
|
||||
.addFormDataPart("fileUri", fileUri)
|
||||
.addFormDataPart("fileType", "android")
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("https://api.smartling.com/files-api/v2/projects/$projectId/file")
|
||||
.header("Authorization", "Bearer $authToken")
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
val responseBody = response.body.string()
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw SmartlingException("Upload failed with code ${response.code}: $responseBody")
|
||||
}
|
||||
|
||||
return responseBody
|
||||
}
|
||||
|
||||
class SmartlingException(message: String) : RuntimeException(message)
|
||||
}
|
||||
Reference in New Issue
Block a user