diff --git a/.gitignore b/.gitignore index ac327fc782d..3a374d855b9 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ eml-files/ # Merged PRs report merged-prs-*.md merged-prs-*.csv + +# Weblate cache +cli/weblate-cli/weblate-components-cache.json diff --git a/README.md b/README.md index 05d73ac8820..5179fa7d2ed 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ tracked in our [sprint board](https://github.com/orgs/thunderbird/projects/20/vi We welcome contributions from everyone. - Development: Have you done a little bit of Kotlin? The [CONTRIBUTING](docs/CONTRIBUTING.md) guide will help you get started -- Translations: Do you speak a language aside from English? [Translating is easy](https://hosted.weblate.org/projects/tb-android/) and just takes a few minutes for your first success. +- Translations: Do you speak a language aside from English? [Translating is easy](https://hosted.weblate.org/projects/thunderbird/thunderbird-android/) and just takes a few minutes for your first success. - We have [a number of other contribution opportunities](https://blog.thunderbird.net/2024/09/contribute-to-thunderbird-for-android/) available. - Thunderbird is supported solely by financial contributions from users like you. [Make a financial contribution today](https://www.thunderbird.net/donate/mobile/?form=tfa)! - Make sure to check out the [Mozilla Community Participation Guidelines](https://www.mozilla.org/about/governance/policies/participation/). diff --git a/cli/translation-cli/README.md b/cli/translation-cli/README.md index c75a41de784..36c1e845877 100644 --- a/cli/translation-cli/README.md +++ b/cli/translation-cli/README.md @@ -1,6 +1,6 @@ # 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. +This is a command line interface that will check the [weblate](https://hosted.weblate.org/projects/thunderbird/thunderbird-android/#languages) translation state for all languages and print out the ones that are above a certain threshold. ## Usage @@ -9,11 +9,13 @@ To use this script you need to have a [weblate token](https://hosted.weblate.org You can run the script with the following command: ```bash -./scripts/translation --token [--threshold 70] +./scripts/translation --token [--threshold 70] [--log-level INFO] ``` 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. +The `--log-level` option controls the verbosity of the Weblate API client's logs. Supported values are: `NONE`, `INFO`, `HEADERS`, `BODY`, `ALL`. The default is `NONE`. + 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 diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/LanguageCodeLoader.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/LanguageCodeLoader.kt index 24693224f58..f651520eb77 100644 --- a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/LanguageCodeLoader.kt +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/LanguageCodeLoader.kt @@ -3,9 +3,11 @@ 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 +import net.thunderbird.cli.translation.net.WeblateConfig class LanguageCodeLoader( - private val client: WeblateClient = WeblateClient(), + config: WeblateConfig = WeblateConfig(), + private val client: WeblateClient = WeblateClient(config = config), ) { fun loadCurrentAndroidLanguageCodes(token: String, threshold: Double): List { val languages = client.loadLanguages(token) diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/TranslationCli.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/TranslationCli.kt index d85aacd937a..9f0623782af 100644 --- a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/TranslationCli.kt +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/TranslationCli.kt @@ -7,6 +7,9 @@ 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 +import com.github.ajalt.clikt.parameters.types.enum +import io.ktor.client.plugins.logging.LogLevel +import net.thunderbird.cli.translation.net.WeblateConfig const val TRANSLATED_THRESHOLD = 70.0 @@ -29,9 +32,16 @@ class TranslationCli( help = "Print code example", ).flag() + private val logLevel: LogLevel by option( + "--log-level", + help = "Log level for the Weblate API client", + ).enum(ignoreCase = true).default(LogLevel.NONE) + override fun help(context: Context): String = "Translation CLI" override fun run() { + val config = WeblateConfig(logLevel = logLevel) + val languageCodeLoader = LanguageCodeLoader(config = config) val languageCodes = languageCodeLoader.loadCurrentAndroidLanguageCodes(token, threshold) val androidLanguageCodes = languageCodes.map { AndroidLanguageCodeHelper.fixLanguageCodeFormat(it) } val size = languageCodes.size diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateClient.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateClient.kt index 7121f44a2b5..64ffa2aa2e9 100644 --- a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateClient.kt +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateClient.kt @@ -5,7 +5,6 @@ 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 @@ -15,8 +14,8 @@ import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json class WeblateClient( - private val client: HttpClient = createClient(), private val config: WeblateConfig = WeblateConfig(), + private val client: HttpClient = createClient(config), ) { fun loadLanguages(token: String): List { val languages: List @@ -63,11 +62,11 @@ class WeblateClient( } private companion object { - fun createClient(): HttpClient { + fun createClient(config: WeblateConfig): HttpClient { return HttpClient(CIO) { install(Logging) { logger = Logger.DEFAULT - level = LogLevel.NONE + level = config.logLevel } install(ContentNegotiation) { json( diff --git a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateConfig.kt b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateConfig.kt index 8109431ef28..0b03a11a281 100644 --- a/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateConfig.kt +++ b/cli/translation-cli/src/main/kotlin/net/thunderbird/cli/translation/net/WeblateConfig.kt @@ -1,16 +1,20 @@ package net.thunderbird.cli.translation.net +import io.ktor.client.plugins.logging.LogLevel + /** * 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 + * @property logLevel Log level for the Weblate API client */ data class WeblateConfig( val baseUrl: String = "https://hosted.weblate.org/api/", - val projectName: String = "tb-android", - val defaultComponent: String = "app-strings", + val projectName: String = "thunderbird", + val defaultComponent: String = "thunderbird-android%252Fapp-common", + val logLevel: LogLevel = LogLevel.NONE, private val defaultHeaders: Map = mapOf( "Accept" to "application/json", "Authorization" to "Token $PLACEHOLDER_TOKEN", diff --git a/cli/weblate-cli/README.md b/cli/weblate-cli/README.md index 27b1d311988..56ec091d068 100644 --- a/cli/weblate-cli/README.md +++ b/cli/weblate-cli/README.md @@ -1,33 +1,54 @@ # Weblate CLI This is a command line interface that inspects Weblate project components and applies a -"golden" component configuration. It's intended for maintainers to review component configuration -consistency and, when appropriate, patch components to match the golden config. +"default" component configuration. It's intended for maintainers to review component configuration +consistency and, when appropriate, patch components to match the component config. ## Usage You need a Weblate API token (available from your Weblate account profile). A convenience wrapper script is provided at `./scripts/weblate` which builds and runs the CLI. +The CLI uses a subcommand pattern: `weblate [OPTIONS] COMMAND [ARGS]...` + +Available commands: +- `update`: Update managed components with the standard configuration. +- `create`: Create missing components on Weblate based on local modules. +- `delete`: Delete a component from Weblate. + Basic examples: ```bash -# Dry-run using the default golden config and include file -./scripts/weblate --token YOUR_WEBLATE_TOKEN --dry-run +# Dry-run using the default configuration and managed components file +./scripts/weblate --token YOUR_WEBLATE_TOKEN --dry-run update + +# Apply changes to managed components +./scripts/weblate --token YOUR_WEBLATE_TOKEN update -# Apply changes to included components -./scripts/weblate --token YOUR_WEBLATE_TOKEN +# Create missing components +./scripts/weblate --token YOUR_WEBLATE_TOKEN create -# Use a custom include file and golden config -./scripts/weblate --token YOUR_WEBLATE_TOKEN --include-file-path ./cli/weblate-cli/include-components.txt --golden-config-path ./cli/weblate-cli/golden-component-config.json --dry-run +# Delete a component by slug +./scripts/weblate --token YOUR_WEBLATE_TOKEN delete --slug component-slug-to-delete + +# Use a custom managed components file, component config, and set log level to ALL +./scripts/weblate --token YOUR_WEBLATE_TOKEN --managed-components-file ./cli/weblate-cli/managed-components.txt --component-config-file ./cli/weblate-cli/default-component-config.json --log-level ALL --dry-run update ``` +## Available options + +- `--token`: Weblate API token (required). +- `--component-config-file`: Path to component config JSON (default: `./cli/weblate-cli/default-component-config.json`). +- `--managed-components-file`: Path to file with managed component slugs (default: `./cli/weblate-cli/managed-components.txt`). +- `--dry-run`: Dry run the command without making any changes. +- `--log-level`: Log level for the Weblate API client (`NONE`, `INFO`, `HEADERS`, `BODY`, `ALL`). Default: `NONE`. + ## Defaults -- Golden config: `./cli/weblate-cli/golden-component-config.json` -- Include file: `./cli/weblate-cli/include-components.txt` +- Component config: `./cli/weblate-cli/default-component-config.json` +- Managed components file: `./cli/weblate-cli/managed-components.txt` -## Include file format +## Managed components file format - One component slug per non-empty line. Inline comments are allowed after `#` and full-line comments that start with `#` are ignored. @@ -45,4 +66,6 @@ app-common ## Safety notes - Always run with `--dry-run` first to verify diffs before applying changes to the live Weblate instance. +- The `update` command only processes components listed in the managed components file. +- Use `./scripts/weblate --help` to see all available commands and options. diff --git a/cli/weblate-cli/golden-component-config.json b/cli/weblate-cli/default-component-config.json similarity index 92% rename from cli/weblate-cli/golden-component-config.json rename to cli/weblate-cli/default-component-config.json index 579d678954c..db004cfa5d4 100644 --- a/cli/weblate-cli/golden-component-config.json +++ b/cli/weblate-cli/default-component-config.json @@ -30,7 +30,7 @@ "commit_pending_age": 24, "auto_lock_error": true, - "commit_message": "chore(i18n): translated using Weblate\r\n\r\nTranslation: {{ project_name }}/{{ component_name }}\r\nTranslate-URL: {{ url }}", + "commit_message": "chore(i18n): translated using Weblate ({{ language_name }})\r\n\r\nTranslation: {{ project_name }}/{{ component_name }}\r\nTranslate-URL: {{ url }}", "add_message": "chore(i18n): added translation using Weblate ({{ language_name }})", "delete_message": "chore(i18n): deleted translation using Weblate ({{ language_name }})", "merge_message": "chore(i18n): merge branch '{{ component_remote_branch }}' into Weblate", diff --git a/cli/weblate-cli/include-components.txt b/cli/weblate-cli/include-components.txt deleted file mode 100644 index 783d2630497..00000000000 --- a/cli/weblate-cli/include-components.txt +++ /dev/null @@ -1,30 +0,0 @@ -# legacy -app-strings # ID: 17093 (main) -designsystem # ID: 25913 -account-common # ID: 25914 -account-setup # ID: 25915 -account-server-validation # ID: 25916 -account-server-settings # ID: 25917 -account-oauth # ID: 25918 -onboarding # ID: 25919 -onboarding-permissions # ID: 26293 -account-server-certificate # ID: 27694 -app-ui-base # ID: 27803 -settings-import # ID: 27804 -widget-unread # ID: 29555 -app-k9mail # ID: 29573 -app-thunderbird # ID: 29574 -widget-message-list # ID: 29632 -widget-shortcut # ID: 29717 -legacy-ui-folder # ID: 30127 -migration-qrcode # ID: 31033 -funding-googleplay # ID: 31077 -onboarding-migration # ID: 31088 -navigation-drawer-dropdown # ID: 34347 -app-common # ID: 37829 -core-ui-setting-dialog # ID: 37836 -feature-account-settings-impl # ID: 37837 -feature-mail-message-composer # ID: 37838 -# feature-mail-message-list # ID: 37839 -feature-widget-message-list-glance # ID: 37840 -feature-notification-api # ID: 37851 diff --git a/cli/weblate-cli/managed-components.txt b/cli/weblate-cli/managed-components.txt new file mode 100644 index 00000000000..5fa153d78be --- /dev/null +++ b/cli/weblate-cli/managed-components.txt @@ -0,0 +1,37 @@ +# Managed components +## Main Component +app-common # ID: 44665 +## Child Components +core-ui-compose-designsystem # ID: 49031 +legacy-ui-base # ID: 49036 +legacy-ui-folder # ID: 49035 +legacy-ui-legacy # ID: 44667 +app-thunderbird # ID: 46953 +app-k9mail # ID: 46954 +core-ui-setting-impl-dialog # ID: 46955 +core-ui-design-system # ID: 46956 +feature-account-common # ID: 46957 +feature-account-edit # ID: 46958 +feature-account-oauth # ID: 46959 +feature-account-server-certificate # ID: 46960 +feature-account-server-settings # ID: 46961 +feature-account-server-validation # ID: 49047 +feature-account-settings-impl # ID: 46962 +feature-account-setup # ID: 46963 +feature-debug-settings # ID: 49053 +feature-funding-googleplay # ID: 49052 +feature-mail-message-composer # ID: 49043 +feature-mail-message-list-api # ID: 49045 +feature-mail-message-list-internal # ID: 49044 +feature-migration-qrcode # ID: 49048 +feature-navigation-drawer-dropdown # ID: 49046 +feature-notification-api # ID: 49042 +feature-onboarding-migration-thunderbird # ID: 49051 +feature-onboarding-permissions # ID: 49050 +feature-onboarding-welcome # ID: 49049 +feature-settings-import # ID: 49037 +feature-widget-message-list # ID: 49039 +feature-widget-message-list-glance # ID: 49040 +feature-widget-shortcut # ID: 49038 +feature-widget-unread # ID: 49041 + diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/CliConfig.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/CliConfig.kt new file mode 100644 index 00000000000..1e7fb4c3656 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/CliConfig.kt @@ -0,0 +1,11 @@ +package net.thunderbird.cli.weblate + +import io.ktor.client.plugins.logging.LogLevel + +data class CliConfig( + val token: String, + val componentConfigFile: String, + val managedComponentsFile: String, + val dryRun: Boolean, + val logLevel: LogLevel, +) diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/Main.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/Main.kt index dc66dc22a0a..e3611285ddc 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/Main.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/Main.kt @@ -1,5 +1,14 @@ package net.thunderbird.cli.weblate import com.github.ajalt.clikt.core.main +import com.github.ajalt.clikt.core.subcommands +import net.thunderbird.cli.weblate.command.CreateComponent +import net.thunderbird.cli.weblate.command.DeleteComponent +import net.thunderbird.cli.weblate.command.UpdateComponent -fun main(args: Array) = WeblateCli().main(args) +fun main(args: Array) = WeblateCli() + .subcommands( + UpdateComponent(), + CreateComponent(), + DeleteComponent(), + ).main(args) diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/WeblateCli.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/WeblateCli.kt index 9b5b062ac17..eaf40a88e00 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/WeblateCli.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/WeblateCli.kt @@ -6,120 +6,46 @@ 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 java.io.File -import net.thunderbird.cli.weblate.api.Component -import net.thunderbird.cli.weblate.api.ComponentConfig -import net.thunderbird.cli.weblate.api.ComponentPatch -import net.thunderbird.cli.weblate.api.WeblateClient +import com.github.ajalt.clikt.parameters.types.enum +import io.ktor.client.plugins.logging.LogLevel @Suppress("TooGenericExceptionCaught") class WeblateCli : CliktCommand( name = "weblate", ) { - private val token: String by option( + internal val token: String by option( help = "Weblate API token", ).required() - private val dryRun: Boolean by option( + internal val componentConfigFile: String by option( + "--component-config-file", + help = "Path to component config JSON", + ).default("./cli/weblate-cli/default-component-config.json") + + internal val managedComponentsFile: String by option( + "--managed-components-file", + help = "Path to file with managed component slugs (one per line, '#' comments)", + ).default("./cli/weblate-cli/managed-components.txt") + internal val dryRun: Boolean by option( help = "Dry run the command without making any changes", ).flag() - private val goldenConfigPath: String by option( - help = "Path to golden component config JSON", - ).default("./cli/weblate-cli/golden-component-config.json") - - private val includeFilePath: String by option( - help = "Path to file with component slug to include (one per line, '#' comments)", - ).default("./cli/weblate-cli/include-components.txt") + internal val logLevel: LogLevel by option( + "--log-level", + help = "Log level for the Weblate API client", + ).enum(ignoreCase = true).default(LogLevel.NONE) override fun help(context: Context): String = "Weblate CLI" override fun run() { - val goldenConfig = loadGoldenConfig(goldenConfigPath) - val includeConfig = loadIncludeConfig(includeFilePath) - - val client = WeblateClient() - val components = client.loadComponents(token) - - println("Loaded ${components.size} components:") - - components.forEach { component -> - println() - println("- ${component.info.name} (slug: ${component.info.slug} # ID: ${component.info.id}) ") - println() - - if (!includeConfig.contains(component.info.slug)) { - println(" ⏭\uFE0F skipped (not listed in include file)") - } else { - processComponent(component, goldenConfig, client) - } - println() - } - } - - @Suppress("NestedBlockDepth") - private fun processComponent(component: Component, goldenConfig: ComponentConfig, client: WeblateClient) { - val diffs = ComponentConfigDiff.computeConfigDiff(goldenConfig, component.config, 1) - - if (diffs.isEmpty()) { - println(" ✅ Config matches common config") - } else { - println(" ⚠\uFE0F Config differs:") - println() - diffs.forEach { println(" $it") } - if (!dryRun) { - try { - val result = client.patchComponent( - token, - component.info.url, - ComponentPatch( - category = component.info.category, - linkedComponent = component.info.linkedComponent, - config = goldenConfig, - ), - ) - if (result) { - println(" ✅ Updated component config successfully") - } else { - println(" ❌ Failed to update component config: API request failed") - } - } catch (e: Exception) { - println(" ❌ Failed to update component config: ${e.message}") - } - } - } - } - - private fun loadGoldenConfig(path: String): ComponentConfig { - val file = File(path) - if (!file.exists()) { - error("Golden config file not found: $path") - } - - return try { - ComponentConfigLoader().load(file) - } catch (e: Exception) { - error("Failed to load golden config: ${e.message}") - } - } - - private fun loadIncludeConfig(path: String): Set { - val file = File(path) - if (!file.exists()) { - error("Include file not found: $file — no components will be managed") - } - - return try { - file.readLines() - .map { it.trim() } - .map { line -> - // Remove inline comments safely; substringBefore handles missing '#' - line.substringBefore('#').trim() - } - .filter { it.isNotEmpty() } - .toSet() - } catch (e: Exception) { - error("Failed to read include file $file: ${e.message}") + currentContext.findOrSetObject { + CliConfig( + token = token, + componentConfigFile = componentConfigFile, + managedComponentsFile = managedComponentsFile, + dryRun = dryRun, + logLevel = logLevel, + ) } } } diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/Component.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/Component.kt index 8439d5b4689..c9b2a9d4060 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/Component.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/Component.kt @@ -40,7 +40,19 @@ data class Component( encoder: Encoder, value: Component, ) { - error("Component serialization is not supported") + require(encoder is kotlinx.serialization.json.JsonEncoder) { + "Expected JsonEncoder, got ${encoder::class.simpleName}" + } + + val info = encoder.json.encodeToJsonElement(ComponentInfo.serializer(), value.info) + val config = encoder.json.encodeToJsonElement(ComponentConfig.serializer(), value.config) + + val jsonObject = kotlinx.serialization.json.buildJsonObject { + info.jsonObject.forEach { (key, value) -> put(key, value) } + config.jsonObject.forEach { (key, value) -> put(key, value) } + } + + encoder.encodeJsonElement(jsonObject) } } } diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentCreate.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentCreate.kt new file mode 100644 index 00000000000..6e614b7af20 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentCreate.kt @@ -0,0 +1,98 @@ +package net.thunderbird.cli.weblate.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.put + +/** + * Weblate Component Creation Payload + * + * @property name The name of the component + * @property slug The slug identifier of the component + * @property project The project url of the component + * @property fileMask The file mask for translations + * @property template The template for translations + * @property fileFormat The file format (e.g., android: "aresource", compose-resource: "cmp-resource") + * @property category The category url of the component + * @property linkedComponent The url of the linked component to use as a base + * @property repo The repository URL of the component + * @property vcs The VCS type (e.g., git, github) + * @property mergeStyle The merge style + * @property config The configuration of the component + */ +@Serializable(with = ComponentCreate.ComponentCreateSerializer::class) +data class ComponentCreate( + val name: String, + val slug: String, + val project: String, + @SerialName("filemask") + val fileMask: String, + val template: String, + @SerialName("file_format") + val fileFormat: String, + val category: String? = null, + @SerialName("linked_component") + val linkedComponent: String? = null, + val repo: String, + val vcs: String, + @SerialName("merge_style") + val mergeStyle: String, + val config: ComponentConfig, +) { + companion object ComponentCreateSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ComponentCreate") { + element("name") + element("slug") + element("project") + element("filemask") + element("template") + element("file_format") + element("category", isOptional = true) + element("linked_component", isOptional = true) + element("repo") + element("vcs") + element("merge_style") + element("config", ComponentConfig.serializer().descriptor) + } + + override fun deserialize(decoder: Decoder): ComponentCreate { + error("Deserialization is not supported for ComponentCreate") + } + + override fun serialize(encoder: Encoder, value: ComponentCreate) { + require(encoder is JsonEncoder) { + "Expected JsonEncoder, got ${encoder::class.simpleName}" + } + + val json = buildJsonObject { + put("name", value.name) + put("slug", value.slug) + put("project", value.project) + put("filemask", value.fileMask) + put("template", value.template) + put("file_format", value.fileFormat) + value.category?.let { put("category", it) } + value.linkedComponent?.let { put("linked_component", it) } + put("repo", value.repo) + put("vcs", value.vcs) + put("merge_style", value.mergeStyle) + + val configJson = encoder.json.encodeToJsonElement(ComponentConfig.serializer(), value.config) + configJson.jsonObject.forEach { (key, value) -> + put(key, value) + } + } + + encoder.encodeJsonElement(json) + } + } +} diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentInfo.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentInfo.kt index 35c3f81b2fa..a2b35f017eb 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentInfo.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentInfo.kt @@ -22,4 +22,5 @@ data class ComponentInfo( val category: String?, @SerialName("linked_component") val linkedComponent: String?, + val locked: Boolean = false, ) diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentPatch.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentPatch.kt index 5079c498f0f..15165e7d144 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentPatch.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentPatch.kt @@ -28,12 +28,14 @@ data class ComponentPatch( @SerialName("linked_component") val linkedComponent: String?, val config: ComponentConfig, + val locked: Boolean, ) { companion object ComponentPatchSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ComponentPatch") { element("category") element("linked_component") element("config", ComponentConfig.serializer().descriptor) + element("locked") } override fun deserialize(decoder: Decoder): ComponentPatch { @@ -50,6 +52,7 @@ data class ComponentPatch( val json = buildJsonObject { value.category?.let { put("category", it) } value.linkedComponent?.let { put("linked_component", it) } + put("locked", value.locked) config.jsonObject.forEach { (key, value) -> put(key, value) } diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateClient.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateClient.kt index d012f958056..c4148d9b1f6 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateClient.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateClient.kt @@ -8,91 +8,145 @@ 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.delete import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.patch +import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.http.contentType import io.ktor.http.headers import io.ktor.serialization.kotlinx.json.json +import java.io.File import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json class WeblateClient( - private val client: HttpClient = createClient(), + private val token: String, private val config: WeblateConfig = WeblateConfig(), + private val logLevel: LogLevel = LogLevel.INFO, + private val client: HttpClient = createClient(logLevel), ) { - fun loadComponents(token: String): List { + fun loadComponents(): List { + if (config.cacheEnabled) { + try { + val cached = readComponentsFromCache() + if (cached.isNotEmpty()) { + return cached + } + } catch (_: Exception) { + } + } + val components = mutableListOf() var page = 1 var hasNextPage = true while (hasNextPage) { - val componentPage = loadComponentPage(token, page) + val componentPage = loadComponentPage(page) components.addAll(componentPage.results) hasNextPage = componentPage.next != null page++ } + if (config.cacheEnabled) { + try { + writeComponentsToCache(components) + } catch (_: Exception) { + } + } + return components } - fun patchComponent(token: String, url: String, patch: ComponentPatch): Boolean { - var success = false - - runBlocking { + fun patchComponent(url: String, patch: ComponentPatch): Boolean { + return runBlocking { val response = client.patch(url) { - header(HttpHeaders.ContentType, "application/json") header(HttpHeaders.Authorization, "Token $token") contentType(ContentType.Application.Json) setBody(patch) } - success = response.status.value in SUCCESS + response.status.value in SUCCESS } + } - return success + fun createComponent(create: ComponentCreate): Boolean { + return runBlocking { + val url = "${config.baseUrl}projects/${config.projectName}/components/" + val response = client.post(url) { + header(HttpHeaders.Authorization, "Token $token") + contentType(ContentType.Application.Json) + setBody(create) + } + + response.status.value in SUCCESS + } } - private fun loadComponentPage(token: String, page: Int): ComponentResponse { - val componentResponse: ComponentResponse + fun deleteComponent(url: String): Boolean { + return runBlocking { + val response = client.delete(url) { + header(HttpHeaders.Authorization, "Token $token") + } - runBlocking { - componentResponse = client.get(config.componentsUrl(page)) { + response.status.value in SUCCESS + } + } + + private fun loadComponentPage(page: Int): ComponentResponse { + return runBlocking { + client.get(config.componentsUrl(page)) { headers { config.getDefaultHeaders(token).forEach { (key, value) -> append(key, value) } } }.body() } + } - return componentResponse + private fun readComponentsFromCache(): List { + val cacheFile = File(CACHE_FILE_PATH) + if (cacheFile.exists()) { + return json.decodeFromString>(cacheFile.readText()) + } + return emptyList() + } + + private fun writeComponentsToCache(components: List) { + if (components.isEmpty()) return + + val cacheFile = File(CACHE_FILE_PATH) + cacheFile.parentFile.mkdirs() + cacheFile.writeText(json.encodeToString(components)) } private companion object { val SUCCESS = 200..299 - fun createClient(): HttpClient { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + } + + fun createClient(logLevel: LogLevel): HttpClient { return HttpClient(CIO) { install(Logging) { logger = Logger.DEFAULT - level = LogLevel.INFO + level = logLevel } install(ContentNegotiation) { - json( - Json { - ignoreUnknownKeys = true - encodeDefaults = true - explicitNulls = false - }, - ) + json(json) } } } private fun WeblateConfig.componentsUrl(page: Int) = "${baseUrl}projects/$projectName/components/?page=$page" + + private const val CACHE_FILE_PATH = "cli/weblate-cli/weblate-components-cache.json" } } diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateConfig.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateConfig.kt index e6596eabdb9..7b56f902eef 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateConfig.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateConfig.kt @@ -6,15 +6,17 @@ package net.thunderbird.cli.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 + * @property cacheEnabled Whether caching is enabled for API responses */ data class WeblateConfig( val baseUrl: String = "https://hosted.weblate.org/api/", - val projectName: String = "tb-android", - val defaultComponent: String = "app-strings", + val projectName: String = "thunderbird", + val defaultComponent: String = "app-common", private val defaultHeaders: Map = mapOf( "Accept" to "application/json", "Authorization" to "Token $PLACEHOLDER_TOKEN", ), + val cacheEnabled: Boolean = false, ) { fun getDefaultHeaders(token: String): List> = defaultHeaders.mapValues { it.value.replace(PLACEHOLDER_TOKEN, token) } diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/BaseCommand.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/BaseCommand.kt new file mode 100644 index 00000000000..dd57f8104ab --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/BaseCommand.kt @@ -0,0 +1,76 @@ +package net.thunderbird.cli.weblate.command + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.requireObject +import java.io.File +import net.thunderbird.cli.weblate.CliConfig +import net.thunderbird.cli.weblate.ComponentConfigLoader +import net.thunderbird.cli.weblate.api.ComponentConfig +import net.thunderbird.cli.weblate.api.WeblateClient +import net.thunderbird.cli.weblate.project.ModuleInfo + +@Suppress("TooGenericExceptionCaught") +abstract class BaseCommand(name: String) : CliktCommand(name = name) { + + internal val config by requireObject() + + override fun run() { + val defaultComponentConfig = loadComponentConfig(config.componentConfigFile) + val managedComponents = loadManagedConfig(config.managedComponentsFile) + + val client = WeblateClient(token = config.token, logLevel = config.logLevel) + + onRun(client, defaultComponentConfig, managedComponents) + } + + abstract fun onRun( + client: WeblateClient, + defaultComponentConfig: ComponentConfig, + managedComponents: Set, + ) + + private fun loadComponentConfig(path: String): ComponentConfig { + val file = File(path) + if (!file.exists()) { + error("Component config file not found: $path") + } + + return try { + ComponentConfigLoader().load(file) + } catch (e: Exception) { + error("Failed to load component config: ${e.message}") + } + } + + private fun loadManagedConfig(path: String): Set { + val file = File(path) + if (!file.exists()) { + error("Managed components file not found: $file — no components will be managed") + } + + return try { + file.readLines() + .map { it.trim() } + .map { line -> + // Remove inline comments + line.substringBefore('#').trim() + } + .filter { it.isNotEmpty() } + .toSet() + } catch (e: Exception) { + error("Failed to read managed components file $file: ${e.message}") + } + } + + protected fun reportUnmanagedManagedComponents( + localModules: List, + managedComponents: Set, + ) { + val unmanaged = localModules.filter { it.slug !in managedComponents } + + if (unmanaged.isNotEmpty()) { + println("\nLocal modules NOT in managed components file:") + unmanaged.forEach { println(" - ${it.path} (${it.type})") } + } + } +} diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/CreateComponent.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/CreateComponent.kt new file mode 100644 index 00000000000..e0ae36da718 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/CreateComponent.kt @@ -0,0 +1,145 @@ +package net.thunderbird.cli.weblate.command + +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.terminal +import com.github.ajalt.mordant.terminal.YesNoPrompt +import net.thunderbird.cli.weblate.api.Component +import net.thunderbird.cli.weblate.api.ComponentConfig +import net.thunderbird.cli.weblate.api.ComponentCreate +import net.thunderbird.cli.weblate.api.ComponentInfo +import net.thunderbird.cli.weblate.api.WeblateClient +import net.thunderbird.cli.weblate.api.WeblateConfig +import net.thunderbird.cli.weblate.project.ModuleDiscovery +import net.thunderbird.cli.weblate.project.ModuleInfo +import net.thunderbird.cli.weblate.project.ResourceType + +class CreateComponent : BaseCommand(name = "create") { + + override fun help(context: Context): String = "Create missing components" + + override fun onRun( + client: WeblateClient, + defaultComponentConfig: ComponentConfig, + managedComponents: Set, + ) { + val allComponents = client.loadComponents() + val apiConfig = WeblateConfig() + val defaultComponent = allComponents.find { it.info.slug == apiConfig.defaultComponent } + if (defaultComponent == null) { + println(" ❌ Could not find default component: ${apiConfig.defaultComponent}") + return + } + + val weblateSlugs = allComponents.map { it.info.slug }.toSet() + val localModules = ModuleDiscovery().discoverLocalModules() + + println("Found ${localModules.size} local modules with Android or Compose strings") + + val missingInWeblate = localModules.filter { it.slug !in weblateSlugs } + + println() + if (missingInWeblate.isNotEmpty()) { + println("Modules missing in Weblate:") + missingInWeblate.forEach { + createComponentFromModule( + client = client, + module = it, + defaultComponent = defaultComponent, + defaultComponentConfig = defaultComponentConfig, + ) + } + } else { + println("All local modules with resource strings have a corresponding component in Weblate.") + } + + reportUnmanagedManagedComponents(localModules, managedComponents) + } + + private fun createComponentFromModule( + client: WeblateClient, + module: ModuleInfo, + defaultComponent: Component, + defaultComponentConfig: ComponentConfig, + ) { + println() + println(" - ${module.path} (type: ${module.type})") + println(" expected name: \"${module.name}\"") + println(" expected slug: \"${module.slug}\"") + + if (config.dryRun) { + println(" (Dry run: would create component)") + } else { + val createPayload = createComponentPayload( + module = module, + defaultConfig = defaultComponentConfig, + defaultInfo = defaultComponent.info, + ) + if (YesNoPrompt(" Do you want to create this component?", terminal).ask() == true) { + println(" Creating component...") + val success = executeCreateComponent( + client = client, + component = createPayload, + ) + if (!success) { + println(" Stopping execution due to failure.") + return + } + } else { + println(" Skipped.") + } + } + } + + private fun createComponentPayload( + module: ModuleInfo, + defaultConfig: ComponentConfig, + defaultInfo: ComponentInfo, + ): ComponentCreate { + val apiConfig = WeblateConfig() + val (fileMask, template) = when (module.type) { + ResourceType.ANDROID -> { + "${module.path}/src/main/res/values-*/strings.xml" to + "${module.path}/src/main/res/values/strings.xml" + } + + ResourceType.COMPOSE -> { + "${module.path}/src/commonMain/composeResources/values-*/strings.xml" to + "${module.path}/src/commonMain/composeResources/values/strings.xml" + } + } + + return ComponentCreate( + name = module.name, + slug = module.slug, + project = "${apiConfig.baseUrl}projects/${apiConfig.projectName}/", + fileMask = fileMask, + template = template, + fileFormat = if (module.type == ResourceType.ANDROID) "aresource" else "cmp-resource", + category = defaultInfo.category, + linkedComponent = defaultInfo.url, + repo = "weblate://thunderbird/thunderbird-android/app-common", + vcs = "github", + mergeStyle = "merge", + config = defaultConfig.copy(editTemplate = false), + ) + } + + @Suppress("TooGenericExceptionCaught") + private fun executeCreateComponent( + client: WeblateClient, + component: ComponentCreate, + ): Boolean { + return try { + val success = client.createComponent(component) + if (success) { + println(" ✅ Created component successfully") + } else { + println(" ❌ Failed to create component") + } + success + } catch (e: Exception) { + println(" ❌ Error creating component: ${e.message}") + false + } + } +} diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/DeleteComponent.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/DeleteComponent.kt new file mode 100644 index 00000000000..a5f0d7e9074 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/DeleteComponent.kt @@ -0,0 +1,66 @@ +package net.thunderbird.cli.weblate.command + +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.terminal +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.mordant.terminal.YesNoPrompt +import net.thunderbird.cli.weblate.api.ComponentInfo +import net.thunderbird.cli.weblate.api.WeblateClient + +@Suppress("TooGenericExceptionCaught", "MemberNameEqualsClassName") +class DeleteComponent : BaseCommand(name = "delete") { + + private val slugToDelete: String by option( + "--slug", + help = "The slug of the component to delete", + ).required() + + override fun help(context: Context): String = "Delete a component from Weblate" + + override fun onRun( + client: WeblateClient, + defaultComponentConfig: net.thunderbird.cli.weblate.api.ComponentConfig, + managedComponents: Set, + ) { + val components = client.loadComponents() + val component = components.find { it.info.slug == slugToDelete } + + if (component == null) { + println() + println(" ❌ Could not find component with slug: $slugToDelete") + println() + println(" Available slugs:") + components.forEach { println(" ${it.info.slug}") } + return + } + + println("Found component: ${component.info.name} (slug: ${component.info.slug} # ID: ${component.info.id})") + + if (config.dryRun) { + println(" Dry run: would delete component") + } else { + if (YesNoPrompt(" Are you sure you want to delete this component?", terminal).ask() == true) { + executeDeleteComponent(client, component.info) + } else { + println(" Deletion cancelled.") + } + } + } + + private fun executeDeleteComponent( + client: WeblateClient, + info: ComponentInfo, + ) { + try { + val success = client.deleteComponent(info.url) + if (success) { + println(" ✅ Deleted component successfully") + } else { + println(" ❌ Failed to delete component: API request failed") + } + } catch (e: Exception) { + println(" ❌ Failed to delete component: ${e.message}") + } + } +} diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/UpdateComponent.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/UpdateComponent.kt new file mode 100644 index 00000000000..6ae251475ae --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/command/UpdateComponent.kt @@ -0,0 +1,74 @@ +package net.thunderbird.cli.weblate.command + +import com.github.ajalt.clikt.core.Context +import net.thunderbird.cli.weblate.ComponentConfigDiff +import net.thunderbird.cli.weblate.api.Component +import net.thunderbird.cli.weblate.api.ComponentConfig +import net.thunderbird.cli.weblate.api.ComponentPatch +import net.thunderbird.cli.weblate.api.WeblateClient + +@Suppress("TooGenericExceptionCaught") +class UpdateComponent : BaseCommand(name = "update") { + override fun help(context: Context): String = "Update managed components" + + override fun onRun( + client: WeblateClient, + defaultComponentConfig: ComponentConfig, + managedComponents: Set, + ) { + val allComponents = client.loadComponents() + val (managed, skipped) = allComponents.partition { it.info.slug in managedComponents } + + println("Loaded ${allComponents.size} components (${managed.size} managed, ${skipped.size} skipped):") + + managed.forEach { component -> + println() + println("- ${component.info.name} (slug: ${component.info.slug} # ID: ${component.info.id}) ") + println() + processComponent(component, defaultComponentConfig, client) + println() + } + + if (skipped.isNotEmpty()) { + println("-------") + println() + println("Skipped components (not in managed list):") + println() + skipped.forEach { println("${it.info.slug} # ID: ${it.info.id}") } + println() + } + } + + @Suppress("NestedBlockDepth") + private fun processComponent(component: Component, componentConfig: ComponentConfig, client: WeblateClient) { + val diffs = ComponentConfigDiff.computeConfigDiff(componentConfig, component.config, 1) + + if (diffs.isEmpty()) { + println(" ✅ Config matches common config") + } else { + println(" ⚠\uFE0F Config differs:") + println() + diffs.forEach { println(" $it") } + if (!config.dryRun) { + try { + val result = client.patchComponent( + component.info.url, + ComponentPatch( + category = component.info.category, + linkedComponent = component.info.linkedComponent, + config = componentConfig, + locked = component.info.locked, + ), + ) + if (result) { + println(" ✅ Updated component config successfully") + } else { + println(" ❌ Failed to update component config: API request failed") + } + } catch (e: Exception) { + println(" ❌ Failed to update component config: ${e.message}") + } + } + } + } +} diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/project/ModuleDiscovery.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/project/ModuleDiscovery.kt new file mode 100644 index 00000000000..ce432d7310c --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/project/ModuleDiscovery.kt @@ -0,0 +1,65 @@ +package net.thunderbird.cli.weblate.project + +import java.io.File + +class ModuleDiscovery { + + fun discoverLocalModules(): List { + val root = File(ROOT_PATH) + val modules = mutableMapOf() + + root.walkTopDown() + .onEnter { dir -> !isSkippedDirectory(dir.name) } + .forEach { file -> + val type = getResourceType(file) + if (type != null) { + val modulePath = extractModulePath(file.path) + if (modulePath != null && modulePath != "." && !isExcluded(modulePath)) { + modules[modulePath] = type + } + } + } + + return modules.map { (path, type) -> + ModuleInfo( + path = path, + type = type, + name = path.replace("/", ":"), + slug = path.replace("/", "-"), + ) + } + } + + private fun isSkippedDirectory(name: String): Boolean { + return name in SKIPPED_DIRECTORIES || name.startsWith("values-") + } + + private fun getResourceType(file: File): ResourceType? { + if (file.name != "strings.xml") return null + val path = file.path + return when { + path.contains("/res/values") -> ResourceType.ANDROID + path.contains("/composeResources/values") -> ResourceType.COMPOSE + else -> null + } + } + + private fun extractModulePath(path: String): String? { + val normalizedPath = path.removePrefix("./") + return when { + normalizedPath.contains("/src/") -> normalizedPath.substringBefore("/src/") + normalizedPath.contains("/composeResources/") -> normalizedPath.substringBefore("/composeResources/") + else -> null + } + } + + private fun isExcluded(path: String): Boolean { + return EXCLUDED_PATHS.any { path.contains(it) } + } + + private companion object { + private const val ROOT_PATH = "." + private val SKIPPED_DIRECTORIES = setOf(".git", "build", ".gradle", "gradle", "tmp") + private val EXCLUDED_PATHS = setOf("app-ui-catalog", "openpgp-api", "/test/") + } +} diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/project/ModuleInfo.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/project/ModuleInfo.kt new file mode 100644 index 00000000000..ec6cfb7989f --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/project/ModuleInfo.kt @@ -0,0 +1,8 @@ +package net.thunderbird.cli.weblate.project + +data class ModuleInfo( + val path: String, + val type: ResourceType, + val name: String, + val slug: String, +) diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/project/ResourceType.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/project/ResourceType.kt new file mode 100644 index 00000000000..2614086e60b --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/project/ResourceType.kt @@ -0,0 +1,6 @@ +package net.thunderbird.cli.weblate.project + +enum class ResourceType { + ANDROID, + COMPOSE, +} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index f9b0c70d363..cc6caadcc3e 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -46,7 +46,7 @@ We don’t track new ideas or feature requests in GitHub Issues. If you'd like to help to translate Thunderbird for Android, please visit: - **[Translations](contributing/translations.md)** – How to help localize Thunderbird for Android via Weblate. -- **[Weblate - Thunderbird for Android project](https://hosted.weblate.org/projects/tb-android/)** - Translation platform where all localization happens. +- **[Weblate - Thunderbird for Android project](https://hosted.weblate.org/projects/thunderbird/thunderbird-android/)** - Translation platform where all localization happens. ## 🤝 Contributing Code diff --git a/docs/about.md b/docs/about.md index 77678a1f80b..b672d752c4f 100644 --- a/docs/about.md +++ b/docs/about.md @@ -54,6 +54,6 @@ Community chat (Matrix): We welcome contributions of all kinds: - Development: [Contributing Guide](CONTRIBUTING.md) if you want to help with code -- Translations: [Weblate Project](https://hosted.weblate.org/projects/tb-android/) if you want to help localize the app +- Translations: [Weblate Project](https://hosted.weblate.org/projects/thunderbird/thunderbird-android/) if you want to help localize the app - [Other ways to help](https://blog.thunderbird.net/2024/09/contribute-to-thunderbird-for-android/) - [Participation guidelines](https://www.mozilla.org/about/governance/policies/participation/) diff --git a/docs/contributing/managing-strings.md b/docs/contributing/managing-strings.md index e30d629ad5f..d0734f6019f 100644 --- a/docs/contributing/managing-strings.md +++ b/docs/contributing/managing-strings.md @@ -13,7 +13,7 @@ Thunderbird for Android project. * We use [Compose Multiplatform Resources](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-resources.html) for localizing strings in Kotlin Multiplatform (KMP) modules. * **Source language** is **English** (American English, represented as `en`). * **Source strings** are modified only in this repository (via pull requests). -* **Translations** are managed exclusively in [Weblate](https://hosted.weblate.org/projects/tb-android/) and merged into the repository by the Thunderbird team. +* **Translations** are managed exclusively in [Weblate](https://hosted.weblate.org/projects/thunderbird/thunderbird-android/) and merged into the repository via the [Translation - Update](https://github.com/thunderbird/thunderbird-android/actions/workflows/translation-update.yml) workflow. * **Languages** are added/removed when they reach 70% translation or fall below 60%. ## 🔄 Changing Source Strings @@ -66,18 +66,24 @@ To use Compose Multiplatform Resources in a module, follow these steps in your ` ### 🔧 Mechanical/Global Changes -If a **mechanical or global change** to translations is required (for example, renaming placeholders or fixing -formatting across all languages): - -1. Lock components in Weblate ([maintenance page](https://hosted.weblate.org/projects/tb-android/#repository)). -2. Commit all outstanding changes. -3. Push Weblate changes (creates a PR). -4. Merge the Weblate PR. -5. Apply your mechanical change in a separate PR. -6. Wait for Weblate sync to propagate your merged PR. -7. Unlock components in Weblate. - -This ensures translators do not work on outdated strings and avoids merge conflicts. +If a **mechanical or global change** to translations is required (for example, renaming placeholders or fixing formatting across all languages), follow this workflow: + +1. **Lock components in Weblate:** + Go to the [maintenance page](https://hosted.weblate.org/projects/thunderbird/thunderbird-android/#repository) and lock all components to prevent new translations during the change. +2. **Commit outstanding changes:** + Ensure all pending translations in Weblate are committed to its internal Git repository. +3. **Pull latest translations:** + Trigger the [Translation - Update](https://github.com/thunderbird/thunderbird-android/actions/workflows/translation-update.yml) GitHub workflow manually using `workflow_dispatch`. +4. **Merge the pull request:** + Review and merge the resulting PR to ensure your local `main` branch is in sync with Weblate. +5. **Apply your change:** + Apply your mechanical changes to the source and translation files in a new branch and merge it. +6. **Update Weblate configuration (if needed):** + If your change involved moving files or changing directory structures, use the `weblate-cli update` command to ensure Weblate is correctly configured. +7. **Unlock components:** + Once Weblate has pulled the changes from the repository, unlock the components. + +See the [Weblate CLI section](#-weblate-cli) for more details on the tooling. ### ➕ Adding a String @@ -169,9 +175,14 @@ val messagesCount = getPluralString(Res.plurals.new_messages, count, count) > The `Res` class is generated by the Compose Multiplatform Gradle plugin. You may need to build the project to > generate it after adding new strings. -## 🔀 Merging Weblate PRs +## 🔀 Merging Translations -When merging Weblate-generated PRs: +Translations are merged from Weblate via an automated [GitHub workflow](https://github.com/thunderbird/thunderbird-android/actions/workflows/translation-update.yml). This workflow: +1. Fetches the latest changes from Weblate's Git export. +2. Creates a pull request with the updated translation files. +3. Preserves contributor attribution via `Co-authored-by` trailers. + +When reviewing and merging these PRs: * Check plural forms for **cs, lt, sk** locales. Weblate does not handle these correctly ([issue](https://github.com/WeblateOrg/weblate/issues/7520)). * Ensure both `many` and `other` forms are present. @@ -183,23 +194,26 @@ We use Gradle’s [`androidResources.localeFilters`](https://developer.android.c This must stay in sync with the string array `supported_languages` so the in-app picker shows only available locales. -### 🔎 Checking Translation Coverage +### 🛠️ Automation Tools + +We provide several CLI tools to assist with translation management. -Before adding a language, we require that it is at least **70% translated** in Weblate. +#### 🔎 Translation CLI -We provide a **Translation CLI** script to check translation coverage: +Used to check translation coverage before adding or removing languages. ```bash ./scripts/translation --token -# Specify the low 60% threshold -./scripts/translation --token --threshold 60 +# Specify the low 60% threshold and verbose logging +./scripts/translation --token --threshold 60 --log-level BODY ``` - Requires a [Weblate API token](https://hosted.weblate.org/accounts/profile/#api) - Default threshold is 70% (can be changed with `--threshold `) +- Default log level is `NONE` (can be changed with `--log-level `) -For example code integration, run with --print-all: +For example code integration, run with `--print-all`: ```bash ./scripts/translation --token --print-all @@ -210,6 +224,30 @@ This output can be used to update: - `resourceConfigurations` in `app-k9mail/build.gradle.kts` and `app-thunderbird/build.gradle.kts` - `supported_languages` in `legacy/core/src/res/values/arrays_general_settings_values.xml` +#### 🔧 Weblate CLI + +Used to manage component configurations and create missing components on Weblate. + +```bash +# Update managed components with standard configuration +./scripts/weblate --token update + +# Create missing components based on local modules +./scripts/weblate --token create + +# Delete a component by slug +./scripts/weblate --token delete --slug +``` + +##### ➕ Adding a Component + +1. Ensure your module follows the standard directory structure for strings. +2. Run the `create` command to identify and create missing components. +3. The tool will scan for modules containing `strings.xml` and prompt you to create components for those missing from Weblate. +4. After creation, add the new component slug to `cli/weblate-cli/managed-components.txt` to keep it updated with future configuration changes. + +For more details, see the [Weblate CLI README](../../cli/weblate-cli/README.md). + ### ➖ Removing a Language 1. Remove language code from `androidResources.localeFilters` in: @@ -232,25 +270,7 @@ This output can be used to update: ### 🧩 Adding a Component to Weblate -When a new module contains translatable strings, a new Weblate component must be created. - -Steps: - -1. Go to [Add Component](https://hosted.weblate.org/create/component/?project=3696). -2. Choose **From existing component**. -3. Name your component (e.g., `feature:notification:api`). -4. For **Component**, select **Thunderbird for Android / K-9 Mail/ui-legacy**. -5. Continue → Select **Specify configuration manually**. -6. Set file format to **Android String Resource**. -7. File mask: - - Android: `path/to/module/src/main/res/values-*/strings.xml` - - Compose: `path/to/module/src/commonMain/composeResources/values-*/strings.xml` -8. Base file: - - Android: `path/to/module/src/main/res/values/strings.xml` - - Compose: `path/to/module/src/commonMain/composeResources/values/strings.xml` -9. Uncheck **Edit base file**. -10. License: **Apache License 2.0**. -11. Save. +When a new module contains translatable strings, a new Weblate component must be created. We provide a **Weblate CLI** to automate this process. See the [Weblate CLI section](#-weblate-cli) for more details. ## ⚠️ Language Code Differences diff --git a/docs/contributing/translations.md b/docs/contributing/translations.md index 1cb5e86d031..b5f26ce7426 100644 --- a/docs/contributing/translations.md +++ b/docs/contributing/translations.md @@ -2,7 +2,7 @@ This document explains how you can help translate Thunderbird for Android into your language. -- All translations for Thunderbird for Android are managed in [Thunderbird for Android Weblate project](https://hosted.weblate.org/projects/tb-android/). +- All translations for Thunderbird for Android are managed in [Thunderbird for Android Weblate project](https://hosted.weblate.org/projects/thunderbird/thunderbird-android/). - The Source language is **English** (American English, represented as `en`). - Translations are done only in Weblate, not in this repository. - The Thunderbird team regularly syncs Weblate with the repository to pull in translation updates. @@ -17,7 +17,7 @@ Before contributing, familiarize yourself with [documentation](https://docs.webl To start translating Thunderbird for Android: 1. Create a [Weblate account](https://hosted.weblate.org/accounts/signup/). -2. Go to the [Thunderbird for Android Weblate project](https://hosted.weblate.org/projects/tb-android/). +2. Go to the [Thunderbird for Android Weblate project](https://hosted.weblate.org/projects/thunderbird/thunderbird-android/). 3. Select your language from the list of languages. 4. Start translating strings through the Weblate web interface.