Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View file

@ -0,0 +1,23 @@
# Translation CLI
This is a command line interface that will check the [weblate](https://hosted.weblate.org/projects/tb-android/#languages) translation state for all languages and print out the ones that are above a certain threshold.
## Usage
To use this script you need to have a weblate token. You can get it by logging in to weblate and going to your profile settings.
You can run the script with the following command:
```bash
./scripts/translation --token <weblate-token> [--threshold 70]
```
It will print out the languages that are above the threshold. The default threshold is 70. You can change it by passing the `--threshold` argument.
If you want a code example, you can pass the `--print-all` argument. It will print out example code for easier integration into the project.
```bash
./scripts/translation --token <weblate-token> --print-all
```
You could use this output to update the `resourceConfigurations` variable in the `app-k9mail/build.gradle.kts` file and the `supported_languages` in the `arrays_general_settings_values.xml` file.

View file

@ -0,0 +1,20 @@
plugins {
id(ThunderbirdPlugins.App.jvm)
alias(libs.plugins.kotlin.serialization)
}
version = "unspecified"
application {
mainClass.set("net.thunderbird.cli.translation.MainKt")
}
dependencies {
implementation(libs.clikt)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.serialization.json)
implementation(libs.logback.classic)
}

View file

@ -0,0 +1,11 @@
package net.thunderbird.cli.translation
object AndroidLanguageCodeHelper {
/**
* Fix the language code format to match the Android resource format.
*/
fun fixLanguageCodeFormat(languageCode: String): String {
return if (languageCode.contains("-r")) languageCode.replace("-r", "_") else languageCode
}
}

View file

@ -0,0 +1,32 @@
package net.thunderbird.cli.translation
import net.thunderbird.cli.translation.net.Language
import net.thunderbird.cli.translation.net.Translation
import net.thunderbird.cli.translation.net.WeblateClient
class LanguageCodeLoader(
private val client: WeblateClient = WeblateClient(),
) {
fun loadCurrentAndroidLanguageCodes(token: String, threshold: Double): List<String> {
val languages = client.loadLanguages(token)
val translations = client.loadTranslations(token)
val languageCodeLookup = createLanguageCodeLookup(translations)
return filterAndMapLanguages(languages, threshold, languageCodeLookup)
}
private fun createLanguageCodeLookup(translations: List<Translation>): Map<String, String> {
return translations.associate { it.language.code to it.languageCode }
}
private fun filterAndMapLanguages(
languages: List<Language>,
threshold: Double,
languageCodeLookup: Map<String, String>,
): List<String> {
return languages.filter { it.translatedPercent >= threshold }
.map {
languageCodeLookup[it.code] ?: throw IllegalArgumentException("Language code ${it.code} is not mapped")
}.sorted()
}
}

View file

@ -0,0 +1,5 @@
package net.thunderbird.cli.translation
import com.github.ajalt.clikt.core.main
fun main(args: Array<String>) = TranslationCli().main(args)

View file

@ -0,0 +1,16 @@
package net.thunderbird.cli.translation
class ResourceConfigurationsFormatter {
fun format(languageCodes: List<String>) = buildString {
appendLine("android {")
appendLine(" androidResources {")
appendLine(" // Keep in sync with the resource string array \"supported_languages\"")
appendLine(" localeFilters += listOf(")
languageCodes.forEach { code ->
appendLine(" \"$code\",")
}
appendLine(" )")
appendLine(" }")
appendLine("}")
}.trim()
}

View file

@ -0,0 +1,15 @@
package net.thunderbird.cli.translation
class SupportedLanguagesFormatter {
fun format(languageCodes: List<String>) = buildString {
appendLine("<?xml version=\"1.0\" encoding=\"utf-8\"?>")
appendLine("<resources>")
appendLine(" <string-array name=\"supported_languages\" translatable=\"false\">")
appendLine(" <item />")
languageCodes.forEach {
appendLine(" <item>$it</item>")
}
appendLine(" </string-array>")
appendLine("</resources>")
}.trim()
}

View file

@ -0,0 +1,59 @@
package net.thunderbird.cli.translation
import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.clikt.parameters.options.default
import com.github.ajalt.clikt.parameters.options.flag
import com.github.ajalt.clikt.parameters.options.option
import com.github.ajalt.clikt.parameters.options.required
import com.github.ajalt.clikt.parameters.types.double
const val TRANSLATED_THRESHOLD = 70.0
class TranslationCli(
private val languageCodeLoader: LanguageCodeLoader = LanguageCodeLoader(),
private val configurationsFormatter: ResourceConfigurationsFormatter = ResourceConfigurationsFormatter(),
private val supportedLanguagesFormatter: SupportedLanguagesFormatter = SupportedLanguagesFormatter(),
) : CliktCommand(
name = "translation",
) {
private val token: String by option(
help = "Weblate API token",
).required()
private val threshold: Double by option(
help = "Threshold for translation completion",
).double().default(TRANSLATED_THRESHOLD)
private val printAll: Boolean by option(
help = "Print code example",
).flag()
override fun help(context: Context): String = "Translation CLI"
override fun run() {
val languageCodes = languageCodeLoader.loadCurrentAndroidLanguageCodes(token, threshold)
val androidLanguageCodes = languageCodes.map { AndroidLanguageCodeHelper.fixLanguageCodeFormat(it) }
val size = languageCodes.size
echo("\nLanguages that are translated above the threshold of ($threshold%): $size")
echo("--------------------------------------------------------------")
echo("For androidResources.localeFilters:")
echo(languageCodes.joinToString(", "))
echo()
echo("For array resource supported_languages:")
echo(androidLanguageCodes.joinToString(", "))
if (printAll) {
echo()
echo("--------------------------------------------------------------")
echo(configurationsFormatter.format(languageCodes))
echo("--------------------------------------------------------------")
echo("--------------------------------------------------------------")
echo(supportedLanguagesFormatter.format(androidLanguageCodes))
echo("--------------------------------------------------------------")
echo("Please read docs/translating.md for more information on how to update language values.")
echo("--------------------------------------------------------------")
}
echo()
}
}

View file

@ -0,0 +1,11 @@
package net.thunderbird.cli.translation.net
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Language(
val code: String,
@SerialName("translated_percent")
val translatedPercent: Double,
)

View file

@ -0,0 +1,22 @@
package net.thunderbird.cli.translation.net
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TranslationResponse(
val next: String?,
val results: List<Translation>,
)
@Serializable
data class Translation(
@SerialName("language_code")
val languageCode: String,
val language: TranslationLanguage,
)
@Serializable
data class TranslationLanguage(
val code: String,
)

View file

@ -0,0 +1,88 @@
package net.thunderbird.cli.translation.net
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.cio.CIO
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.DEFAULT
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.request.get
import io.ktor.http.headers
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
class WeblateClient(
private val client: HttpClient = createClient(),
private val config: WeblateConfig = WeblateConfig(),
) {
fun loadLanguages(token: String): List<Language> {
val languages: List<Language>
runBlocking {
languages = client.get(config.projectsLanguagesUrl()) {
headers {
config.getDefaultHeaders(token).forEach { (key, value) -> append(key, value) }
}
}.body()
}
return languages
}
fun loadTranslations(token: String): List<Translation> {
val translations = mutableListOf<Translation>()
var page = 1
var hasNextPage = true
while (hasNextPage) {
val translationPage = loadTranslationPage(token, page)
translations.addAll(translationPage.results)
hasNextPage = translationPage.next != null
page++
}
return translations
}
private fun loadTranslationPage(token: String, page: Int): TranslationResponse {
val translationResponse: TranslationResponse
runBlocking {
translationResponse = client.get(config.componentsTranslationsUrl(page)) {
headers {
config.getDefaultHeaders(token).forEach { (key, value) -> append(key, value) }
}
}.body()
}
return translationResponse
}
private companion object {
fun createClient(): HttpClient {
return HttpClient(CIO) {
install(Logging) {
logger = Logger.DEFAULT
level = LogLevel.NONE
}
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
},
)
}
}
}
private fun WeblateConfig.projectsLanguagesUrl() =
"${baseUrl}projects/$projectName/languages/"
private fun WeblateConfig.componentsTranslationsUrl(page: Int) =
"${baseUrl}components/$projectName/$defaultComponent/translations/?page=$page"
}
}

View file

@ -0,0 +1,26 @@
package net.thunderbird.cli.translation.net
/**
* Configuration for Weblate API
*
* @property baseUrl Base URL of the Weblate API
* @property projectName Name of the Weblate project
* @property defaultComponent Default component to use for translations
*/
data class WeblateConfig(
val baseUrl: String = "https://hosted.weblate.org/api/",
val projectName: String = "tb-android",
val defaultComponent: String = "app-strings",
private val defaultHeaders: Map<String, String> = mapOf(
"Accept" to "application/json",
"Authorization" to "Token $PLACEHOLDER_TOKEN",
),
) {
fun getDefaultHeaders(token: String): List<Pair<String, String>> =
defaultHeaders.mapValues { it.value.replace(PLACEHOLDER_TOKEN, token) }
.map { (key, value) -> key to value }
private companion object {
const val PLACEHOLDER_TOKEN = "{weblate_token}"
}
}