diff --git a/cli/weblate-cli/README.md b/cli/weblate-cli/README.md new file mode 100644 index 00000000000..27b1d311988 --- /dev/null +++ b/cli/weblate-cli/README.md @@ -0,0 +1,48 @@ +# 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. + +## 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. + +Basic examples: + +```bash +# 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 +``` + +## Defaults + +- Golden config: `./cli/weblate-cli/golden-component-config.json` +- Include file: `./cli/weblate-cli/include-components.txt` + +## 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/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/golden-component-config.json b/cli/weblate-cli/golden-component-config.json new file mode 100644 index 00000000000..579d678954c --- /dev/null +++ b/cli/weblate-cli/golden-component-config.json @@ -0,0 +1,54 @@ +{ + "license": "Apache-2.0", + "license_url": "https://spdx.org/licenses/Apache-2.0.html", + "agreement": "", + "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, + + + "repoweb": "https://github.com/thunderbird/thunderbird-android/blob/{{branch}}/{{filename}}#L{{line}}", + "push_on_commit": false, + "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 }}", + "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_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..d0a51c21fd4 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/ComponentConfigDiff.kt @@ -0,0 +1,185 @@ +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("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("repoweb") { it.repoweb }, + value("push_on_commit") { it.pushOnCommit }, + value("commit_pending_age") { it.commitPendingAge }, + 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_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/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..9b5b062ac17 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/WeblateCli.kt @@ -0,0 +1,125 @@ +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 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") +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() + + 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() + 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}") + } + } +} 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..fe97fe29754 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentConfig.kt @@ -0,0 +1,143 @@ +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 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 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 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 + * @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 newLang Default new language handling value + * @property languageCodeStyle Style applied to language codes + * @property screenshotFilemask File mask for screenshots + */ +@Serializable +data class ComponentConfig( + val license: String = "", + + @SerialName("license_url") + val licenseUrl: String = "", + + val agreement: String = "", + + 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, + + val repoweb: String = "", + + @SerialName("push_on_commit") + val pushOnCommit: Boolean = false, + + @SerialName("commit_pending_age") + val commitPendingAge: Int = 0, + + @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, + + val intermediate: 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..35c3f81b2fa --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentInfo.kt @@ -0,0 +1,25 @@ +package net.thunderbird.cli.weblate.api + +import kotlinx.serialization.SerialName +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. + * @property category The category url of the component. + * @property linkedComponent The url of the linked component. + */ +@Serializable +data class ComponentInfo( + val id: Int, + val name: String, + 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 new file mode 100644 index 00000000000..5079c498f0f --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentPatch.kt @@ -0,0 +1,61 @@ +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 Patch + * + * We need the category to prevent the API from resetting it to undefined when we update the config. + * + * @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) + } + + 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) } + value.linkedComponent?.let { put("linked_component", 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/ComponentResponse.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentResponse.kt new file mode 100644 index 00000000000..c68e5c6d29a --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/ComponentResponse.kt @@ -0,0 +1,9 @@ +package net.thunderbird.cli.weblate.api + +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/api/WeblateClient.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateClient.kt new file mode 100644 index 00000000000..d012f958056 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateClient.kt @@ -0,0 +1,98 @@ +package net.thunderbird.cli.weblate.api + +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.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 +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 + } + + fun patchComponent(token: String, url: String, patch: ComponentPatch): 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 + + runBlocking { + componentResponse = client.get(config.componentsUrl(page)) { + headers { + config.getDefaultHeaders(token).forEach { (key, value) -> append(key, value) } + } + }.body() + } + + return componentResponse + } + + private companion object { + val SUCCESS = 200..299 + + fun createClient(): HttpClient { + return HttpClient(CIO) { + install(Logging) { + logger = Logger.DEFAULT + level = LogLevel.INFO + } + install(ContentNegotiation) { + json( + Json { + ignoreUnknownKeys = true + encodeDefaults = true + explicitNulls = false + }, + ) + } + } + } + + 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/api/WeblateConfig.kt b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateConfig.kt new file mode 100644 index 00000000000..e6596eabdb9 --- /dev/null +++ b/cli/weblate-cli/src/main/kotlin/net/thunderbird/cli/weblate/api/WeblateConfig.kt @@ -0,0 +1,26 @@ +package net.thunderbird.cli.weblate.api + +/** + * 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(