Skip to content
Merged
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
@@ -1,9 +1,19 @@
package cc.modlabs.klassicx.translation.interfaces

import cc.modlabs.klassicx.translation.Translation
import cc.modlabs.klassicx.translation.live.LiveUpdateEvent
import kotlinx.coroutines.flow.Flow

interface TranslationSource {
suspend fun getLanguages(): List<String>

suspend fun getTranslations(language: String): List<Translation>

/**
* Optional live updates stream. Implementations may return a Flow of [LiveUpdateEvent]
* if they support real-time updates via WebSocket or other push mechanisms.
*
* Default is null (no live updates available).
*/
fun liveUpdates(): Flow<LiveUpdateEvent>? = null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package cc.modlabs.klassicx.translation.live

/**
* Sealed hierarchy describing live update events pushed over the WebSocket.
*
* wireName values are stable and match the server contract:
* - "hello"
* - "key_created"
* - "key_deleted"
* - "key_updated"
*/
sealed interface LiveUpdateEvent {
val type: String
val translationId: String
}

data class HelloEvent(
override val translationId: String,
val permission: String,
) : LiveUpdateEvent {
override val type: String = "hello"
}

data class KeyCreatedEvent(
override val translationId: String,
val keyId: String,
val key: String,
val ts: String,
) : LiveUpdateEvent {
override val type: String = "key_created"
}

data class KeyDeletedEvent(
override val translationId: String,
val keyId: String,
val ts: String,
) : LiveUpdateEvent {
override val type: String = "key_deleted"
}

data class KeyUpdatedEvent(
override val translationId: String,
val keyId: String,
val locale: String,
val value: String?,
val ts: String,
) : LiveUpdateEvent {
override val type: String = "key_updated"
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,27 @@ package cc.modlabs.klassicx.translation.sources

import cc.modlabs.klassicx.translation.Translation
import cc.modlabs.klassicx.translation.interfaces.TranslationSource
import cc.modlabs.klassicx.translation.live.HelloEvent
import cc.modlabs.klassicx.translation.live.KeyCreatedEvent
import cc.modlabs.klassicx.translation.live.KeyDeletedEvent
import cc.modlabs.klassicx.translation.live.KeyUpdatedEvent
import cc.modlabs.klassicx.translation.live.LiveUpdateEvent
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.withContext
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.http.WebSocket
import java.nio.ByteBuffer
import java.util.concurrent.CompletionStage

/**
* A TranslationSource implementation that loads translations from a Transforge instance.
Expand All @@ -21,11 +34,14 @@ import java.net.http.HttpResponse
* @param baseUrl Base URL of the Transforge API, e.g. "https://transforge.example.com"
* The class will append the path segments itself, no trailing slash required.
* @param translationId The Transforge translation module ID to use for lookups.
* @param apiKey Optional API key for authenticated access. If provided, it will be sent via
* Authorization header and as X-API-Key; for WebSocket also as query param.
* @param httpClient Optional custom HttpClient, defaults to HttpClient.newHttpClient().
*/
class TransForgeTranslationSource(
private val baseUrl: String,
private val translationId: String,
private val apiKey: String? = null,
private val httpClient: HttpClient = HttpClient.newHttpClient(),
) : TranslationSource {

Expand All @@ -43,15 +59,19 @@ class TransForgeTranslationSource(
)

override suspend fun getLanguages(): List<String> = withContext(Dispatchers.IO) {
val request = HttpRequest.newBuilder()
val builder = HttpRequest.newBuilder()
.uri(
URI.create(
normalizeBaseUrl(baseUrl) +
"/api/translations/$translationId/locales"
)
)
.GET()
.build()

if (!apiKey.isNullOrBlank()) {
builder.header("X-API-Key", apiKey)
}
val request = builder.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

Expand All @@ -70,15 +90,19 @@ class TransForgeTranslationSource(
}

override suspend fun getTranslations(language: String): List<Translation> = withContext(Dispatchers.IO) {
val request = HttpRequest.newBuilder()
val builder = HttpRequest.newBuilder()
.uri(
URI.create(
normalizeBaseUrl(baseUrl) +
"/api/translations/$translationId/export/$language"
)
)
.GET()
.build()

if (!apiKey.isNullOrBlank()) {
builder.header("X-API-Key", apiKey)
}
val request = builder.build()

val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())

Expand All @@ -99,9 +123,107 @@ class TransForgeTranslationSource(
}
}

override fun liveUpdates(): Flow<LiveUpdateEvent>? {
// Provide live updates only if baseUrl and translationId are set (always) and apiKey is optional
val wsUri = buildWsUri()

return callbackFlow {
val listener = object : WebSocket.Listener {
override fun onOpen(webSocket: WebSocket) {
webSocket.request(1)
}

override fun onText(webSocket: WebSocket, data: CharSequence, last: Boolean): CompletionStage<*>? {
try {
val json = data.toString()
val obj = gson.fromJson(json, Map::class.java) as Map<*, *>
val type = obj["type"] as? String
val event: LiveUpdateEvent? = when (type) {
"hello" -> HelloEvent(
translationId = (obj["translationId"] as? String) ?: translationId,
permission = (obj["permission"] as? String) ?: "READ"
)
"key_created" -> KeyCreatedEvent(
translationId = (obj["translationId"] as? String) ?: translationId,
keyId = (obj["keyId"] as? String) ?: "",
key = (obj["key"] as? String) ?: "",
ts = (obj["ts"] as? String) ?: ""
)
"key_deleted" -> KeyDeletedEvent(
translationId = (obj["translationId"] as? String) ?: translationId,
keyId = (obj["keyId"] as? String) ?: "",
ts = (obj["ts"] as? String) ?: ""
)
"key_updated" -> KeyUpdatedEvent(
translationId = (obj["translationId"] as? String) ?: translationId,
keyId = (obj["keyId"] as? String) ?: "",
locale = (obj["locale"] as? String) ?: "",
value = obj["value"] as? String,
ts = (obj["ts"] as? String) ?: ""
)
else -> null
}
if (event != null) trySend(event).isSuccess
} catch (_: Throwable) {
// ignore malformed frames
} finally {
webSocket.request(1)
}
return null
}

override fun onBinary(webSocket: WebSocket, data: ByteBuffer, last: Boolean): CompletionStage<*>? {
webSocket.request(1)
return null
}

override fun onError(webSocket: WebSocket, error: Throwable) {
close(error)
}

override fun onClose(webSocket: WebSocket, statusCode: Int, reason: String?): CompletionStage<*>? {
close()
return null
}
}

val builder = httpClient.newWebSocketBuilder()
if (!apiKey.isNullOrBlank()) {
builder.header("X-API-Key", apiKey)
}
// Also add query param for redundancy
val uriWithQuery = if (!apiKey.isNullOrBlank()) URI.create("$wsUri${if (wsUri.contains("?")) "&" else "?"}api-key=${encode(apiKey)}") else URI.create(wsUri)

val ws = builder.buildAsync(uriWithQuery, listener)

awaitClose {
try {
ws.get()?.sendClose(WebSocket.NORMAL_CLOSURE, "client-close")
} catch (_: Throwable) {
}
}
}.buffer(capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST)
}

/**
* Ensures the base URL has no trailing slash.
*/
private fun normalizeBaseUrl(url: String): String =
if (url.endsWith("/")) url.dropLast(1) else url

private fun buildWsUri(): String {
val normalized = normalizeBaseUrl(baseUrl)
val uri = URI.create(normalized)
val scheme = when (uri.scheme?.lowercase()) {
"https" -> "wss"
"http" -> "ws"
"wss", "ws" -> uri.scheme
else -> "wss"
}
val authority = uri.authority ?: normalized.removePrefix("${uri.scheme}://")
val basePath = uri.path?.takeIf { it.isNotBlank() }?.let { if (it.endsWith("/")) it.dropLast(1) else it } ?: ""
return "$scheme://$authority$basePath/ws/translations/${encode(translationId)}"
}

private fun encode(s: String?): String = java.net.URLEncoder.encode(s ?: "", java.nio.charset.StandardCharsets.UTF_8)
}