Skip to content

Commit

Permalink
Added Crowdin Integration (#19)
Browse files Browse the repository at this point in the history
Signed-off-by: Arnau Mora <arnyminerz@proton.me>
  • Loading branch information
ArnyminerZ authored Sep 6, 2023
1 parent cb3d3d9 commit 684c283
Show file tree
Hide file tree
Showing 7 changed files with 324 additions and 7 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,6 @@ bin/

### Ignore testing database ###
testing.db

### Ignore secrets file ###
secrets.env
6 changes: 5 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ plugins {
}

group = "com.arnyminerz.escalaralcoiaicomtat.backend"
version = "1.0.15"
version = "1.0.16"

repositories {
mavenCentral()
maven("https://jitpack.io")
}

val exposedVersion: String by project
Expand Down Expand Up @@ -64,6 +65,9 @@ dependencies {
// For displaying progress bar in terminal
implementation("me.tongfei:progressbar:0.10.0")

// Crowdin localization
implementation("com.github.crowdin:crowdin-api-client-java:1.11.2")


testImplementation(kotlin("test"))

Expand Down
2 changes: 1 addition & 1 deletion docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
services:
backend:
image: arnyminerz/escalaralcoiaicomtat:1.0-SNAPSHOT
restart: no
restart: unless-stopped
environment:
ENABLE_IMPORTER: "false"
15 changes: 10 additions & 5 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@ version: '3.7'

services:
backend:
image: escalaralcoiaicomtat:1.0.7-SNAPSHOT
image: escalaralcoiaicomtat:latest
container_name: escalaralcoiaicomtat_backend
restart: unless-stopped
restart: "no"
environment:
# Configure database
# -- Configure database
DATABASE_DRIVER: "org.postgresql.Driver"
DATABASE_URL: "jdbc:postgresql://db:5432/postgres"
DATABASE_USERNAME: "postgres"
DATABASE_PASSWORD: "AbfEwudQ8AqhhVVqTiRURpbMmQTW46"
# Configure Secure Endpoints
# -- Configure Secure Endpoints
AUTH_TOKEN: "cwBsTxhjf8mFQrN8zPUpGstmtCeg3z"
# Enable Importer
# -- Enable Importer
ENABLE_IMPORTER: "true"
# -- Configure Crowdin
# Required: CROWDIN_TOKEN, CROWDIN_PROJECT_ID
# Optional: CROWDIN_ORGANIZATION
env_file:
- "secrets.env"
networks:
- escalaralcoiaicomtat_backend
depends_on:
Expand Down
9 changes: 9 additions & 0 deletions src/main/kotlin/Main.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import com.arnyminerz.escalaralcoiaicomtat.backend.Logger
import com.arnyminerz.escalaralcoiaicomtat.backend.ServerDatabase
import com.arnyminerz.escalaralcoiaicomtat.backend.localization.Localization
import com.arnyminerz.escalaralcoiaicomtat.backend.server.plugins.configureEndpoints
import com.arnyminerz.escalaralcoiaicomtat.backend.server.plugins.installPlugins
import io.ktor.server.application.Application
Expand All @@ -12,6 +13,7 @@ import io.ktor.server.netty.Netty
import io.ktor.util.decodeBase64String
import java.io.File
import java.security.KeyStore
import kotlinx.coroutines.runBlocking

const val HTTP_PORT = 8080
const val HTTPS_PORT = 8443
Expand All @@ -22,6 +24,13 @@ fun main() {
Logger.info("Connecting to the database, and creating tables...")
ServerDatabase.instance

Logger.info("Initializing Crowdin connection...")
Localization.init()

runBlocking {
Localization.synchronizePathDescriptions()
}

val environment = applicationEngineEnvironment {
connector { port = HTTP_PORT }
configureSsl()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
package com.arnyminerz.escalaralcoiaicomtat.backend.localization

import com.arnyminerz.escalaralcoiaicomtat.backend.Logger
import com.arnyminerz.escalaralcoiaicomtat.backend.ServerDatabase
import com.arnyminerz.escalaralcoiaicomtat.backend.database.entity.Path
import com.arnyminerz.escalaralcoiaicomtat.backend.system.EnvironmentVariables
import com.crowdin.client.Client
import com.crowdin.client.core.http.exceptions.HttpBadRequestException
import com.crowdin.client.core.http.exceptions.HttpException
import com.crowdin.client.core.model.Credentials
import com.crowdin.client.core.model.PatchOperation
import com.crowdin.client.core.model.PatchRequest
import com.crowdin.client.sourcefiles.model.AddBranchRequest
import com.crowdin.client.sourcefiles.model.AddDirectoryRequest
import com.crowdin.client.sourcefiles.model.AddFileRequest
import com.crowdin.client.sourcefiles.model.Branch
import com.crowdin.client.sourcefiles.model.Directory
import com.crowdin.client.sourcefiles.model.FileInfo
import com.crowdin.client.sourcestrings.model.AddSourceStringRequest
import com.crowdin.client.sourcestrings.model.SourceString
import org.json.JSONObject

object Localization {
private const val BRANCH_NAME = "backend"
private const val BRANCH_TITLE = "Backend Localization"

private const val DIRECTORY_NAME = "database"
private const val DIRECTORY_TITLE = "Database"

private const val DESCRIPTIONS_FILE_NAME = "path_descriptions"
private const val DESCRIPTIONS_FILE_TITLE = "Path Descriptions"

@Volatile
private var client: Client? = null

@Volatile
private var projectId: Long? = null

@Volatile
private var pathDescriptionsFile: FileInfo? = null

/**
* Initializes the Crowdin localization service.
*
* @throws HttpException If a request to the Crowdin API fails.
* @throws HttpBadRequestException If a request to the Crowdin API was badly formatted.
*/
@Synchronized
fun init() {
val token = EnvironmentVariables.Localization.CrowdinToken.value
val organization = EnvironmentVariables.Localization.CrowdinOrganization.value
projectId = EnvironmentVariables.Localization.CrowdinProjectId.value?.toLongOrNull()

if (token == null || projectId == null) {
Logger.debug("Won't enable Crowdin integration: Environment variables not set")
return
}
Logger.info("Initializing Crowdin integration...")
Logger.debug("Crowdin organization: $organization")
Logger.debug("Crowdin Project ID: $projectId")

val credentials = Credentials(token, organization)
client = Client(credentials)

val branch: Branch = getBranch()
val directory: Directory = getDirectory(branch)

pathDescriptionsFile = getFile(directory, DESCRIPTIONS_FILE_NAME, DESCRIPTIONS_FILE_TITLE)

Logger.info("Crowdin is ready.")
}

suspend fun synchronizePathDescriptions() {
val pathDescriptionsFile = pathDescriptionsFile
if (pathDescriptionsFile == null) {
Logger.debug("Won't synchronize path descriptions with Crowdin: Not initialized")
return
}

Logger.info("Synchronizing path descriptions with Crowdin...")
val paths = ServerDatabase.instance.query {
Path.all().filter { it.description != null }
}
if (paths.isEmpty()) {
Logger.debug("There isn't any path with a description.")
} else {
paths.forEach { path ->
getSourceString(
pathDescriptionsFile,
"path_${path.id}",
path.description ?: "",
"Description for path ${path.id} (${path.displayName})"
)
}
}
}

/**
* Fetches the localization branch for the current [projectId] using [client].
*
* @throws HttpException If a request to the Crowdin API fails.
* @throws HttpBadRequestException If a request to the Crowdin API was badly formatted.
* @throws IllegalStateException If [client] or [projectId] is null
*/
private fun getBranch(): Branch {
val sourceFilesApi = client?.sourceFilesApi

check(sourceFilesApi != null) { "Client has not been initialized." }
check(projectId != null) { "projectId has not been initialized." }

val branches = sourceFilesApi.listBranches(projectId, BRANCH_NAME, 1, 0).data
return if (branches.isEmpty()) {
Logger.info("Crowdin branch not initialized. Creating...")

sourceFilesApi.addBranch(
projectId,
AddBranchRequest().apply {
name = BRANCH_NAME
title = BRANCH_TITLE
}
).data
} else {
Logger.debug("Crowdin branch already initialized.")

branches.first().data
}
}

/**
* Fetches the localization branch for the current [projectId] using [client].
*
* @param branch The branch where the directory is placed at.
*
* @throws HttpException If a request to the Crowdin API fails.
* @throws HttpBadRequestException If a request to the Crowdin API was badly formatted.
* @throws IllegalStateException If [client] or [projectId] is null
*/
private fun getDirectory(branch: Branch): Directory {
val sourceFilesApi = client?.sourceFilesApi

check(sourceFilesApi != null) { "Client has not been initialized." }
check(projectId != null) { "projectId has not been initialized." }

val directories = sourceFilesApi.listDirectories(
projectId,
branch.id,
null,
DIRECTORY_NAME,
null,
1,
0
).data
return if (directories.isEmpty()) {
Logger.info("Crowdin directory not initialized. Creating...")

sourceFilesApi.addDirectory(
projectId,
AddDirectoryRequest().apply {
branchId = branch.id
name = DIRECTORY_NAME
title = DIRECTORY_TITLE
}
).data
} else {
Logger.debug("Crowdin directory already initialized.")

directories.first().data
}
}

/**
* Fetches a file from Crowdin.
*
* @param directory The directory where the file is located at.
*
* @throws HttpException If a request to the Crowdin API fails.
* @throws HttpBadRequestException If a request to the Crowdin API was badly formatted.
* @throws IllegalStateException If [client] or [projectId] is null
*/
@Suppress("SameParameterValue")
private fun getFile(directory: Directory, name: String, title: String): FileInfo {
val sourceFilesApi = client?.sourceFilesApi
val storageApi = client?.storageApi

check(sourceFilesApi != null) { "Client has not been initialized." }
check(storageApi != null) { "Client has not been initialized." }
check(projectId != null) { "projectId has not been initialized." }

val descriptionsFiles = sourceFilesApi.listFiles(
projectId,
null,
directory.id,
DESCRIPTIONS_FILE_NAME,
null,
1,
0
).data
return if (descriptionsFiles.isEmpty()) {
Logger.info("Crowdin descriptions file not initialized. Creating storage...")

val storage = storageApi.addStorage("$name.json", JSONObject().toString()).data

Logger.info(" Storage ready. Creating file...")
sourceFilesApi.addFile(
projectId,
AddFileRequest().apply {
this.storageId = storage.id
this.directoryId = directory.id
this.name = "$name.json"
this.title = title
this.type = "json"
}
).data
} else {
Logger.debug("Crowdin descriptions file already initialized.")

descriptionsFiles.first().data
}
}

/**
* Gets or adds a source string to the given file.
*
* @param fileInfo The information of the file where the string will be added at. See [getFile].
* @param identifier Defines unique string identifier.
* @param text Text for translation.
* @param context Use to provide additional information for better source text understanding.
*
* @throws HttpException If a request to the Crowdin API fails.
* @throws HttpBadRequestException If a request to the Crowdin API was badly formatted.
* @throws IllegalStateException If [client] or [projectId] is null
*
* @return The requested [SourceString].
*/
private fun getSourceString(fileInfo: FileInfo, identifier: String, text: String, context: String?): SourceString {
val sourceStringsApi = client?.sourceStringsApi

check(sourceStringsApi != null) { "Client has not been initialized." }
check(projectId != null) { "projectId has not been initialized." }

val sourceStrings = sourceStringsApi.listSourceStrings(
projectId,
fileInfo.id,
0,
null,
null,
null,
identifier,
"identifier",
1,
0
).data

return if (sourceStrings.isEmpty()) {
Logger.debug("Creating source string ID#$identifier")

sourceStringsApi.addSourceString(
projectId,
AddSourceStringRequest().apply {
this.fileId = fileInfo.id
this.identifier = identifier
this.context = context
this.text = text
}
).data
} else {
sourceStrings.first().data.also { sourceString ->
val patchRequests = mutableListOf<PatchRequest>()
if (sourceString.text != text) {
patchRequests += PatchRequest().apply {
op = PatchOperation.REPLACE
path = "/text"
value = text
}
}
if (sourceString.context != context) {
patchRequests += PatchRequest().apply {
op = PatchOperation.REPLACE
path = "/context"
value = context
}
}
if (patchRequests.isNotEmpty()) {
Logger.debug("Source string ID#$identifier has been updated. Notifying Crowdin...")
sourceStringsApi.editSourceString(projectId, sourceString.id, patchRequests)
}
}
}
}
}
Loading

0 comments on commit 684c283

Please sign in to comment.