Repo created
This commit is contained in:
parent
75dc487a7a
commit
39c29d175b
6317 changed files with 388324 additions and 2 deletions
18
cli/autodiscovery-cli/build.gradle.kts
Normal file
18
cli/autodiscovery-cli/build.gradle.kts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.App.jvm)
|
||||
}
|
||||
|
||||
version = "unspecified"
|
||||
|
||||
application {
|
||||
mainClass.set("app.k9mail.cli.autodiscovery.MainKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.feature.autodiscovery.api)
|
||||
implementation(projects.feature.autodiscovery.autoconfig)
|
||||
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.clikt)
|
||||
implementation(libs.kxml2)
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
package app.k9mail.cli.autodiscovery
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings
|
||||
import app.k9mail.autodiscovery.autoconfig.AutoconfigUrlConfig
|
||||
import app.k9mail.autodiscovery.autoconfig.createIspDbAutoconfigDiscovery
|
||||
import app.k9mail.autodiscovery.autoconfig.createMxLookupAutoconfigDiscovery
|
||||
import app.k9mail.autodiscovery.autoconfig.createProviderAutoconfigDiscovery
|
||||
import com.github.ajalt.clikt.core.CliktCommand
|
||||
import com.github.ajalt.clikt.core.Context
|
||||
import com.github.ajalt.clikt.parameters.arguments.argument
|
||||
import com.github.ajalt.clikt.parameters.options.flag
|
||||
import com.github.ajalt.clikt.parameters.options.option
|
||||
import kotlin.time.measureTimedValue
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.thunderbird.core.common.mail.toUserEmailAddress
|
||||
import okhttp3.OkHttpClient.Builder
|
||||
|
||||
class AutoDiscoveryCli : CliktCommand() {
|
||||
private val httpsOnly by option(help = "Only perform Autoconfig lookups using HTTPS").flag()
|
||||
private val includeEmailAddress by option(help = "Include email address in Autoconfig lookups").flag()
|
||||
|
||||
private val emailAddress by argument(name = "email", help = "Email address")
|
||||
|
||||
override fun help(context: Context) =
|
||||
"Performs the auto-discovery steps used by Thunderbird for Android to find mail server settings"
|
||||
|
||||
override fun run() {
|
||||
echo("Attempting to find mail server settings for <$emailAddress>…")
|
||||
echo()
|
||||
|
||||
val config = AutoconfigUrlConfig(
|
||||
httpsOnly = httpsOnly,
|
||||
includeEmailAddress = includeEmailAddress,
|
||||
)
|
||||
|
||||
val (discoveryResult, duration) = measureTimedValue {
|
||||
runAutoDiscovery(config)
|
||||
}
|
||||
|
||||
if (discoveryResult is Settings) {
|
||||
echo("Found the following mail server settings:")
|
||||
AutoDiscoveryResultFormatter(::echo).output(discoveryResult)
|
||||
} else {
|
||||
echo("Couldn't find any mail server settings.")
|
||||
}
|
||||
|
||||
echo()
|
||||
echo("Duration: ${duration.inWholeMilliseconds}")
|
||||
}
|
||||
|
||||
private fun runAutoDiscovery(config: AutoconfigUrlConfig): AutoDiscoveryResult {
|
||||
val okHttpClient = Builder().build()
|
||||
try {
|
||||
val providerDiscovery = createProviderAutoconfigDiscovery(okHttpClient, config)
|
||||
val ispDbDiscovery = createIspDbAutoconfigDiscovery(okHttpClient)
|
||||
val mxDiscovery = createMxLookupAutoconfigDiscovery(okHttpClient, config)
|
||||
|
||||
val runnables = listOf(providerDiscovery, ispDbDiscovery, mxDiscovery)
|
||||
.flatMap { it.initDiscovery(emailAddress.toUserEmailAddress()) }
|
||||
val serialRunner = SerialRunner(runnables)
|
||||
|
||||
return runBlocking {
|
||||
serialRunner.run()
|
||||
}
|
||||
} finally {
|
||||
okHttpClient.dispatcher.executorService.shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package app.k9mail.cli.autodiscovery
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings
|
||||
import app.k9mail.autodiscovery.api.ImapServerSettings
|
||||
import app.k9mail.autodiscovery.api.SmtpServerSettings
|
||||
|
||||
internal class AutoDiscoveryResultFormatter(private val echo: (String) -> Unit) {
|
||||
fun output(settings: Settings) {
|
||||
val incomingServer = requireNotNull(settings.incomingServerSettings as? ImapServerSettings)
|
||||
val outgoingServer = requireNotNull(settings.outgoingServerSettings as? SmtpServerSettings)
|
||||
|
||||
echo("------------------------------")
|
||||
echo("Source: ${settings.source}")
|
||||
echo("")
|
||||
echo("Incoming server:")
|
||||
echo(" Hostname: ${incomingServer.hostname.value}")
|
||||
echo(" Port: ${incomingServer.port.value}")
|
||||
echo(" Connection security: ${incomingServer.connectionSecurity}")
|
||||
echo(" Authentication: ${incomingServer.authenticationTypes.joinToString()}")
|
||||
echo(" Username: ${incomingServer.username}")
|
||||
echo("")
|
||||
echo("Outgoing server:")
|
||||
echo(" Hostname: ${outgoingServer.hostname.value}")
|
||||
echo(" Port: ${outgoingServer.port.value}")
|
||||
echo(" Connection security: ${outgoingServer.connectionSecurity}")
|
||||
echo(" Authentication: ${outgoingServer.authenticationTypes.joinToString()}")
|
||||
echo(" Username: ${outgoingServer.username}")
|
||||
echo("------------------------------")
|
||||
if (settings.isTrusted) {
|
||||
echo("These settings have been retrieved through trusted channels.")
|
||||
} else {
|
||||
echo("At least one UNTRUSTED channel was involved in retrieving these settings.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package app.k9mail.cli.autodiscovery
|
||||
|
||||
import com.github.ajalt.clikt.core.main
|
||||
|
||||
fun main(args: Array<String>) = AutoDiscoveryCli().main(args)
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
package app.k9mail.cli.autodiscovery
|
||||
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NetworkError
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.NoUsableSettingsFound
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.Settings
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryResult.UnexpectedException
|
||||
import app.k9mail.autodiscovery.api.AutoDiscoveryRunnable
|
||||
import net.thunderbird.core.logging.legacy.Log
|
||||
|
||||
/**
|
||||
* Run a list of [AutoDiscoveryRunnable] one after the other until one returns a [Settings] result.
|
||||
*/
|
||||
class SerialRunner(private val runnables: List<AutoDiscoveryRunnable>) {
|
||||
suspend fun run(): AutoDiscoveryResult {
|
||||
var networkErrorCount = 0
|
||||
var networkError: NetworkError? = null
|
||||
|
||||
for (runnable in runnables) {
|
||||
when (val discoveryResult = runnable.run()) {
|
||||
is Settings -> {
|
||||
return discoveryResult
|
||||
}
|
||||
is NetworkError -> {
|
||||
networkErrorCount++
|
||||
if (networkError == null) {
|
||||
networkError = discoveryResult
|
||||
}
|
||||
}
|
||||
NoUsableSettingsFound -> { }
|
||||
is UnexpectedException -> {
|
||||
Log.w(discoveryResult.exception, "Unexpected exception")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (networkError != null && networkErrorCount == runnables.size) {
|
||||
networkError
|
||||
} else {
|
||||
NoUsableSettingsFound
|
||||
}
|
||||
}
|
||||
}
|
||||
17
cli/html-cleaner-cli/README.md
Normal file
17
cli/html-cleaner-cli/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
```text
|
||||
Usage: html-cleaner [OPTIONS] INPUT [OUTPUT]
|
||||
|
||||
A tool that modifies HTML to only keep allowed elements and attributes the
|
||||
same way that K-9 Mail does.
|
||||
|
||||
Options:
|
||||
-h, --help Show this message and exit
|
||||
|
||||
Arguments:
|
||||
INPUT HTML input file (needs to be UTF-8 encoded)
|
||||
OUTPUT Output file
|
||||
```
|
||||
|
||||
You can run this tool using the [html-cleaner](../../html-cleaner) script in the root directory of this repository.
|
||||
It will compile the application and then run it using the given arguments. This allows you to make modifications to the
|
||||
[HTML cleaning code](../../app/html-cleaner/src/main/java/app/k9mail/html/cleaner) and test the changes right away.
|
||||
16
cli/html-cleaner-cli/build.gradle.kts
Normal file
16
cli/html-cleaner-cli/build.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.App.jvm)
|
||||
}
|
||||
|
||||
version = "unspecified"
|
||||
|
||||
application {
|
||||
mainClass.set("app.k9mail.cli.html.cleaner.MainKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.library.htmlCleaner)
|
||||
|
||||
implementation(libs.clikt)
|
||||
implementation(libs.okio)
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
package app.k9mail.cli.html.cleaner
|
||||
|
||||
import app.k9mail.html.cleaner.HtmlHeadProvider
|
||||
import app.k9mail.html.cleaner.HtmlProcessor
|
||||
import com.github.ajalt.clikt.core.CliktCommand
|
||||
import com.github.ajalt.clikt.core.Context
|
||||
import com.github.ajalt.clikt.core.main
|
||||
import com.github.ajalt.clikt.parameters.arguments.argument
|
||||
import com.github.ajalt.clikt.parameters.arguments.optional
|
||||
import com.github.ajalt.clikt.parameters.types.file
|
||||
import com.github.ajalt.clikt.parameters.types.inputStream
|
||||
import java.io.File
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
|
||||
@Suppress("MemberVisibilityCanBePrivate")
|
||||
class HtmlCleaner : CliktCommand() {
|
||||
val input by argument(help = "HTML input file (needs to be UTF-8 encoded)")
|
||||
.inputStream()
|
||||
|
||||
val output by argument(help = "Output file")
|
||||
.file(mustExist = false, canBeDir = false)
|
||||
.optional()
|
||||
|
||||
override fun help(context: Context) =
|
||||
"A tool that modifies HTML to only keep allowed elements and attributes the same way that K-9 Mail does."
|
||||
|
||||
override fun run() {
|
||||
val html = readInput()
|
||||
val processedHtml = cleanHtml(html)
|
||||
writeOutput(processedHtml)
|
||||
}
|
||||
|
||||
private fun readInput(): String {
|
||||
return input.source().buffer().use { it.readUtf8() }
|
||||
}
|
||||
|
||||
private fun cleanHtml(html: String): String {
|
||||
val htmlProcessor = HtmlProcessor(
|
||||
object : HtmlHeadProvider {
|
||||
override val headHtml = """<meta name="viewport" content="width=device-width"/>"""
|
||||
},
|
||||
)
|
||||
|
||||
return htmlProcessor.processForDisplay(html)
|
||||
}
|
||||
|
||||
private fun writeOutput(data: String) {
|
||||
output?.writeOutput(data) ?: echo(data)
|
||||
}
|
||||
|
||||
private fun File.writeOutput(data: String) {
|
||||
sink().buffer().use {
|
||||
it.writeUtf8(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun main(args: Array<String>) = HtmlCleaner().main(args)
|
||||
17
cli/resource-mover-cli/README.md
Normal file
17
cli/resource-mover-cli/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Resource Mover CLI
|
||||
|
||||
This is a command line interface that will move resources from one module to another.
|
||||
|
||||
## Usage
|
||||
|
||||
You can run the script with the following command:
|
||||
|
||||
```bash
|
||||
./scripts/resource-mover --from <source-module-path> --to <target-module-path> --keys <keys-to-move>
|
||||
```
|
||||
|
||||
The **source-module-path** should be the path to the module that contains the resources you want to move.
|
||||
|
||||
The **target-module-path** should be the path to the module where you want to move the resources.
|
||||
|
||||
The **keys-to-move** should be the keys of the resources you want to move. You can pass multiple keys separated by a comma.
|
||||
13
cli/resource-mover-cli/build.gradle.kts
Normal file
13
cli/resource-mover-cli/build.gradle.kts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.App.jvm)
|
||||
}
|
||||
|
||||
version = "unspecified"
|
||||
|
||||
application {
|
||||
mainClass.set("net.thunderbird.cli.resource.mover.MainKt")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.clikt)
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.thunderbird.cli.resource.mover
|
||||
|
||||
import com.github.ajalt.clikt.core.main
|
||||
|
||||
fun main(args: Array<String>) = ResourceMoverCli().main(args)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package net.thunderbird.cli.resource.mover
|
||||
|
||||
import com.github.ajalt.clikt.core.CliktCommand
|
||||
import com.github.ajalt.clikt.core.Context
|
||||
import com.github.ajalt.clikt.parameters.options.option
|
||||
import com.github.ajalt.clikt.parameters.options.required
|
||||
import com.github.ajalt.clikt.parameters.options.split
|
||||
|
||||
class ResourceMoverCli(
|
||||
private val stringResourceMover: StringResourceMover = StringResourceMover(),
|
||||
) : CliktCommand(
|
||||
name = "resource-mover",
|
||||
) {
|
||||
private val from: String by option(
|
||||
help = "Source module path",
|
||||
).required()
|
||||
|
||||
private val to: String by option(
|
||||
help = "Target module path",
|
||||
).required()
|
||||
|
||||
private val keys: List<String> by option(
|
||||
help = "Keys to move",
|
||||
).split(",").required()
|
||||
|
||||
override fun help(context: Context): String = "Move string resources from one file to another"
|
||||
|
||||
override fun run() {
|
||||
stringResourceMover.moveKeys(from, to, keys)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
package net.thunderbird.cli.resource.mover
|
||||
|
||||
import java.io.File
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@Suppress("TooManyFunctions")
|
||||
class StringResourceMover {
|
||||
|
||||
fun moveKeys(source: String, target: String, keys: List<String>) {
|
||||
val sourcePath = File(source + RESOURCE_PATH)
|
||||
val targetPath = File(target + RESOURCE_PATH)
|
||||
|
||||
if (!sourcePath.exists()) {
|
||||
println("\nSource path does not exist: $sourcePath\n")
|
||||
return
|
||||
}
|
||||
|
||||
println("\nMoving keys $keys")
|
||||
println(" from \"$sourcePath\" -> \"$targetPath\"\n")
|
||||
for (key in keys) {
|
||||
moveKey(sourcePath, targetPath, key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveKey(sourcePath: File, targetPath: File, key: String) {
|
||||
println("\nMoving key: $key\n")
|
||||
|
||||
sourcePath.walk()
|
||||
.filter { it.name.startsWith(VALUES_PATH) }
|
||||
.forEach { sourceDir ->
|
||||
val sourceFile = sourceDir.resolve(STRING_RESOURCE_FILE_NAME)
|
||||
if (sourceFile.exists()) {
|
||||
moveKeyDeclaration(sourceFile, targetPath, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveKeyDeclaration(sourceFile: File, targetPath: File, key: String) {
|
||||
if (containsKey(sourceFile, key)) {
|
||||
println("\nFound key in file: ${sourceFile.path}\n")
|
||||
|
||||
val targetFile = getOrCreateTargetFile(targetPath, sourceFile)
|
||||
val keyDeclaration = extractKeyDeclaration(sourceFile, key)
|
||||
|
||||
println(" Key declaration: $keyDeclaration")
|
||||
|
||||
copyKeyToTarget(targetFile, keyDeclaration, key)
|
||||
deleteKeyFromSource(sourceFile, keyDeclaration)
|
||||
|
||||
if (isSourceFileEmpty(sourceFile)) {
|
||||
println(" Source file is empty: ${sourceFile.path} -> deleting it.")
|
||||
sourceFile.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun containsKey(sourceFile: File, key: String): Boolean {
|
||||
val keyPattern = createKeyPattern(key)
|
||||
val sourceContent = sourceFile.readText()
|
||||
return keyPattern.containsMatchIn(sourceContent)
|
||||
}
|
||||
|
||||
private fun extractKeyDeclaration(sourceFile: File, key: String): String {
|
||||
val keyPattern = createKeyPattern(key)
|
||||
val declaration = StringBuilder()
|
||||
var isTagClosed = true
|
||||
|
||||
sourceFile.forEachLine { line ->
|
||||
if (keyPattern.containsMatchIn(line)) {
|
||||
declaration.appendLine(line)
|
||||
isTagClosed = isTagClosed(line)
|
||||
} else if (!isTagClosed) {
|
||||
declaration.appendLine(line)
|
||||
isTagClosed = isTagClosed(line)
|
||||
}
|
||||
}
|
||||
|
||||
return declaration.toString()
|
||||
}
|
||||
|
||||
private fun createKeyPattern(key: String): Regex {
|
||||
return KEY_PATTERN.replace(KEY_PLACEHOLDER, Regex.escape(key)).toRegex()
|
||||
}
|
||||
|
||||
private fun isTagClosed(line: String): Boolean {
|
||||
return line.contains(STRING_CLOSING_TAG) || line.contains(PLURALS_CLOSING_TAG)
|
||||
}
|
||||
|
||||
private fun copyKeyToTarget(targetFile: File, keyDeclaration: String, key: String) {
|
||||
println(" Moving key to file: ${targetFile.path}")
|
||||
|
||||
if (containsKey(targetFile, key)) {
|
||||
println(" Key already exists in target file: ${targetFile.path} replacing it.")
|
||||
replaceKeyInTarget(targetFile, keyDeclaration, key)
|
||||
} else {
|
||||
addKeyToTarget(targetFile, keyDeclaration)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addKeyToTarget(targetFile: File, keyDeclaration: String) {
|
||||
val targetContent = StringBuilder()
|
||||
|
||||
targetFile.forEachLine { line ->
|
||||
if (line.contains(RESOURCE_CLOSING_TAG)) {
|
||||
targetContent.appendLine(keyDeclaration.trimEnd())
|
||||
targetContent.appendLine(line)
|
||||
} else {
|
||||
targetContent.appendLine(line)
|
||||
}
|
||||
}
|
||||
|
||||
targetFile.writeText(targetContent.toString())
|
||||
}
|
||||
|
||||
private fun replaceKeyInTarget(targetFile: File, keyDeclaration: String, key: String) {
|
||||
println(" Replacing key in file: ${targetFile.path}")
|
||||
|
||||
val oldKeyDeclaration = extractKeyDeclaration(targetFile, key)
|
||||
val targetContent = targetFile.readText()
|
||||
|
||||
targetFile.writeText(targetContent.replace(oldKeyDeclaration, keyDeclaration))
|
||||
}
|
||||
|
||||
private fun deleteKeyFromSource(sourceFile: File, keyDeclaration: String) {
|
||||
println(" Deleting key from file: ${sourceFile.path}")
|
||||
|
||||
val sourceContent = sourceFile.readText()
|
||||
|
||||
sourceFile.writeText(sourceContent.replace(keyDeclaration, ""))
|
||||
}
|
||||
|
||||
private fun isSourceFileEmpty(sourceFile: File): Boolean {
|
||||
val sourceContent = sourceFile.readText()
|
||||
return sourceContent.contains(STRING_CLOSING_TAG).not() && sourceContent.contains(PLURALS_CLOSING_TAG).not()
|
||||
}
|
||||
|
||||
private fun getOrCreateTargetFile(targetPath: File, sourceFile: File): File {
|
||||
val targetFilePath = targetPath.resolve(sourceFile.parentFile.name)
|
||||
val targetFile = File(targetFilePath, sourceFile.name)
|
||||
val targetDirectory = targetFile.parentFile
|
||||
|
||||
if (!targetDirectory.exists()) {
|
||||
targetDirectory.mkdirs()
|
||||
println(" Target directory created: ${targetDirectory.path}")
|
||||
}
|
||||
|
||||
if (!targetFile.exists()) {
|
||||
createTargetFile(targetFile)
|
||||
}
|
||||
|
||||
return targetFile
|
||||
}
|
||||
|
||||
private fun createTargetFile(targetFile: File) {
|
||||
val isNewFileCreated: Boolean = targetFile.createNewFile()
|
||||
if (!isNewFileCreated) {
|
||||
printError("Target file could not be created: ${targetFile.path}")
|
||||
exitProcess(-1)
|
||||
}
|
||||
|
||||
targetFile.writeText(TARGET_FILE_CONTENT)
|
||||
println("Target file ${targetFile.path} created")
|
||||
}
|
||||
|
||||
private fun printError(message: String) {
|
||||
System.err.println("\n$message\n")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RESOURCE_PATH = "/src/main/res/"
|
||||
const val KEY_PLACEHOLDER = "{KEY}"
|
||||
const val KEY_PATTERN = """name="$KEY_PLACEHOLDER""""
|
||||
const val VALUES_PATH = "values"
|
||||
const val STRING_RESOURCE_FILE_NAME = "strings.xml"
|
||||
const val STRING_CLOSING_TAG = "</string>"
|
||||
const val PLURALS_CLOSING_TAG = "</plurals>"
|
||||
const val RESOURCE_CLOSING_TAG = "</resources>"
|
||||
|
||||
val TARGET_FILE_CONTENT = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
</resources>
|
||||
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
23
cli/translation-cli/README.md
Normal file
23
cli/translation-cli/README.md
Normal 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.
|
||||
20
cli/translation-cli/build.gradle.kts
Normal file
20
cli/translation-cli/build.gradle.kts
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
package net.thunderbird.cli.translation
|
||||
|
||||
import com.github.ajalt.clikt.core.main
|
||||
|
||||
fun main(args: Array<String>) = TranslationCli().main(args)
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue