diff --git a/.gitignore b/.gitignore index cc63f88..8d27f86 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ bin/ ### Ignore testing database ### testing.db + +### Ignore secrets file ### +secrets.env diff --git a/build.gradle.kts b/build.gradle.kts index 7390411..6658829 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 @@ -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")) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f758366..c380f5a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: backend: image: arnyminerz/escalaralcoiaicomtat:1.0-SNAPSHOT - restart: no + restart: unless-stopped environment: ENABLE_IMPORTER: "false" diff --git a/docker-compose.yml b/docker-compose.yml index 25ba3f9..b72bf50 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/main/kotlin/Main.kt b/src/main/kotlin/Main.kt index a8454ef..00eb7a3 100644 --- a/src/main/kotlin/Main.kt +++ b/src/main/kotlin/Main.kt @@ -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 @@ -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 @@ -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() diff --git a/src/main/kotlin/com/arnyminerz/escalaralcoiaicomtat/backend/localization/Localization.kt b/src/main/kotlin/com/arnyminerz/escalaralcoiaicomtat/backend/localization/Localization.kt new file mode 100644 index 0000000..6354f39 --- /dev/null +++ b/src/main/kotlin/com/arnyminerz/escalaralcoiaicomtat/backend/localization/Localization.kt @@ -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() + 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) + } + } + } + } +} diff --git a/src/main/kotlin/com/arnyminerz/escalaralcoiaicomtat/backend/system/EnvironmentVariables.kt b/src/main/kotlin/com/arnyminerz/escalaralcoiaicomtat/backend/system/EnvironmentVariables.kt index 21a35b0..9f220fb 100644 --- a/src/main/kotlin/com/arnyminerz/escalaralcoiaicomtat/backend/system/EnvironmentVariables.kt +++ b/src/main/kotlin/com/arnyminerz/escalaralcoiaicomtat/backend/system/EnvironmentVariables.kt @@ -36,6 +36,12 @@ object EnvironmentVariables { data object AuthToken : EnvironmentVariable("AUTH_TOKEN") } + object Localization { + data object CrowdinToken : EnvironmentVariable("CROWDIN_TOKEN") + data object CrowdinOrganization : EnvironmentVariable("CROWDIN_ORGANIZATION") + data object CrowdinProjectId : EnvironmentVariable("CROWDIN_PROJECT_ID") + } + object Legacy { data object Importer : EnvironmentVariable("ENABLE_IMPORTER") }