Skip to content

Commit 92934fd

Browse files
committed
feat(cli): implement configuration management and "golden" config enforcement
1 parent 5bc5ad1 commit 92934fd

13 files changed

Lines changed: 693 additions & 25 deletions

cli/weblate-cli/README.md

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,48 @@
11
# Weblate CLI
22

3-
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.
3+
This is a command line interface that inspects Weblate project components and applies a
4+
"golden" component configuration. It's intended for maintainers to review component configuration
5+
consistency and, when appropriate, patch components to match the golden config.
46

57
## Usage
68

7-
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.
9+
You need a Weblate API token (available from your Weblate account profile). A convenience wrapper script
10+
is provided at `./scripts/weblate` which builds and runs the CLI.
811

9-
You can run the script with the following command:
12+
Basic examples:
1013

1114
```bash
12-
./scripts/weblate --token <weblate-token> [--dry-run]
15+
# Dry-run using the default golden config and include file
16+
./scripts/weblate --token YOUR_WEBLATE_TOKEN --dry-run
17+
18+
# Apply changes to included components
19+
./scripts/weblate --token YOUR_WEBLATE_TOKEN
20+
21+
# Use a custom include file and golden config
22+
./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
1323
```
1424

15-
It will patch all components to the same configuration.
25+
## Defaults
1626

17-
If you want to preview the outcome you can pass the `--dry-run` argument. It will print out the changes that would be applied.
27+
- Golden config: `./cli/weblate-cli/golden-component-config.json`
28+
- Include file: `./cli/weblate-cli/include-components.txt`
1829

19-
```bash
20-
./scripts/weblate --token <weblate-token> --dry-run
30+
## Include file format
31+
32+
- One component slug per non-empty line. Inline comments are allowed after `#` and full-line comments that
33+
start with `#` are ignored.
34+
- Matching is exact and case-sensitive against the component slug returned by the Weblate API.
35+
36+
Example:
37+
38+
```
39+
# legacy
40+
app-strings # ID: 17093 (main)
41+
designsystem # ID: 25913
42+
app-common
2143
```
44+
45+
## Safety notes
46+
47+
- Always run with `--dry-run` first to verify diffs before applying changes to the live Weblate instance.
48+
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"license": "Apache-2.0",
3+
"license_url": "https://spdx.org/licenses/Apache-2.0.html",
4+
"agreement": "",
5+
"report_source_bugs": "",
6+
"priority": 100,
7+
"is_glossary": false,
8+
"glossary_color": "silver",
9+
10+
11+
"enable_suggestions": true,
12+
"suggestion_voting": false,
13+
"suggestion_autoaccept": 0,
14+
15+
"allow_translation_propagation": true,
16+
17+
"check_flags": "ignore-punctuation-spacing",
18+
"variant_regex": "",
19+
"enforced_checks": [
20+
"double_space",
21+
"translated",
22+
"java_printf_format",
23+
"plurals",
24+
"placeholders"
25+
],
26+
"secondary_language": null,
27+
28+
29+
"vcs": "github",
30+
"repo": "https://github.com/thunderbird/thunderbird-android",
31+
"branch": "main",
32+
"push": "",
33+
"push_branch": "",
34+
"repoweb": "https://github.com/thunderbird/thunderbird-android/blob/{{branch}}/{{filename}}#L{{line}}",
35+
"commit_pending_age": 24,
36+
"merge_style": "rebase",
37+
"auto_lock_error": true,
38+
39+
"commit_message": "chore(i18n): translated using Weblate\r\n\r\nTranslation: {{ project_name }}/{{ component_name }}\r\nTranslate-URL: {{ url }}",
40+
"add_message": "chore(i18n): added translation using Weblate ({{ language_name }})",
41+
"delete_message": "chore(i18n): deleted translation using Weblate ({{ language_name }})",
42+
"merge_message": "chore(i18n): merge branch '{{ component_remote_branch }}' into Weblate",
43+
"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 }}",
44+
"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}})",
45+
46+
47+
"language_regex": "^[^.]+$",
48+
"key_filter": "",
49+
50+
"file_format_params": {
51+
"xml_closing_tags": true
52+
},
53+
54+
"edit_template": false,
55+
"intermediate": "",
56+
"new_base": "",
57+
"new_lang": "contact",
58+
"language_code_style": "",
59+
"screenshot_filemask": ""
60+
}
61+
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# legacy
2+
app-strings # ID: 17093 (main)
3+
designsystem # ID: 25913
4+
account-common # ID: 25914
5+
account-setup # ID: 25915
6+
account-server-validation # ID: 25916
7+
account-server-settings # ID: 25917
8+
account-oauth # ID: 25918
9+
onboarding # ID: 25919
10+
onboarding-permissions # ID: 26293
11+
account-server-certificate # ID: 27694
12+
app-ui-base # ID: 27803
13+
settings-import # ID: 27804
14+
widget-unread # ID: 29555
15+
app-k9mail # ID: 29573
16+
app-thunderbird # ID: 29574
17+
widget-message-list # ID: 29632
18+
widget-shortcut # ID: 29717
19+
legacy-ui-folder # ID: 30127
20+
migration-qrcode # ID: 31033
21+
funding-googleplay # ID: 31077
22+
onboarding-migration # ID: 31088
23+
navigation-drawer-dropdown # ID: 34347
24+
app-common # ID: 37829
25+
core-ui-setting-dialog # ID: 37836
26+
feature-account-settings-impl # ID: 37837
27+
feature-mail-message-composer # ID: 37838
28+
# feature-mail-message-list # ID: 37839
29+
feature-widget-message-list-glance # ID: 37840
30+
feature-notification-api # ID: 37851
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
package net.thunderbird.cli.weblate
2+
3+
import net.thunderbird.cli.weblate.api.ComponentConfig
4+
5+
object ComponentConfigDiff {
6+
7+
fun computeConfigDiff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int = 0): List<String> {
8+
return fields.mapNotNull { it.diff(expected, actual, indentLevel) }
9+
}
10+
11+
private fun <T> value(
12+
name: String,
13+
selector: (ComponentConfig) -> T,
14+
): DiffField = ValueField(name, selector)
15+
16+
private fun set(
17+
name: String,
18+
selector: (ComponentConfig) -> List<String>,
19+
): DiffField = SetField(name, selector)
20+
21+
private fun multiline(
22+
name: String,
23+
selector: (ComponentConfig) -> String,
24+
): DiffField = MultilineField(name, selector)
25+
26+
private val fields: List<DiffField> = listOf(
27+
value("license") { it.license },
28+
value("license_url") { it.licenseUrl },
29+
value("agreement") { it.agreement },
30+
value("report_source_bugs") { it.reportSourceBugs },
31+
value("priority") { it.priority },
32+
value("is_glossary") { it.isGlossary },
33+
value("glossary_color") { it.glossaryColor },
34+
35+
value("enable_suggestions") { it.enableSuggestions },
36+
value("suggestion_voting") { it.suggestionVoting },
37+
value("suggestion_autoaccept") { it.suggestionAutoaccept },
38+
39+
value("allow_translation_propagation") { it.allowTranslationPropagation },
40+
41+
value("check_flags") { it.checkFlags },
42+
value("variant_regex") { it.variantRegex },
43+
set("enforced_checks") { it.enforcedChecks },
44+
value("secondary_language") { it.secondaryLanguage },
45+
46+
value("vcs") { it.vcs },
47+
value("repo") { it.repo },
48+
value("branch") { it.branch },
49+
value("push") { it.push },
50+
value("push_branch") { it.pushBranch },
51+
value("repoweb") { it.repoweb },
52+
value("commit_pending_age") { it.commitPendingAge },
53+
value("merge_style") { it.mergeStyle },
54+
value("auto_lock_error") { it.autoLockError },
55+
56+
multiline("commit_message") { it.commitMessage },
57+
multiline("add_message") { it.addMessage },
58+
multiline("delete_message") { it.deleteMessage },
59+
multiline("merge_message") { it.mergeMessage },
60+
multiline("addon_message") { it.addonMessage },
61+
multiline("pull_message") { it.pullMessage },
62+
63+
value("language_regex") { it.languageRegex },
64+
value("key_filter") { it.keyFilter },
65+
66+
value("file_format_params.xml_closing_tags") { it.fileFormatParams.xmlClosingTags },
67+
68+
value("edit_template") { it.editTemplate },
69+
value("intermediate") { it.intermediate },
70+
value("new_base") { it.newBase },
71+
value("new_lang") { it.newLang },
72+
value("language_code_style") { it.languageCodeStyle },
73+
value("screenshot_filemask") { it.screenshotFilemask },
74+
)
75+
}
76+
77+
private interface DiffField {
78+
fun diff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int): String?
79+
}
80+
81+
private class ValueField<T>(
82+
private val name: String,
83+
private val selector: (ComponentConfig) -> T,
84+
) : DiffField {
85+
override fun diff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int): String? {
86+
val expectedValue = selector(expected)
87+
val actualValue = selector(actual)
88+
val indent = " ".repeat(indentLevel * 2)
89+
90+
return if (expectedValue != actualValue) {
91+
"$indent$name: expected=$expectedValue, actual=$actualValue"
92+
} else {
93+
null
94+
}
95+
}
96+
}
97+
98+
private class SetField(
99+
private val name: String,
100+
private val selector: (ComponentConfig) -> List<String>,
101+
) : DiffField {
102+
override fun diff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int): String? {
103+
val expectedValue = selector(expected)
104+
val actualValue = selector(actual)
105+
106+
return if (expectedValue.toSet() != actualValue.toSet()) {
107+
listDiff(name, expectedValue, actualValue, indentLevel)
108+
} else {
109+
null
110+
}
111+
}
112+
113+
private fun listDiff(name: String, expected: List<String>, actual: List<String>, indentLevel: Int): String {
114+
val indent = " ".repeat(indentLevel * 2)
115+
val expectedSet = expected.toSet()
116+
val actualSet = actual.toSet()
117+
118+
val missing = expected.filter { it !in actualSet }
119+
val unexpected = actual.filter { it !in expectedSet }
120+
121+
val inner = buildString {
122+
if (missing.isNotEmpty()) {
123+
appendLine("missing:")
124+
missing.forEach { appendLine(" - $it") }
125+
}
126+
if (unexpected.isNotEmpty()) {
127+
appendLine("unexpected:")
128+
unexpected.forEach { appendLine(" + $it") }
129+
}
130+
}.trimEnd()
131+
132+
return if (inner.isEmpty()) {
133+
""
134+
} else {
135+
buildString {
136+
appendLine("$indent$name:")
137+
append(indentText(inner, indentLevel + 1))
138+
}.trimEnd()
139+
}
140+
}
141+
}
142+
143+
private class MultilineField(
144+
private val name: String,
145+
private val selector: (ComponentConfig) -> String,
146+
) : DiffField {
147+
override fun diff(expected: ComponentConfig, actual: ComponentConfig, indentLevel: Int): String? {
148+
val expectedValue = selector(expected)
149+
val actualValue = selector(actual)
150+
151+
return if (expectedValue != actualValue) {
152+
multilineDiff(name, expectedValue, actualValue, indentLevel)
153+
} else {
154+
null
155+
}
156+
}
157+
158+
private fun multilineDiff(name: String, expected: String, actual: String, indentLevel: Int): String {
159+
val indent = " ".repeat(indentLevel * 2)
160+
val expectedLines = expected.lines()
161+
val actualLines = actual.lines()
162+
val max = maxOf(expectedLines.size, actualLines.size)
163+
164+
val inner = buildString {
165+
for (i in 0 until max) {
166+
val exp = expectedLines.getOrNull(i)
167+
val act = actualLines.getOrNull(i)
168+
if (exp != act) {
169+
val expText = exp ?: "<missing>"
170+
val actText = act ?: "<missing>"
171+
appendLine(" [${i + 1}] expected: $expText")
172+
appendLine(" [${i + 1}] actual : $actText")
173+
}
174+
}
175+
}.trimEnd()
176+
177+
return if (inner.isEmpty()) {
178+
""
179+
} else {
180+
buildString {
181+
appendLine("$indent$name:")
182+
append(indentText(inner, indentLevel + 1))
183+
}.trimEnd()
184+
}
185+
}
186+
}
187+
188+
/**
189+
* Indent a multi-line string by a given indent level. Each level equals 2 spaces by default.
190+
*/
191+
private fun indentText(text: String, level: Int, spacesPerLevel: Int = 2): String =
192+
text.lines().joinToString("\n") { " ".repeat(level * spacesPerLevel) + it }
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package net.thunderbird.cli.weblate
2+
3+
import java.io.File
4+
import kotlinx.serialization.json.Json
5+
import net.thunderbird.cli.weblate.api.ComponentConfig
6+
7+
class ComponentConfigLoader {
8+
private val json = Json { ignoreUnknownKeys = true }
9+
10+
fun load(file: File): ComponentConfig {
11+
val text = file.readText(Charsets.UTF_8)
12+
return json.decodeFromString(ComponentConfig.serializer(), text)
13+
}
14+
}

0 commit comments

Comments
 (0)