From 5bc5ad162b2cf9eca777b7ab3ee7159ac8fa2da9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Tue, 7 Apr 2026 17:21:47 +0200 Subject: [PATCH 1/5] feat(cli): add base weblate cli --- cli/weblate-cli/README.md | 21 ++++++ cli/weblate-cli/build.gradle.kts | 25 +++++++ .../net/thunderbird/cli/weblate/Main.kt | 5 ++ .../net/thunderbird/cli/weblate/WeblateCli.kt | 31 ++++++++ .../cli/weblate/client/Component.kt | 9 +++ .../cli/weblate/client/ComponentResponse.kt | 9 +++ .../cli/weblate/client/WeblateClient.kt | 71 +++++++++++++++++++ .../cli/weblate/client/WeblateConfig.kt | 26 +++++++ scripts/weblate | 3 + settings.gradle.kts | 1 + 10 files changed, 201 insertions(+) create mode 100644 cli/weblate-cli/README.md create mode 100644 cli/weblate-cli/build.gradle.kts create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/Main.kt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/WeblateCli.kt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/Component.kt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/ComponentResponse.kt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateClient.kt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateConfig.kt create mode 100755 scripts/weblate diff --git a/cli/weblate-cli/README.md b/cli/weblate-cli/README.md new file mode 100644 index 00000000000..dc2b5682d0b --- /dev/null +++ b/cli/weblate-cli/README.md @@ -0,0 +1,21 @@ +# Weblate CLI + +This is a command line interface that will check the [weblate](https://hosted.weblate.org/projects/tb-android/#components) components configuration and apply a streamlined configuration. + +## Usage + +To use this script you need to have a [weblate token](https://hosted.weblate.org/accounts/profile/#api). 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/weblate --token [--dry-run] +``` + +It will patch all components to the same configuration. + +If you want to preview the outcome you can pass the `--dry-run` argument. It will print out the changes that would be applied. + +```bash +./scripts/weblate --token --dry-run +``` diff --git a/cli/weblate-cli/build.gradle.kts b/cli/weblate-cli/build.gradle.kts new file mode 100644 index 00000000000..0453c234252 --- /dev/null +++ b/cli/weblate-cli/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + id(ThunderbirdPlugins.App.jvm) + alias(libs.plugins.kotlin.serialization) +} + +version = "unspecified" + +application { + mainClass.set("net.thunderbird.cli.weblate.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) +} + +codeCoverage { + branchCoverage = 0 + lineCoverage = 0 +} 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 new file mode 100644 index 00000000000..dc66dc22a0a --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/Main.kt @@ -0,0 +1,5 @@ +package net.thunderbird.cli.weblate + +import com.github.ajalt.clikt.core.main + +fun main(args: Array) = WeblateCli().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 new file mode 100644 index 00000000000..995f913cb02 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/WeblateCli.kt @@ -0,0 +1,31 @@ +package net.thunderbird.cli.weblate + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +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 net.thunderbird.cli.weblate.client.WeblateClient + +class WeblateCli : CliktCommand( + name = "weblate", +) { + private val token: String by option( + help = "Weblate API token", + ).required() + + private val dryRun: Boolean by option( + help = "Dry run the command without making any changes", + ).flag() + + override fun help(context: Context): String = "Weblate CLI" + + override fun run() { + val client = WeblateClient() + val components = client.loadComponents(token) + + println("Loaded ${components.size} components:") + + components.forEach { component -> println("- ${component.name} (ID: ${component.id})") } + } +} diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/Component.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/Component.kt new file mode 100644 index 00000000000..c5b007e2d10 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/Component.kt @@ -0,0 +1,9 @@ +package net.thunderbird.cli.weblate.client + +import kotlinx.serialization.Serializable + +@Serializable +data class Component( + val id: Int, + val name: String, +) diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/ComponentResponse.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/ComponentResponse.kt new file mode 100644 index 00000000000..63a1110cf91 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/ComponentResponse.kt @@ -0,0 +1,9 @@ +package net.thunderbird.cli.weblate.client + +import kotlinx.serialization.Serializable + +@Serializable +data class ComponentResponse( + val next: String?, + val results: List, +) diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateClient.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateClient.kt new file mode 100644 index 00000000000..979a171188f --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateClient.kt @@ -0,0 +1,71 @@ +package net.thunderbird.cli.weblate.client + +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 loadComponents(token: String): List { + val components = mutableListOf() + var page = 1 + var hasNextPage = true + + while(hasNextPage) { + val componentPage = loadComponentPage(token, page) + components.addAll(componentPage.results) + + hasNextPage = componentPage.next != null + page++ + } + + return components + } + + private fun loadComponentPage(token: String, page: Int): ComponentResponse { + val componentResponse: ComponentResponse + + runBlocking { + componentResponse = client.get(config.componentsUrl(page)) { + headers { + config.getDefaultHeaders(token).forEach { (key, value) -> append(key, value) } + } + }.body() + } + + return componentResponse + } + + private companion object { + fun createClient(): HttpClient { + return HttpClient(CIO) { + install(Logging) { + logger = Logger.Companion.DEFAULT + level = LogLevel.NONE + } + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + }, + ) + } + } + } + + private fun WeblateConfig.componentsUrl(page: Int) = "${baseUrl}projects/$projectName/components/?page=$page" + } +} diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateConfig.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateConfig.kt new file mode 100644 index 00000000000..18da10749c6 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateConfig.kt @@ -0,0 +1,26 @@ +package net.thunderbird.cli.weblate.client + +/** + * 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 = mapOf( + "Accept" to "application/json", + "Authorization" to "Token $PLACEHOLDER_TOKEN", + ), +) { + fun getDefaultHeaders(token: String): List> = + defaultHeaders.mapValues { it.value.replace(PLACEHOLDER_TOKEN, token) } + .map { (key, value) -> key to value } + + private companion object { + const val PLACEHOLDER_TOKEN = "{weblate_token}" + } +} diff --git a/scripts/weblate b/scripts/weblate new file mode 100755 index 00000000000..197ad5d0ba9 --- /dev/null +++ b/scripts/weblate @@ -0,0 +1,3 @@ +#!/bin/sh + +./gradlew --quiet ":cli:weblate-cli:installDist" < /dev/null && ./cli/weblate-cli/build/install/weblate-cli/bin/weblate-cli "$@" diff --git a/settings.gradle.kts b/settings.gradle.kts index 68d5bea77d2..58db3f2e317 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -256,6 +256,7 @@ include( ":cli:html-cleaner-cli", ":cli:resource-mover-cli", ":cli:translation-cli", + ":cli:weblate-cli", ) include( From 92934fd6e62778821faa65372cf33a25fd56bbd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Wed, 8 Apr 2026 15:25:09 +0200 Subject: [PATCH 2/5] feat(cli): implement configuration management and "golden" config enforcement --- cli/weblate-cli/README.md | 43 +++- cli/weblate-cli/golden-component-config.json | 61 ++++++ cli/weblate-cli/include-components.txt | 30 +++ .../cli/weblate/ComponentConfigDiff.kt | 192 ++++++++++++++++++ .../cli/weblate/ComponentConfigLoader.kt | 14 ++ .../net/thunderbird/cli/weblate/WeblateCli.kt | 89 +++++++- .../thunderbird/cli/weblate/api/Component.kt | 46 +++++ .../cli/weblate/api/ComponentConfig.kt | 178 ++++++++++++++++ .../cli/weblate/api/ComponentInfo.kt | 19 ++ .../{client => api}/ComponentResponse.kt | 2 +- .../weblate/{client => api}/WeblateClient.kt | 33 ++- .../weblate/{client => api}/WeblateConfig.kt | 2 +- .../cli/weblate/client/Component.kt | 9 - 13 files changed, 693 insertions(+), 25 deletions(-) create mode 100644 cli/weblate-cli/golden-component-config.json create mode 100644 cli/weblate-cli/include-components.txt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigLoader.kt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/Component.kt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentInfo.kt rename cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/{client => api}/ComponentResponse.kt (77%) rename cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/{client => api}/WeblateClient.kt (69%) rename cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/{client => api}/WeblateConfig.kt (95%) delete mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/Component.kt diff --git a/cli/weblate-cli/README.md b/cli/weblate-cli/README.md index dc2b5682d0b..27b1d311988 100644 --- a/cli/weblate-cli/README.md +++ b/cli/weblate-cli/README.md @@ -1,21 +1,48 @@ # Weblate CLI -This is a command line interface that will check the [weblate](https://hosted.weblate.org/projects/tb-android/#components) components configuration and apply a streamlined configuration. +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. ## Usage -To use this script you need to have a [weblate token](https://hosted.weblate.org/accounts/profile/#api). You can get it by logging in to weblate and going to your profile settings. +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. -You can run the script with the following command: +Basic examples: ```bash -./scripts/weblate --token [--dry-run] +# Dry-run using the default golden config and include file +./scripts/weblate --token YOUR_WEBLATE_TOKEN --dry-run + +# Apply changes to included components +./scripts/weblate --token YOUR_WEBLATE_TOKEN + +# 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 ``` -It will patch all components to the same configuration. +## Defaults -If you want to preview the outcome you can pass the `--dry-run` argument. It will print out the changes that would be applied. +- Golden config: `./cli/weblate-cli/golden-component-config.json` +- Include file: `./cli/weblate-cli/include-components.txt` -```bash -./scripts/weblate --token --dry-run +## Include file format + +- One component slug per non-empty line. Inline comments are allowed after `#` and full-line comments that + start with `#` are ignored. +- Matching is exact and case-sensitive against the component slug returned by the Weblate API. + +Example: + +``` +# legacy +app-strings # ID: 17093 (main) +designsystem # ID: 25913 +app-common ``` + +## Safety notes + +- Always run with `--dry-run` first to verify diffs before applying changes to the live Weblate instance. + diff --git a/cli/weblate-cli/golden-component-config.json b/cli/weblate-cli/golden-component-config.json new file mode 100644 index 00000000000..50fd49a3d13 --- /dev/null +++ b/cli/weblate-cli/golden-component-config.json @@ -0,0 +1,61 @@ +{ + "license": "Apache-2.0", + "license_url": "https://spdx.org/licenses/Apache-2.0.html", + "agreement": "", + "report_source_bugs": "", + "priority": 100, + "is_glossary": false, + "glossary_color": "silver", + + + "enable_suggestions": true, + "suggestion_voting": false, + "suggestion_autoaccept": 0, + + "allow_translation_propagation": true, + + "check_flags": "ignore-punctuation-spacing", + "variant_regex": "", + "enforced_checks": [ + "double_space", + "translated", + "java_printf_format", + "plurals", + "placeholders" + ], + "secondary_language": null, + + + "vcs": "github", + "repo": "https://github.com/thunderbird/thunderbird-android", + "branch": "main", + "push": "", + "push_branch": "", + "repoweb": "https://github.com/thunderbird/thunderbird-android/blob/{{branch}}/{{filename}}#L{{line}}", + "commit_pending_age": 24, + "merge_style": "rebase", + "auto_lock_error": true, + + "commit_message": "chore(i18n): translated using Weblate\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", + "addon_message": "chore(i18n): update translation files\r\n\r\nUpdated by \"{{ addon_name }}\" add-on in Weblate.\r\n\r\nTranslation: {{ project_name }}/{{ component_name }}\r\nTranslate-URL: {{ url }}", + "pull_message": "chore(i18n): translations update from Weblate\r\n\r\nTranslations update from [Weblate]({{ site_url }}) for [{{ project_name }}]({{url}}) and base component [{{ component_name }}]({{url}}).\r\n\r\n{% if component_linked_childs %}\r\nIt also includes following components:\r\n{% for linked in component_linked_childs %}\r\n* [{{ linked.project_name }}/{{ linked.name }}]({{ linked.url }})\r\n{% endfor %}\r\n{% endif %}\r\n\r\nCurrent translation status:\r\n\r\n![Weblate translation status]({{widget_url}})", + + + "language_regex": "^[^.]+$", + "key_filter": "", + + "file_format_params": { + "xml_closing_tags": true + }, + + "edit_template": false, + "intermediate": "", + "new_base": "", + "new_lang": "contact", + "language_code_style": "", + "screenshot_filemask": "" +} + diff --git a/cli/weblate-cli/include-components.txt b/cli/weblate-cli/include-components.txt new file mode 100644 index 00000000000..783d2630497 --- /dev/null +++ b/cli/weblate-cli/include-components.txt @@ -0,0 +1,30 @@ +# 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/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt new file mode 100644 index 00000000000..64512fa85bf --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt @@ -0,0 +1,192 @@ +package net.thunderbird.cli.weblate + +import net.thunderbird.cli.weblate.api.ComponentConfig + +object ComponentConfigDiff { + + fun computeConfigDiff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int = 0): List { + return fields.mapNotNull { it.diff(expected, actual, indentLevel) } + } + + private fun value( + name: String, + selector: (ComponentConfig) -> T, + ): DiffField = ValueField(name, selector) + + private fun set( + name: String, + selector: (ComponentConfig) -> List, + ): DiffField = SetField(name, selector) + + private fun multiline( + name: String, + selector: (ComponentConfig) -> String, + ): DiffField = MultilineField(name, selector) + + private val fields: List = listOf( + value("license") { it.license }, + value("license_url") { it.licenseUrl }, + value("agreement") { it.agreement }, + value("report_source_bugs") { it.reportSourceBugs }, + value("priority") { it.priority }, + value("is_glossary") { it.isGlossary }, + value("glossary_color") { it.glossaryColor }, + + value("enable_suggestions") { it.enableSuggestions }, + value("suggestion_voting") { it.suggestionVoting }, + value("suggestion_autoaccept") { it.suggestionAutoaccept }, + + value("allow_translation_propagation") { it.allowTranslationPropagation }, + + value("check_flags") { it.checkFlags }, + value("variant_regex") { it.variantRegex }, + set("enforced_checks") { it.enforcedChecks }, + value("secondary_language") { it.secondaryLanguage }, + + value("vcs") { it.vcs }, + value("repo") { it.repo }, + value("branch") { it.branch }, + value("push") { it.push }, + value("push_branch") { it.pushBranch }, + value("repoweb") { it.repoweb }, + value("commit_pending_age") { it.commitPendingAge }, + value("merge_style") { it.mergeStyle }, + value("auto_lock_error") { it.autoLockError }, + + multiline("commit_message") { it.commitMessage }, + multiline("add_message") { it.addMessage }, + multiline("delete_message") { it.deleteMessage }, + multiline("merge_message") { it.mergeMessage }, + multiline("addon_message") { it.addonMessage }, + multiline("pull_message") { it.pullMessage }, + + value("language_regex") { it.languageRegex }, + value("key_filter") { it.keyFilter }, + + value("file_format_params.xml_closing_tags") { it.fileFormatParams.xmlClosingTags }, + + value("edit_template") { it.editTemplate }, + value("intermediate") { it.intermediate }, + value("new_base") { it.newBase }, + value("new_lang") { it.newLang }, + value("language_code_style") { it.languageCodeStyle }, + value("screenshot_filemask") { it.screenshotFilemask }, + ) +} + +private interface DiffField { + fun diff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int): String? +} + +private class ValueField( + private val name: String, + private val selector: (ComponentConfig) -> T, +) : DiffField { + override fun diff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int): String? { + val expectedValue = selector(expected) + val actualValue = selector(actual) + val indent = " ".repeat(indentLevel * 2) + + return if (expectedValue != actualValue) { + "$indent$name: expected=$expectedValue, actual=$actualValue" + } else { + null + } + } +} + +private class SetField( + private val name: String, + private val selector: (ComponentConfig) -> List, +) : DiffField { + override fun diff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int): String? { + val expectedValue = selector(expected) + val actualValue = selector(actual) + + return if (expectedValue.toSet() != actualValue.toSet()) { + listDiff(name, expectedValue, actualValue, indentLevel) + } else { + null + } + } + + private fun listDiff(name: String, expected: List, actual: List, indentLevel: Int): String { + val indent = " ".repeat(indentLevel * 2) + val expectedSet = expected.toSet() + val actualSet = actual.toSet() + + val missing = expected.filter { it !in actualSet } + val unexpected = actual.filter { it !in expectedSet } + + val inner = buildString { + if (missing.isNotEmpty()) { + appendLine("missing:") + missing.forEach { appendLine(" - $it") } + } + if (unexpected.isNotEmpty()) { + appendLine("unexpected:") + unexpected.forEach { appendLine(" + $it") } + } + }.trimEnd() + + return if (inner.isEmpty()) { + "" + } else { + buildString { + appendLine("$indent$name:") + append(indentText(inner, indentLevel + 1)) + }.trimEnd() + } + } +} + +private class MultilineField( + private val name: String, + private val selector: (ComponentConfig) -> String, +) : DiffField { + override fun diff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int): String? { + val expectedValue = selector(expected) + val actualValue = selector(actual) + + return if (expectedValue != actualValue) { + multilineDiff(name, expectedValue, actualValue, indentLevel) + } else { + null + } + } + + private fun multilineDiff(name: String, expected: String, actual: String, indentLevel: Int): String { + val indent = " ".repeat(indentLevel * 2) + val expectedLines = expected.lines() + val actualLines = actual.lines() + val max = maxOf(expectedLines.size, actualLines.size) + + val inner = buildString { + for (i in 0 until max) { + val exp = expectedLines.getOrNull(i) + val act = actualLines.getOrNull(i) + if (exp != act) { + val expText = exp ?: "" + val actText = act ?: "" + appendLine(" [${i + 1}] expected: $expText") + appendLine(" [${i + 1}] actual : $actText") + } + } + }.trimEnd() + + return if (inner.isEmpty()) { + "" + } else { + buildString { + appendLine("$indent$name:") + append(indentText(inner, indentLevel + 1)) + }.trimEnd() + } + } +} + +/** + * Indent a multi-line string by a given indent level. Each level equals 2 spaces by default. + */ +private fun indentText(text: String, level: Int, spacesPerLevel: Int = 2): String = + text.lines().joinToString("\n") { " ".repeat(level * spacesPerLevel) + it } diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigLoader.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigLoader.kt new file mode 100644 index 00000000000..04e3dc6a489 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigLoader.kt @@ -0,0 +1,14 @@ +package net.thunderbird.cli.weblate + +import java.io.File +import kotlinx.serialization.json.Json +import net.thunderbird.cli.weblate.api.ComponentConfig + +class ComponentConfigLoader { + private val json = Json { ignoreUnknownKeys = true } + + fun load(file: File): ComponentConfig { + val text = file.readText(Charsets.UTF_8) + return json.decodeFromString(ComponentConfig.serializer(), text) + } +} 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 995f913cb02..12fef253405 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 @@ -2,11 +2,16 @@ package net.thunderbird.cli.weblate 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 net.thunderbird.cli.weblate.client.WeblateClient +import java.io.File +import net.thunderbird.cli.weblate.api.Component +import net.thunderbird.cli.weblate.api.ComponentConfig +import net.thunderbird.cli.weblate.api.WeblateClient +@Suppress("TooGenericExceptionCaught") class WeblateCli : CliktCommand( name = "weblate", ) { @@ -18,14 +23,94 @@ class WeblateCli : CliktCommand( 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") + 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("- ${component.name} (ID: ${component.id})") } + 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 (dry-run). Diff:") + println() + diffs.forEach { println(" $it") } + if (!dryRun) { + try { + val result = client.patchComponent(token, component.info.url, 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}") + } } } 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 new file mode 100644 index 00000000000..8439d5b4689 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/Component.kt @@ -0,0 +1,46 @@ +package net.thunderbird.cli.weblate.api + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.jsonObject + +@Serializable(with = Component.ComponentSerializer::class) +data class Component( + val info: ComponentInfo, + val config: ComponentConfig, +) { + companion object ComponentSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Component") { + element("info", ComponentInfo.serializer().descriptor) + element("config", ComponentConfig.serializer().descriptor) + } + + override fun deserialize(decoder: Decoder): Component { + require(decoder is JsonDecoder) { + "Expected JsonDecoder, got ${decoder::class.simpleName}" + } + + val jsonObject = decoder.decodeJsonElement().jsonObject + + val info = decoder.json.decodeFromJsonElement(ComponentInfo.serializer(), jsonObject) + val config = decoder.json.decodeFromJsonElement(ComponentConfig.serializer(), jsonObject) + + return Component( + info = info, + config = config, + ) + } + + override fun serialize( + encoder: Encoder, + value: Component, + ) { + error("Component serialization is not supported") + } + } +} diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt new file mode 100644 index 00000000000..37bf464114d --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt @@ -0,0 +1,178 @@ +package net.thunderbird.cli.weblate.api + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Represents the configuration of a Weblate component. + * + * This maps the shape of `golden-component-config.json` used by the CLI. Defaults are + * provided so decoding remains tolerant when the server omits optional keys. + * + * @property license SPDX license identifier for the component + * @property licenseUrl URL to the license text + * @property agreement Any agreement text associated with the component + * @property reportSourceBugs URL where users should report issues + * @property priority Component priority (numeric) + * @property isGlossary Whether this component is a glossary + * @property glossaryColor Color used to render glossary items in the UI + * @property enableSuggestions Whether suggestions are enabled + * @property suggestionVoting Whether suggestion voting is enabled + * @property suggestionAutoaccept Number of votes required for auto-accept (or 0) + * @property allowTranslationPropagation Whether translation propagation is allowed + * @property manageUnits Controls unit management behavior + * @property checkFlags Miscellaneous check flags + * @property variantRegex Regex used to identify language variants + * @property enforcedChecks List of enforced check identifiers (e.g. "plurals") + * @property secondaryLanguage Optional secondary language code + * @property vcs Version control system identifier (e.g. "github") + * @property repo Repository URL + * @property branch Default branch for the component's repo + * @property push Push URL or configuration + * @property pushBranch Branch used for pushes + * @property repoweb Web UI link pattern for sources + * @property pushOnCommit Whether pushes should be triggered on commit + * @property commitPendingAge Age (hours) before committing pending changes + * @property mergeStyle Merge strategy for commits (e.g. "rebase") + * @property autoLockError Whether to auto-lock component on errors + * @property commitMessage Template used for commit messages + * @property addMessage Template used when adding translations + * @property deleteMessage Template used when deleting translations + * @property mergeMessage Template used when merging + * @property addonMessage Template used by add-ons + * @property pullMessage Template used for pull updates + * @property languageRegex Regex to validate language codes + * @property keyFilter Optional key filter applied to strings + * @property fileFormatParams Nested file-format specific parameters + * @property editTemplate Whether edit templates are enabled + * @property intermediate Intermediate file path or marker + * @property newBase New base branch/file marker + * @property newLang Default new language handling value + * @property languageCodeStyle Style applied to language codes + * @property screenshotFilemask File mask for screenshots + */ +@Serializable +data class ComponentConfig( + @SerialName("license") + val license: String = "", + + @SerialName("license_url") + val licenseUrl: String = "", + + @SerialName("agreement") + val agreement: String = "", + + @SerialName("report_source_bugs") + val reportSourceBugs: String = "", + + @SerialName("priority") + val priority: Int = 0, + + @SerialName("is_glossary") + val isGlossary: Boolean = false, + + @SerialName("glossary_color") + val glossaryColor: String = "", + + @SerialName("enable_suggestions") + val enableSuggestions: Boolean = false, + + @SerialName("suggestion_voting") + val suggestionVoting: Boolean = false, + + @SerialName("suggestion_autoaccept") + val suggestionAutoaccept: Int = 0, + + @SerialName("allow_translation_propagation") + val allowTranslationPropagation: Boolean = false, + + @SerialName("check_flags") + val checkFlags: String = "", + + @SerialName("variant_regex") + val variantRegex: String = "", + + @SerialName("enforced_checks") + val enforcedChecks: List = emptyList(), + + @SerialName("secondary_language") + val secondaryLanguage: String? = null, + + @SerialName("vcs") + val vcs: String = "", + + @SerialName("repo") + val repo: String = "", + + @SerialName("branch") + val branch: String = "", + + @SerialName("push") + val push: String = "", + + @SerialName("push_branch") + val pushBranch: String = "", + + @SerialName("repoweb") + val repoweb: String = "", + + @SerialName("commit_pending_age") + val commitPendingAge: Int = 0, + + @SerialName("merge_style") + val mergeStyle: String = "", + + @SerialName("auto_lock_error") + val autoLockError: Boolean = false, + + @SerialName("commit_message") + val commitMessage: String = "", + + @SerialName("add_message") + val addMessage: String = "", + + @SerialName("delete_message") + val deleteMessage: String = "", + + @SerialName("merge_message") + val mergeMessage: String = "", + + @SerialName("addon_message") + val addonMessage: String = "", + + @SerialName("pull_message") + val pullMessage: String = "", + + @SerialName("language_regex") + val languageRegex: String = "", + + @SerialName("key_filter") + val keyFilter: String = "", + + @SerialName("file_format_params") + val fileFormatParams: FileFormatParams = FileFormatParams(), + + @SerialName("edit_template") + val editTemplate: Boolean = false, + + @SerialName("intermediate") + val intermediate: String = "", + + @SerialName("new_base") + val newBase: String = "", + + @SerialName("new_lang") + val newLang: String = "", + + @SerialName("language_code_style") + val languageCodeStyle: String = "", + + @SerialName("screenshot_filemask") + val screenshotFilemask: String = "", +) + +@Serializable +data class FileFormatParams( + @SerialName("xml_closing_tags") + val xmlClosingTags: Boolean = false, +) 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 new file mode 100644 index 00000000000..4cf9c997c1e --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentInfo.kt @@ -0,0 +1,19 @@ +package net.thunderbird.cli.weblate.api + +import kotlinx.serialization.Serializable + +/** + * Represents the information of a component in Weblate. + * + * @property id The unique identifier of the component. + * @property name The name of the component. + * @property url The URL of the component in Weblate. + * @property slug The slug identifier of the component (suitable for matching/include lists) + */ +@Serializable +data class ComponentInfo( + val id: Int, + val name: String, + val slug: String, + val url: String, +) diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/ComponentResponse.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentResponse.kt similarity index 77% rename from cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/ComponentResponse.kt rename to cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentResponse.kt index 63a1110cf91..c68e5c6d29a 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/ComponentResponse.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentResponse.kt @@ -1,4 +1,4 @@ -package net.thunderbird.cli.weblate.client +package net.thunderbird.cli.weblate.api import kotlinx.serialization.Serializable diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateClient.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateClient.kt similarity index 69% rename from cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateClient.kt rename to cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateClient.kt index 979a171188f..6f96ccc3ce5 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateClient.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateClient.kt @@ -1,4 +1,4 @@ -package net.thunderbird.cli.weblate.client +package net.thunderbird.cli.weblate.api import io.ktor.client.HttpClient import io.ktor.client.call.body @@ -9,6 +9,12 @@ 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.client.request.header +import io.ktor.client.request.patch +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 kotlinx.coroutines.runBlocking @@ -24,7 +30,7 @@ class WeblateClient( var page = 1 var hasNextPage = true - while(hasNextPage) { + while (hasNextPage) { val componentPage = loadComponentPage(token, page) components.addAll(componentPage.results) @@ -35,6 +41,23 @@ class WeblateClient( return components } + fun patchComponent(token: String, url: String, patch: ComponentConfig): Boolean { + var success = false + + 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 + } + + return success + } + private fun loadComponentPage(token: String, page: Int): ComponentResponse { val componentResponse: ComponentResponse @@ -50,11 +73,13 @@ class WeblateClient( } private companion object { + val SUCCESS = 200..299 + fun createClient(): HttpClient { return HttpClient(CIO) { install(Logging) { - logger = Logger.Companion.DEFAULT - level = LogLevel.NONE + logger = Logger.DEFAULT + level = LogLevel.INFO } install(ContentNegotiation) { json( diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateConfig.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateConfig.kt similarity index 95% rename from cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateConfig.kt rename to cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateConfig.kt index 18da10749c6..e6596eabdb9 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/WeblateConfig.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateConfig.kt @@ -1,4 +1,4 @@ -package net.thunderbird.cli.weblate.client +package net.thunderbird.cli.weblate.api /** * Configuration for Weblate API diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/Component.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/Component.kt deleted file mode 100644 index c5b007e2d10..00000000000 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/client/Component.kt +++ /dev/null @@ -1,9 +0,0 @@ -package net.thunderbird.cli.weblate.client - -import kotlinx.serialization.Serializable - -@Serializable -data class Component( - val id: Int, - val name: String, -) From 49bd5d204b6ef7452d72ef6775d89a9f9ec8f54f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Wed, 8 Apr 2026 16:09:29 +0200 Subject: [PATCH 3/5] feat(cli): ensure category is preserved during update --- .../net/thunderbird/cli/weblate/WeblateCli.kt | 12 +++- .../cli/weblate/api/ComponentInfo.kt | 2 + .../cli/weblate/api/ComponentPatch.kt | 55 +++++++++++++++++++ .../cli/weblate/api/WeblateClient.kt | 2 +- 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentPatch.kt 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 12fef253405..9f3ac9e47c1 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 @@ -9,6 +9,7 @@ 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 @Suppress("TooGenericExceptionCaught") @@ -63,12 +64,19 @@ class WeblateCli : CliktCommand( if (diffs.isEmpty()) { println(" ✅ Config matches common config") } else { - println(" ⚠\uFE0F Config differs (dry-run). Diff:") + println(" ⚠\uFE0F Config differs:") println() diffs.forEach { println(" $it") } if (!dryRun) { try { - val result = client.patchComponent(token, component.info.url, goldenConfig) + val result = client.patchComponent( + token, + component.info.url, + ComponentPatch( + category = component.info.category, + config = goldenConfig, + ), + ) if (result) { println(" ✅ Updated component config successfully") } else { 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 4cf9c997c1e..fe45b58e5d8 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 @@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable * @property name The name of the component. * @property url The URL of the component in Weblate. * @property slug The slug identifier of the component (suitable for matching/include lists) + * @property category The category of the component */ @Serializable data class ComponentInfo( @@ -16,4 +17,5 @@ data class ComponentInfo( val name: String, val slug: String, val url: String, + val category: String?, ) 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 new file mode 100644 index 00000000000..e9feb5aaa97 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentPatch.kt @@ -0,0 +1,55 @@ +package net.thunderbird.cli.weblate.api + +import kotlinx.serialization.KSerializer +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 Patch + * + * We need the category to prevent the API from resetting it to undefined when we update the config. + * + * @property category The category of the component + * @property config The configuration of the component to be updated + */ +@Serializable(with = ComponentPatch.ComponentPatchSerializer::class) +data class ComponentPatch( + val category: String?, + val config: ComponentConfig, +) { + companion object ComponentPatchSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ComponentPatch") { + element("category") + element("config", ComponentConfig.serializer().descriptor) + } + + override fun deserialize(decoder: Decoder): ComponentPatch { + error("Deserialization is not supported for ComponentPatch") + } + + override fun serialize(encoder: Encoder, value: ComponentPatch) { + require(encoder is JsonEncoder) { + "Expected JsonEncoder, got ${encoder::class.simpleName}" + } + + val config = encoder.json.encodeToJsonElement(ComponentConfig.serializer(), value.config) + + val json = buildJsonObject { + value.category?.let { put("category", it) } + config.jsonObject.forEach { (key, value) -> + put(key, value) + } + } + + encoder.encodeJsonElement(json) + } + } +} 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 6f96ccc3ce5..31b866b5a2e 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 @@ -41,7 +41,7 @@ class WeblateClient( return components } - fun patchComponent(token: String, url: String, patch: ComponentConfig): Boolean { + fun patchComponent(token: String, url: String, patch: ComponentPatch): Boolean { var success = false runBlocking { From 8a9f63dd7dfa033d62fe10f3fbfe9d4ebe4dd8fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Thu, 9 Apr 2026 09:46:30 +0200 Subject: [PATCH 4/5] fix(cli): remove properties that change the component type from linked to standalone --- cli/weblate-cli/golden-component-config.json | 8 ----- .../cli/weblate/ComponentConfigDiff.kt | 8 ----- .../net/thunderbird/cli/weblate/WeblateCli.kt | 1 + .../cli/weblate/api/ComponentConfig.kt | 34 ------------------- .../cli/weblate/api/ComponentInfo.kt | 8 +++-- .../cli/weblate/api/ComponentPatch.kt | 8 ++++- 6 files changed, 14 insertions(+), 53 deletions(-) diff --git a/cli/weblate-cli/golden-component-config.json b/cli/weblate-cli/golden-component-config.json index 50fd49a3d13..22f4bcabfb7 100644 --- a/cli/weblate-cli/golden-component-config.json +++ b/cli/weblate-cli/golden-component-config.json @@ -2,7 +2,6 @@ "license": "Apache-2.0", "license_url": "https://spdx.org/licenses/Apache-2.0.html", "agreement": "", - "report_source_bugs": "", "priority": 100, "is_glossary": false, "glossary_color": "silver", @@ -26,14 +25,8 @@ "secondary_language": null, - "vcs": "github", - "repo": "https://github.com/thunderbird/thunderbird-android", - "branch": "main", - "push": "", - "push_branch": "", "repoweb": "https://github.com/thunderbird/thunderbird-android/blob/{{branch}}/{{filename}}#L{{line}}", "commit_pending_age": 24, - "merge_style": "rebase", "auto_lock_error": true, "commit_message": "chore(i18n): translated using Weblate\r\n\r\nTranslation: {{ project_name }}/{{ component_name }}\r\nTranslate-URL: {{ url }}", @@ -53,7 +46,6 @@ "edit_template": false, "intermediate": "", - "new_base": "", "new_lang": "contact", "language_code_style": "", "screenshot_filemask": "" diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt index 64512fa85bf..5f03d80560d 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt @@ -27,7 +27,6 @@ object ComponentConfigDiff { value("license") { it.license }, value("license_url") { it.licenseUrl }, value("agreement") { it.agreement }, - value("report_source_bugs") { it.reportSourceBugs }, value("priority") { it.priority }, value("is_glossary") { it.isGlossary }, value("glossary_color") { it.glossaryColor }, @@ -43,14 +42,8 @@ object ComponentConfigDiff { set("enforced_checks") { it.enforcedChecks }, value("secondary_language") { it.secondaryLanguage }, - value("vcs") { it.vcs }, - value("repo") { it.repo }, - value("branch") { it.branch }, - value("push") { it.push }, - value("push_branch") { it.pushBranch }, value("repoweb") { it.repoweb }, value("commit_pending_age") { it.commitPendingAge }, - value("merge_style") { it.mergeStyle }, value("auto_lock_error") { it.autoLockError }, multiline("commit_message") { it.commitMessage }, @@ -67,7 +60,6 @@ object ComponentConfigDiff { value("edit_template") { it.editTemplate }, value("intermediate") { it.intermediate }, - value("new_base") { it.newBase }, value("new_lang") { it.newLang }, value("language_code_style") { it.languageCodeStyle }, value("screenshot_filemask") { it.screenshotFilemask }, 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 9f3ac9e47c1..9b5b062ac17 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 @@ -74,6 +74,7 @@ class WeblateCli : CliktCommand( component.info.url, ComponentPatch( category = component.info.category, + linkedComponent = component.info.linkedComponent, config = goldenConfig, ), ) diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt index 37bf464114d..0a7077719b7 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt @@ -12,7 +12,6 @@ import kotlinx.serialization.Serializable * @property license SPDX license identifier for the component * @property licenseUrl URL to the license text * @property agreement Any agreement text associated with the component - * @property reportSourceBugs URL where users should report issues * @property priority Component priority (numeric) * @property isGlossary Whether this component is a glossary * @property glossaryColor Color used to render glossary items in the UI @@ -20,20 +19,12 @@ import kotlinx.serialization.Serializable * @property suggestionVoting Whether suggestion voting is enabled * @property suggestionAutoaccept Number of votes required for auto-accept (or 0) * @property allowTranslationPropagation Whether translation propagation is allowed - * @property manageUnits Controls unit management behavior * @property checkFlags Miscellaneous check flags * @property variantRegex Regex used to identify language variants * @property enforcedChecks List of enforced check identifiers (e.g. "plurals") * @property secondaryLanguage Optional secondary language code - * @property vcs Version control system identifier (e.g. "github") - * @property repo Repository URL - * @property branch Default branch for the component's repo - * @property push Push URL or configuration - * @property pushBranch Branch used for pushes * @property repoweb Web UI link pattern for sources - * @property pushOnCommit Whether pushes should be triggered on commit * @property commitPendingAge Age (hours) before committing pending changes - * @property mergeStyle Merge strategy for commits (e.g. "rebase") * @property autoLockError Whether to auto-lock component on errors * @property commitMessage Template used for commit messages * @property addMessage Template used when adding translations @@ -46,7 +37,6 @@ import kotlinx.serialization.Serializable * @property fileFormatParams Nested file-format specific parameters * @property editTemplate Whether edit templates are enabled * @property intermediate Intermediate file path or marker - * @property newBase New base branch/file marker * @property newLang Default new language handling value * @property languageCodeStyle Style applied to language codes * @property screenshotFilemask File mask for screenshots @@ -62,9 +52,6 @@ data class ComponentConfig( @SerialName("agreement") val agreement: String = "", - @SerialName("report_source_bugs") - val reportSourceBugs: String = "", - @SerialName("priority") val priority: Int = 0, @@ -98,30 +85,12 @@ data class ComponentConfig( @SerialName("secondary_language") val secondaryLanguage: String? = null, - @SerialName("vcs") - val vcs: String = "", - - @SerialName("repo") - val repo: String = "", - - @SerialName("branch") - val branch: String = "", - - @SerialName("push") - val push: String = "", - - @SerialName("push_branch") - val pushBranch: String = "", - @SerialName("repoweb") val repoweb: String = "", @SerialName("commit_pending_age") val commitPendingAge: Int = 0, - @SerialName("merge_style") - val mergeStyle: String = "", - @SerialName("auto_lock_error") val autoLockError: Boolean = false, @@ -158,9 +127,6 @@ data class ComponentConfig( @SerialName("intermediate") val intermediate: String = "", - @SerialName("new_base") - val newBase: String = "", - @SerialName("new_lang") val newLang: String = "", 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 fe45b58e5d8..35c3f81b2fa 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 @@ -1,5 +1,6 @@ package net.thunderbird.cli.weblate.api +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable /** @@ -8,8 +9,9 @@ import kotlinx.serialization.Serializable * @property id The unique identifier of the component. * @property name The name of the component. * @property url The URL of the component in Weblate. - * @property slug The slug identifier of the component (suitable for matching/include lists) - * @property category The category of the component + * @property slug The slug identifier of the component. + * @property category The category url of the component. + * @property linkedComponent The url of the linked component. */ @Serializable data class ComponentInfo( @@ -18,4 +20,6 @@ data class ComponentInfo( val slug: String, val url: String, val category: String?, + @SerialName("linked_component") + val linkedComponent: String?, ) 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 e9feb5aaa97..5079c498f0f 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 @@ -1,6 +1,7 @@ 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 @@ -17,17 +18,21 @@ import kotlinx.serialization.json.put * * We need the category to prevent the API from resetting it to undefined when we update the config. * - * @property category The category of the component + * @property category The category url of the component + * @property linkedComponent The url of the linked component * @property config The configuration of the component to be updated */ @Serializable(with = ComponentPatch.ComponentPatchSerializer::class) data class ComponentPatch( val category: String?, + @SerialName("linked_component") + val linkedComponent: String?, val config: ComponentConfig, ) { companion object ComponentPatchSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ComponentPatch") { element("category") + element("linked_component") element("config", ComponentConfig.serializer().descriptor) } @@ -44,6 +49,7 @@ data class ComponentPatch( val json = buildJsonObject { value.category?.let { put("category", it) } + value.linkedComponent?.let { put("linked_component", it) } config.jsonObject.forEach { (key, value) -> put(key, value) } From eb1c0816a694697d11172c7bf9ede622b7894299 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montw=C3=A9?= Date: Thu, 9 Apr 2026 10:38:04 +0200 Subject: [PATCH 5/5] feat(cli): also disable pushOnCommit and fix JSON setup --- cli/weblate-cli/golden-component-config.json | 1 + .../thunderbird/cli/weblate/ComponentConfigDiff.kt | 1 + .../thunderbird/cli/weblate/api/ComponentConfig.kt | 11 +++++------ .../net/thunderbird/cli/weblate/api/WeblateClient.kt | 2 ++ 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/cli/weblate-cli/golden-component-config.json b/cli/weblate-cli/golden-component-config.json index 22f4bcabfb7..579d678954c 100644 --- a/cli/weblate-cli/golden-component-config.json +++ b/cli/weblate-cli/golden-component-config.json @@ -26,6 +26,7 @@ "repoweb": "https://github.com/thunderbird/thunderbird-android/blob/{{branch}}/{{filename}}#L{{line}}", + "push_on_commit": false, "commit_pending_age": 24, "auto_lock_error": true, diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt index 5f03d80560d..d0a51c21fd4 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt @@ -43,6 +43,7 @@ object ComponentConfigDiff { value("secondary_language") { it.secondaryLanguage }, value("repoweb") { it.repoweb }, + value("push_on_commit") { it.pushOnCommit }, value("commit_pending_age") { it.commitPendingAge }, value("auto_lock_error") { it.autoLockError }, diff --git a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt index 0a7077719b7..fe97fe29754 100644 --- a/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt @@ -23,7 +23,8 @@ import kotlinx.serialization.Serializable * @property variantRegex Regex used to identify language variants * @property enforcedChecks List of enforced check identifiers (e.g. "plurals") * @property secondaryLanguage Optional secondary language code - * @property repoweb Web UI link pattern for sources + * @property repoweb Web UI link pattern for + * @property pushOnCommit Whether to push changes on commit * @property commitPendingAge Age (hours) before committing pending changes * @property autoLockError Whether to auto-lock component on errors * @property commitMessage Template used for commit messages @@ -43,16 +44,13 @@ import kotlinx.serialization.Serializable */ @Serializable data class ComponentConfig( - @SerialName("license") val license: String = "", @SerialName("license_url") val licenseUrl: String = "", - @SerialName("agreement") val agreement: String = "", - @SerialName("priority") val priority: Int = 0, @SerialName("is_glossary") @@ -85,9 +83,11 @@ data class ComponentConfig( @SerialName("secondary_language") val secondaryLanguage: String? = null, - @SerialName("repoweb") val repoweb: String = "", + @SerialName("push_on_commit") + val pushOnCommit: Boolean = false, + @SerialName("commit_pending_age") val commitPendingAge: Int = 0, @@ -124,7 +124,6 @@ data class ComponentConfig( @SerialName("edit_template") val editTemplate: Boolean = false, - @SerialName("intermediate") val intermediate: String = "", @SerialName("new_lang") 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 31b866b5a2e..d012f958056 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 @@ -85,6 +85,8 @@ class WeblateClient( json( Json { ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false }, ) }