Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions cli/weblate-cli/README.md
Original file line number Diff line number Diff line change
@@ -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.

25 changes: 25 additions & 0 deletions cli/weblate-cli/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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
}
54 changes: 54 additions & 0 deletions cli/weblate-cli/golden-component-config.json
Original file line number Diff line number Diff line change
@@ -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": ""
}

30 changes: 30 additions & 0 deletions cli/weblate-cli/include-components.txt
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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<String> {
return fields.mapNotNull { it.diff(expected, actual, indentLevel) }
}

private fun <T> value(
name: String,
selector: (ComponentConfig) -> T,
): DiffField = ValueField(name, selector)

private fun set(
name: String,
selector: (ComponentConfig) -> List<String>,
): DiffField = SetField(name, selector)

private fun multiline(
name: String,
selector: (ComponentConfig) -> String,
): DiffField = MultilineField(name, selector)

private val fields: List<DiffField> = 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<T>(
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<String>,
) : 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<String>, actual: List<String>, 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 ?: "<missing>"
val actText = act ?: "<missing>"
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 }
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package net.thunderbird.cli.weblate

import com.github.ajalt.clikt.core.main

fun main(args: Array<String>) = WeblateCli().main(args)
Loading
Loading