Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ open class LocalizerExtension(project: Project) {
val filters: ListProperty<String> = project.objects.listProperty(String::class.java)
val tags: ListProperty<String> = project.objects.listProperty(String::class.java)
val supportRegions: Property<String> = project.objects.property(String::class.java)
val slackWebHook: Property<String> = project.objects.property(String::class.java)
val validateTranslations: Property<String> = project.objects.property(String::class.java)
}
2 changes: 2 additions & 0 deletions src/main/kotlin/world/betterme/localizer/LocalizerPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ open class LocalizerPlugin : Plugin<Project> {
it.filters?.addAll(extension.filters)
it.tags?.addAll(extension.tags)
it.supportRegions?.set(supportRegions)
it.slackWebHook.set(extension.slackWebHook)
it.validateTranslations.set(extension.validateTranslations)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,46 +1,63 @@
package world.betterme.localizer

import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.TaskAction
import org.gradle.api.DefaultTask
import org.gradle.api.provider.ListProperty
import org.gradle.api.provider.Property
import world.betterme.localizer.data.models.ApiParams
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.TaskAction
import world.betterme.localizer.core.TranslationsLoaderFactory
import world.betterme.localizer.data.models.ApiParams

open class TranslationsDownloaderTask : DefaultTask() {

@Input
val apiToken: Property<String> = project.objects.property(String::class.java)

@Input
val projectId: Property<String> = project.objects.property(String::class.java)

@Input
val resourcesPath: Property<String> = project.objects.property(String::class.java)

@get:Input
@get:Optional
val supportRegions: Property<String>? = project.objects.property(String::class.java)

@get:Input
@get:Optional
val filters: ListProperty<String>? = project.objects.listProperty(String::class.java)

@get:Input
@get:Optional
val tags: ListProperty<String>? = project.objects.listProperty(String::class.java)

@Input
val slackWebHook: Property<String> = project.objects.property(String::class.java)

@get:Input
@get:Optional
val validateTranslations: Property<String> = project.objects.property(String::class.java)

init {
description = "Downloads translations for all locales supported in this project"
group = "translations"
}

@TaskAction
fun downloadTranslations() {
val apiParams = ApiParams(apiToken = apiToken.get(), projectId = projectId.get())
val apiParams = ApiParams(
apiToken = apiToken.get(),
projectId = projectId.get(),
slackWebHook = slackWebHook.get()
)
val translationsLoader = TranslationsLoaderFactory.create(apiParams)
translationsLoader.downloadLocalizedStrings(
resFolderPath = resourcesPath.get(),
filters = filters?.get() ?: emptyList(),
tags = tags?.get() ?: emptyList(),
supportRegions = supportRegions?.get()?.toBoolean() ?: false
supportRegions = supportRegions?.get()?.toBoolean() ?: false,
validateTranslations = validateTranslations.get().toBoolean(),
)
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package world.betterme.localizer

import org.gradle.api.DefaultTask
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.TaskAction
import org.gradle.api.provider.Property
import world.betterme.localizer.data.models.ApiParams
import world.betterme.localizer.core.TranslationsLoaderFactory
import world.betterme.localizer.data.models.ApiParams

open class TranslationsUploaderTask : DefaultTask() {

Expand Down Expand Up @@ -34,7 +34,11 @@ open class TranslationsUploaderTask : DefaultTask() {
@Suppress("unused")
@TaskAction
fun uploadTranslations() {
val apiParams = ApiParams(apiToken = apiToken.get(), projectId = projectId.get())
val apiParams = ApiParams(
apiToken = apiToken.get(),
projectId = projectId.get(),
slackWebHook = "" // no-op
)
val translationsLoader = TranslationsLoaderFactory.create(apiParams)
translationsLoader.uploadTermsAndTranslations(
resFolderPath = resourcesPath.get(),
Expand Down
37 changes: 37 additions & 0 deletions src/main/kotlin/world/betterme/localizer/core/SlackNotifier.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package world.betterme.localizer.core

import java.io.OutputStream
import java.net.HttpURLConnection
import java.net.URL

class SlackNotifier(private val webHookUrl: String) {

fun sendSlackMessage(message: String) {
val url = URL(webHookUrl)
val connection = url.openConnection() as HttpURLConnection

try {
connection.requestMethod = "POST"
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")

val jsonPayload = """{"text": "$message"}"""

connection.outputStream.use { outputStream: OutputStream ->
outputStream.write(jsonPayload.toByteArray(Charsets.UTF_8))
outputStream.flush()
}

val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
println("Slack message sent successfully!")
} else {
println("Failed to send Slack message. Response code: $responseCode")
}
} catch (e: Exception) {
println("Error sending Slack message: ${e.message}")
} finally {
connection.disconnect()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ interface TranslationsLoader {
resFolderPath: String,
filters: List<String>,
tags: List<String>,
supportRegions: Boolean
supportRegions: Boolean,
validateTranslations: Boolean,
)

/**
Expand All @@ -40,24 +41,33 @@ interface TranslationsLoader {
internal class TranslationsLoaderImpl(
private val apiParams: ApiParams,
private val restStore: TranslationsRestStore,
private val localStore: TranslationsLocalStore
private val localStore: TranslationsLocalStore,
private val translationsValidator: TranslationsValidator,
) : TranslationsLoader {

override fun downloadLocalizedStrings(
resFolderPath: String,
filters: List<String>,
tags: List<String>,
supportRegions: Boolean
supportRegions: Boolean,
validateTranslations: Boolean,
) {
val availableLocales = restStore.getAvailableLanguages(apiParams)
println("Retrieved list of locales available for this project: $availableLocales")
val urls = restStore.loadTranslationsUrls(availableLocales, filters, tags, apiParams)
urls.forEach { (locale, url) ->
println("Starting translations for exportLocale $locale download with filters [$filters] and tags [$tags]")
val fileContent = restStore.loadTranslationsContent(url)
if (validateTranslations) {
translationsValidator.appendTranslationContent(locale, fileContent)
}
println("Saving translations for exportLocale $locale")
localStore.saveToFile(resFolderPath, fileContent, locale, supportRegions)
}

if (validateTranslations) {
translationsValidator.validateAll()
}
}

override fun uploadTermsAndTranslations(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package world.betterme.localizer.core

import com.google.gson.Gson
import okhttp3.OkHttpClient
import world.betterme.localizer.data.TranslationsLocalStore
import world.betterme.localizer.data.TranslationsLocalStoreImpl
import world.betterme.localizer.data.TranslationsRestStore
import world.betterme.localizer.data.TranslationsRestStoreImpl
import world.betterme.localizer.data.models.ApiParams
import com.google.gson.Gson
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit

class TranslationsLoaderFactory {
Expand All @@ -18,10 +18,16 @@ class TranslationsLoaderFactory {
*/
@JvmStatic
fun create(apiParams: ApiParams): TranslationsLoader {
val parser = XmlParser()
val notifier = SlackNotifier(apiParams.slackWebHook)
return TranslationsLoaderImpl(
apiParams = apiParams,
restStore = translationsRestStore(),
localStore = translationsLocalStore()
localStore = translationsLocalStore(),
translationsValidator = TranslationsValidator(
parser,
notifier
)
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package world.betterme.localizer.core

import java.util.Locale

typealias Resources = Map<String, String>

class TranslationsValidator(
private val xmlParser: XmlParser,
private val notifier: SlackNotifier
) {
private val translations = mutableMapOf<Locale, Resources>()
private val validationErrors = mutableListOf<String>()

fun appendTranslationContent(locale: String, translationContent: String) {
val parsedTranslationContent = xmlParser.parseXmlStrings(translationContent)
translations[Locale.forLanguageTag(locale)] = parsedTranslationContent
}

fun validateAll() {
println("Starting validation placeholders")
val referenceLocale = Locale.ENGLISH
val referenceTranslation = translations[referenceLocale]
?: throw IllegalArgumentException("Reference locale $referenceLocale is missing.")

translations.forEach { (locale, translationStrings) ->
translationStrings.forEach { (key, localizedText) ->
val referenceText = referenceTranslation[key]
if (referenceText != null) {
validatePlaceholders(referenceText, localizedText, locale, key)
}
}
}

if (validationErrors.isNotEmpty()) {
val report = validationErrors.joinToString(separator = "\n")
notifier.sendSlackMessage("Translation validation issues:\n$report")
} else {
println("All translations validated successfully!")
}
}

private fun validatePlaceholders(
referenceText: String,
localizedText: String,
locale: Locale,
key: String
) {
val referencePlaceholders = extractPlaceholdersFromText(referenceText)
val localizedPlaceholders = extractPlaceholdersFromText(localizedText)

// Validate placeholder count
if (referencePlaceholders.size != localizedPlaceholders.size) {
validationErrors.add(
"Placeholder count mismatch for key '$key' in locale '$locale'. " +
"Expected ${referencePlaceholders.size}, found ${localizedPlaceholders.size}."
)
}

// Validate placeholder types
referencePlaceholders.forEachIndexed { index, referencePlaceholder ->
val localizedPlaceholder = localizedPlaceholders.getOrNull(index)
if (localizedPlaceholder == null || referencePlaceholder != localizedPlaceholder) {
validationErrors.add(
"Placeholder type mismatch for key '$key' in locale '$locale' at position $index. " +
"Expected $referencePlaceholder, found $localizedPlaceholder."
)
}
}
}

/**
* Extracts placeholders like %1$s, %1$d from the text.
*/
private fun extractPlaceholdersFromText(text: String): List<String> {
val placeholderRegex = """%\d+\$[sd]""".toRegex()
return placeholderRegex.findAll(text).map { it.value }.toList()
}
}





29 changes: 29 additions & 0 deletions src/main/kotlin/world/betterme/localizer/core/XmlParser.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package world.betterme.localizer.core

import org.w3c.dom.Document
import javax.xml.parsers.DocumentBuilderFactory


class XmlParser {
fun parseXmlStrings(content: String): Resources {
val translations = mutableMapOf<String, String>()

val factory = DocumentBuilderFactory.newInstance()
val builder = factory.newDocumentBuilder()
val document: Document = builder.parse(content.byteInputStream())
document.documentElement.normalize()

val nodeList = document.getElementsByTagName("string")

for (i in 0 until nodeList.length) {
val node = nodeList.item(i)
val name = node.attributes.getNamedItem("name").nodeValue
val textContent = node.textContent

translations[name] = textContent
}
return translations
}

}

Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ package world.betterme.localizer.data.models

data class ApiParams(
val apiToken: String,
val projectId: String
val projectId: String,
val slackWebHook: String,
)