Skip to content

Commit c563fb2

Browse files
authored
Merge pull request #182 from Q42/feature/po-editor-script
Feature/PO editor script
2 parents a38d802 + 12b488f commit c563fb2

3 files changed

Lines changed: 189 additions & 1 deletion

File tree

README.MD

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ can [generate a new keystore](https://developer.android.com/studio/publish/app-s
9898

9999
openssl base64 < upload-keystore.jks | tr -d '\n' | pbcopy
100100

101-
The above command will encode the file contents to base64 and copy it to your clipboard. Save it to your repo's github secrets in the variable KEYSTORE_BASE_64.
101+
The above command will encode the file contents to base64 and copy it to your clipboard. Save it to
102+
your repo's github secrets in the variable KEYSTORE_BASE_64.
102103

103104
Copy the file `./dummy_secrets.gradle` to `secrets.gradle` and fill in your own keystore details.
104105

@@ -142,6 +143,12 @@ Using Bitrise or another CI tool instead? Then you can skip the above and delete
142143

143144
- `./.github/workflows`
144145

146+
### Translations import from PO Editor
147+
148+
We often use PO editor in our projects as a string management tool. You can find the script
149+
and instructions to auto-fetch and update strings in the `./scripts/updatetranslations/README.md`
150+
file.
151+
145152
### Colors import from Figma
146153

147154
This project contains a script to convert color tokens from Figma to Compose format.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Translation Updater
2+
3+
Fetches and updates translation files from PO Editor.
4+
5+
## Requirements
6+
7+
- command line kotlin (`brew install kotlin`)
8+
9+
## Usage
10+
11+
1. Set the `POEDITOR_API_KEY` in the script to your PoEditor API key.
12+
2. Set the `POEDITOR_PROJECT_ID` in the script to your PoEditor Project Id.
13+
2. Modify the `languages` list in the script to include the desired language codes.
14+
3. Run the script from the root of the project:
15+
16+
```bash
17+
kotlin scripts/updatetranslations/updateTranslations.main.kts
18+
```
19+
20+
### Options
21+
22+
**Allow missing translations (default):**
23+
24+
```bash
25+
kotlin scripts/updatetranslations/updateTranslations.main.kts
26+
```
27+
28+
**Fail on missing translations:**
29+
30+
```bash
31+
kotlin scripts/updatetranslations/updateTranslations.main.kts -noMissingTranslations
32+
```
33+
34+
When the `-noMissingTranslations` flag is used, the script will exit with an error if any
35+
translations are empty/missing.
36+
37+
## What it does
38+
39+
1. Downloads translation files from PoEditor API for Dutch (nl) and other specified languages.
40+
2. Processes the XML content:
41+
- Removes empty translation strings (Android falls back to base language)
42+
- Replaces dots with underscores in string names (Android compatibility)
43+
- Converts iOS-style parameters (`%@`) to Android format (`%s`)
44+
- Removes server-specific and iOS-specific strings
45+
- Cleans up formatting and comments
46+
3. Writes the translations to `/core/ui/src/main/res/values[-xx]/strings.xml`
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
#!/usr/bin/env kotlin
2+
/**
3+
* This script downloads all the Android string translations
4+
* from the PoEditor API (https://poeditor.com/docs/api#projects_export)
5+
*/
6+
7+
@file:DependsOn("org.json:json:20230227")
8+
@file:DependsOn("com.squareup.okhttp3:okhttp:4.11.0")
9+
10+
import org.json.JSONObject
11+
import java.io.File
12+
import java.net.HttpURLConnection
13+
import java.net.URL
14+
import java.net.URLEncoder
15+
import kotlin.script.experimental.dependencies.DependsOn
16+
import kotlin.system.exitProcess
17+
18+
val POEDITOR_API_TOKEN = "set api token here"
19+
val POEDITOR_PROJECT_ID = "set project id here"
20+
21+
val resourceFolder = "./core/ui/src/main/res"
22+
23+
// Map containing pairs of <language, translation-file-path>
24+
val LANGUAGES = mapOf(
25+
"en-gb" to listOf(
26+
"$resourceFolder/values/strings.xml",
27+
"$resourceFolder/values-en/strings.xml"
28+
),
29+
"nl" to listOf("$resourceFolder/values-nl/strings.xml"),
30+
)
31+
32+
val POEDITOR_API_ENDPOINT = "https://api.poeditor.com/v2/projects/export"
33+
34+
// This defines the platform's format of the exported translation file
35+
// Android = android_strings / iOS = apple_strings
36+
val EXPORT_TYPE = "android_strings"
37+
38+
// Running this flag while translations are missing stops the execution
39+
val NO_MISSING_TRANSLATIONS_FLAG = "-noMissingTranslations"
40+
41+
val noMissingTranslations = args.getOrNull(0) == NO_MISSING_TRANSLATIONS_FLAG
42+
43+
if (noMissingTranslations) {
44+
println("Missing translations are NOT allowed.")
45+
} else {
46+
println("Missing translations are allowed.")
47+
}
48+
49+
// Note that we duplicate the default to /values-nl/ because without it nl-NL would default to /values-nl-rBE/ instead of /values/.
50+
// More details: https://stackoverflow.com/questions/69379425/android-os-language-lookup-let-os-know-what-the-apps-default-language-is
51+
for ((language, outputFiles) in LANGUAGES) {
52+
53+
outputFiles.forEach { outputFile ->
54+
File(outputFile).parentFile.mkdirs()
55+
}
56+
57+
val requestParams = mapOf(
58+
"api_token" to POEDITOR_API_TOKEN,
59+
"id" to POEDITOR_PROJECT_ID,
60+
"language" to language,
61+
"type" to EXPORT_TYPE,
62+
"order" to "terms"
63+
).entries.joinToString("&") { (key, value) ->
64+
"${URLEncoder.encode(key, "UTF-8")}=${URLEncoder.encode(value, "UTF-8")}"
65+
}
66+
67+
// Send post request to get download URL
68+
val connection = URL(POEDITOR_API_ENDPOINT).openConnection() as HttpURLConnection
69+
connection.apply {
70+
requestMethod = "POST"
71+
doOutput = true
72+
setRequestProperty("Content-Type", "application/x-www-form-urlencoded")
73+
outputStream.write(requestParams.toByteArray())
74+
}
75+
76+
val response = connection.inputStream.bufferedReader().readText()
77+
val responseJson = JSONObject(response)
78+
println(responseJson)
79+
val downloadUrl = responseJson.getJSONObject("result").getString("url")
80+
81+
// Download translation file
82+
var content = URL(downloadUrl).readText()
83+
84+
// Check for empty strings and stop if the missing translations flag is enabled
85+
// and there are missing translations
86+
val emptyStringsRegex = Regex("""".*><""")
87+
val emptyStrings = emptyStringsRegex.findAll(content).map { it.value }.toList()
88+
89+
if (emptyStrings.isNotEmpty() && noMissingTranslations) {
90+
println("ERROR: Translations for language $language contains the following empty (untranslated) strings:")
91+
emptyStrings.forEach { println(it.removeSuffix("><")) }
92+
exitProcess(1)
93+
}
94+
95+
// Remove empty strings. Android will fall back to the base language for these.
96+
// If the current language is the base language, the string will be removed from the project,
97+
// and the project won't compile. This is fine since the base language should not have empty strings.
98+
content = content.replace(Regex("""<string name=".*"></string>"""), "")
99+
100+
// Replace all dots in names/keys with underscores, android does not support dots:
101+
content = content.replace(Regex("""name="(.+?)"""")) { matchResult ->
102+
val newName = matchResult.groupValues[1].replace(".", "_")
103+
"""name="$newName""""
104+
}
105+
106+
// Replace all %@'s (iOS replacement param) with the android %s string replacement param
107+
content = content.replace("%@", "%s")
108+
109+
// Remove server specific strings
110+
content = content.replace(Regex("""<string name="server_.*">[\s\S]*?</string>"""), "")
111+
content = content.replace(Regex("""<plurals name="server_.*">[\s\S]*?</plurals>"""), "")
112+
113+
// Remove iOS specific strings
114+
content = content.replace(Regex("""<string name=".*_ios">[\s\S]*?</string>"""), "")
115+
content = content.replace(Regex("""<plurals name=".*_ios">[\s\S]*?</plurals>"""), "")
116+
117+
// Remove comments
118+
content = content.replace(Regex("""<!--(.*?)-->\n""", RegexOption.DOT_MATCHES_ALL), "")
119+
120+
// Remove double empty lines
121+
content = content.replace(Regex("""\n\s*\n"""), "\n\n")
122+
123+
// Remove double and triple tabs
124+
content = content.replace(" <string", " <string")
125+
content = content.replace(" <string", " <string")
126+
127+
// Write/overwrite downloaded file to disk
128+
for (outputFile in outputFiles) {
129+
println(" Writing to file $outputFile")
130+
File(outputFile).writeText(content)
131+
}
132+
}
133+
134+
println("Finished downloading translations")
135+

0 commit comments

Comments
 (0)