diff --git a/src/main/kotlin/cc/modlabs/klassicx/translation/interfaces/TranslationSource.kt b/src/main/kotlin/cc/modlabs/klassicx/translation/interfaces/TranslationSource.kt index a0f4438..c80514a 100644 --- a/src/main/kotlin/cc/modlabs/klassicx/translation/interfaces/TranslationSource.kt +++ b/src/main/kotlin/cc/modlabs/klassicx/translation/interfaces/TranslationSource.kt @@ -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 suspend fun getTranslations(language: String): List + + /** + * 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? = null } \ No newline at end of file diff --git a/src/main/kotlin/cc/modlabs/klassicx/translation/live/LiveUpdateEvent.kt b/src/main/kotlin/cc/modlabs/klassicx/translation/live/LiveUpdateEvent.kt new file mode 100644 index 0000000..7e7e32a --- /dev/null +++ b/src/main/kotlin/cc/modlabs/klassicx/translation/live/LiveUpdateEvent.kt @@ -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" +} diff --git a/src/main/kotlin/cc/modlabs/klassicx/translation/sources/TransForgeTranslationSource.kt b/src/main/kotlin/cc/modlabs/klassicx/translation/sources/TransForgeTranslationSource.kt index 72341e0..5bc66b4 100644 --- a/src/main/kotlin/cc/modlabs/klassicx/translation/sources/TransForgeTranslationSource.kt +++ b/src/main/kotlin/cc/modlabs/klassicx/translation/sources/TransForgeTranslationSource.kt @@ -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. @@ -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 { @@ -43,7 +59,7 @@ class TransForgeTranslationSource( ) override suspend fun getLanguages(): List = withContext(Dispatchers.IO) { - val request = HttpRequest.newBuilder() + val builder = HttpRequest.newBuilder() .uri( URI.create( normalizeBaseUrl(baseUrl) + @@ -51,7 +67,11 @@ class TransForgeTranslationSource( ) ) .GET() - .build() + + if (!apiKey.isNullOrBlank()) { + builder.header("X-API-Key", apiKey) + } + val request = builder.build() val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) @@ -70,7 +90,7 @@ class TransForgeTranslationSource( } override suspend fun getTranslations(language: String): List = withContext(Dispatchers.IO) { - val request = HttpRequest.newBuilder() + val builder = HttpRequest.newBuilder() .uri( URI.create( normalizeBaseUrl(baseUrl) + @@ -78,7 +98,11 @@ class TransForgeTranslationSource( ) ) .GET() - .build() + + if (!apiKey.isNullOrBlank()) { + builder.header("X-API-Key", apiKey) + } + val request = builder.build() val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()) @@ -99,9 +123,107 @@ class TransForgeTranslationSource( } } + override fun liveUpdates(): Flow? { + // 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) } \ No newline at end of file