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
10 changes: 10 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ dependencies {
api("com.google.guava:guava:33.4.8-jre")
}

// Exclude files requiring ascend dependency when it's not available
sourceSets {
main {
kotlin {
exclude("**/TimeExtensions.kt")
exclude("**/ClazzLoader.kt")
}
}
}

tasks {
test {
useJUnitPlatform()
Expand Down
Empty file modified gradlew
100644 → 100755
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ package cc.modlabs.klassicx.translation

import cc.modlabs.klassicx.extensions.getInternalKlassicxLogger
import cc.modlabs.klassicx.tools.TempStorage
import cc.modlabs.klassicx.translation.interfaces.LiveUpdateCallback
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -51,12 +53,59 @@ class TranslationManager(
private var liveJob: Job? = null
private val liveMutex = Mutex()

/**
* List of registered callbacks for live update events.
* Callbacks are invoked when a live update event is received and processed.
* Using CopyOnWriteArrayList for thread-safe iteration during notification.
*/
private val liveUpdateCallbacks = java.util.concurrent.CopyOnWriteArrayList<LiveUpdateCallback>()

init {
// Attempt to start live updates collection if the source supports it
// This is best-effort and will no-op if live updates are not available.
scope.launch { ensureLiveUpdatesStarted() }
}

/**
* Registers a callback to be notified when live update events are received.
*
* The callback will be invoked for all live update events including:
* - HelloEvent - when the connection is established
* - KeyCreatedEvent - when a new translation key is created
* - KeyDeletedEvent - when a translation key is deleted
* - KeyUpdatedEvent - when a translation value is updated
*
* @param callback The callback to register
*/
fun registerLiveUpdateCallback(callback: LiveUpdateCallback) {
liveUpdateCallbacks.add(callback)
}

/**
* Unregisters a previously registered live update callback.
*
* @param callback The callback to unregister
* @return true if the callback was found and removed, false otherwise
*/
fun unregisterLiveUpdateCallback(callback: LiveUpdateCallback): Boolean {
return liveUpdateCallbacks.remove(callback)
}

/**
* Notifies all registered callbacks about a live update event.
*
* @param event The event to notify callbacks about
*/
private fun notifyLiveUpdateCallbacks(event: LiveUpdateEvent) {
for (callback in liveUpdateCallbacks) {
try {
callback.onLiveUpdate(event)
} catch (t: Throwable) {
getInternalKlassicxLogger().error("Error in live update callback", t)
}
}
}

/**
* Retrieves all translations from the cache.
*
Expand Down Expand Up @@ -189,6 +238,9 @@ class TranslationManager(
liveJob = scope.launch {
try {
flow.collect { evt ->
// Notify all registered callbacks about the event
notifyLiveUpdateCallbacks(evt)

when (evt) {
is HelloEvent -> {
getInternalKlassicxLogger().info("LiveUpdates connected for translation ${evt.translationId} with permission ${evt.permission}")
Expand Down
28 changes: 28 additions & 0 deletions src/main/kotlin/cc/modlabs/klassicx/translation/Translations.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package cc.modlabs.klassicx.translation

import cc.modlabs.klassicx.translation.interfaces.LiveUpdateCallback
import cc.modlabs.klassicx.translation.interfaces.TranslationHook
import cc.modlabs.klassicx.translation.interfaces.TranslationSource
import kotlinx.coroutines.CoroutineScope
Expand All @@ -15,6 +16,33 @@ object Translations {
translationHooks.add(hook)
}

/**
* Registers a callback to be notified when live update events are received.
*
* The callback will be invoked for all live update events including:
* - HelloEvent - when the connection is established
* - KeyCreatedEvent - when a new translation key is created
* - KeyDeletedEvent - when a translation key is deleted
* - KeyUpdatedEvent - when a translation value is updated
*
* @param callback The callback to register
* @throws UninitializedPropertyAccessException if called before [load] has been called
*/
fun registerLiveUpdateCallback(callback: LiveUpdateCallback) {
manager.registerLiveUpdateCallback(callback)
}

/**
* Unregisters a previously registered live update callback.
*
* @param callback The callback to unregister
* @return true if the callback was found and removed, false otherwise
* @throws UninitializedPropertyAccessException if called before [load] has been called
*/
fun unregisterLiveUpdateCallback(callback: LiveUpdateCallback): Boolean {
return manager.unregisterLiveUpdateCallback(callback)
}

fun getTranslation(language: String, key: String, placeholders: Map<String, Any?> = mapOf()): String? {
if (!Translations::manager.isInitialized) return null

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package cc.modlabs.klassicx.translation.interfaces

import cc.modlabs.klassicx.translation.live.LiveUpdateEvent

/**
* Callback interface for receiving translation live update events.
*
* Applications can register callbacks to be notified when translation
* changes are received via the live updates stream (WebSocket or similar).
*
* This is useful for scenarios where the application needs to react to
* translation changes, such as refreshing UI components or logging updates.
*/
fun interface LiveUpdateCallback {

/**
* Called when a live update event is received and processed.
*
* @param event The live update event that was received. Can be one of:
* - [cc.modlabs.klassicx.translation.live.HelloEvent] - Initial connection acknowledgment
* - [cc.modlabs.klassicx.translation.live.KeyCreatedEvent] - A new translation key was created
* - [cc.modlabs.klassicx.translation.live.KeyDeletedEvent] - A translation key was deleted
* - [cc.modlabs.klassicx.translation.live.KeyUpdatedEvent] - A translation value was updated
*/
fun onLiveUpdate(event: LiveUpdateEvent)

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package cc.modlabs.klassicx.translation

import cc.modlabs.klassicx.translation.interfaces.LiveUpdateCallback
import cc.modlabs.klassicx.translation.interfaces.TranslationSource
import cc.modlabs.klassicx.translation.live.HelloEvent
import cc.modlabs.klassicx.translation.live.KeyCreatedEvent
Expand All @@ -14,6 +15,7 @@ import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
Expand Down Expand Up @@ -67,6 +69,22 @@ class TranslationManagerTest {
assertEquals(expected, last)
}

/**
* Helper function to wait for a condition to become true within a timeout.
* Uses real time (System.nanoTime) to handle timing since the live updates
* collector runs on Dispatchers.Default, not the test scheduler.
*/
private suspend fun waitUntil(
predicate: () -> Boolean,
timeoutMs: Long = 1500L,
stepMs: Long = 25L,
) {
val start = System.nanoTime()
while (!predicate() && (System.nanoTime() - start) / 1_000_000 < timeoutMs) {
delay(stepMs)
}
}

@Test
fun load_and_get_with_placeholders() = runTest {
val src = FakeTranslationSource()
Expand Down Expand Up @@ -170,4 +188,190 @@ class TranslationManagerTest {
assertEventuallyEquals(expected = "VALUE_EN", supplier = { manager.get("en_US", "new_key")?.message })
assertEventuallyEquals(expected = "WERT_DE", supplier = { manager.get("de_DE", "new_key")?.message })
}

@Test
fun live_update_callback_invoked_on_key_updated() = runTest {
val src = FakeTranslationSource()
src.put("en_US", "greet", "Hello")
val manager = TranslationManager(src)
manager.loadTranslations()
advanceUntilIdle()

val receivedEvents = mutableListOf<LiveUpdateEvent>()
manager.registerLiveUpdateCallback(LiveUpdateCallback { event ->
receivedEvents.add(event)
})

val keyUpdatedEvent = KeyUpdatedEvent(
translationId = "test",
keyId = "id-1",
locale = "en_US",
value = "Hello Updated",
ts = "2025-01-01T00:00:00Z"
)
src.emit(keyUpdatedEvent)

// Wait for the event to be processed
waitUntil(predicate = { receivedEvents.isNotEmpty() }, timeoutMs = 3000L)

assertEquals(1, receivedEvents.size)
val event = receivedEvents[0]
assertTrue(event is KeyUpdatedEvent)
assertEquals("en_US", (event as KeyUpdatedEvent).locale)
}

@Test
fun live_update_callback_invoked_on_hello_event() = runTest {
val src = FakeTranslationSource()
val manager = TranslationManager(src)
manager.loadTranslations()
advanceUntilIdle()

val receivedEvents = mutableListOf<LiveUpdateEvent>()
manager.registerLiveUpdateCallback { event ->
receivedEvents.add(event)
}

val helloEvent = HelloEvent(
translationId = "test",
permission = "READ"
)
src.emit(helloEvent)

// Wait for the event to be processed
waitUntil(predicate = { receivedEvents.isNotEmpty() }, timeoutMs = 3000L)

assertEquals(1, receivedEvents.size)
assertTrue(receivedEvents[0] is HelloEvent)
assertEquals("READ", (receivedEvents[0] as HelloEvent).permission)
}

@Test
fun live_update_callback_invoked_on_key_created() = runTest {
val src = FakeTranslationSource()
src.put("en_US", "existing", "Value")
val manager = TranslationManager(src)
manager.loadTranslations()
advanceUntilIdle()

val receivedEvents = mutableListOf<LiveUpdateEvent>()
manager.registerLiveUpdateCallback { event ->
receivedEvents.add(event)
}

val keyCreatedEvent = KeyCreatedEvent(
translationId = "test",
keyId = "new-key-id",
key = "new_key",
ts = "2025-01-01T00:00:00Z"
)
src.emit(keyCreatedEvent)

// Wait for the event to be processed
waitUntil(predicate = { receivedEvents.isNotEmpty() }, timeoutMs = 3000L)

assertEquals(1, receivedEvents.size)
assertTrue(receivedEvents[0] is KeyCreatedEvent)
assertEquals("new_key", (receivedEvents[0] as KeyCreatedEvent).key)
}

@Test
fun live_update_callback_invoked_on_key_deleted() = runTest {
val src = FakeTranslationSource()
src.put("en_US", "to_delete", "Value")
val manager = TranslationManager(src)
manager.loadTranslations()
advanceUntilIdle()

val receivedEvents = mutableListOf<LiveUpdateEvent>()
manager.registerLiveUpdateCallback { event ->
receivedEvents.add(event)
}

val keyDeletedEvent = KeyDeletedEvent(
translationId = "test",
keyId = "delete-key-id",
ts = "2025-01-01T00:00:00Z"
)
src.emit(keyDeletedEvent)

// Wait for the event to be processed
waitUntil(predicate = { receivedEvents.isNotEmpty() }, timeoutMs = 3000L)

assertEquals(1, receivedEvents.size)
assertTrue(receivedEvents[0] is KeyDeletedEvent)
}

@Test
fun multiple_callbacks_all_receive_events() = runTest {
val src = FakeTranslationSource()
src.put("en_US", "greet", "Hello")
val manager = TranslationManager(src)
manager.loadTranslations()
advanceUntilIdle()

val receivedEvents1 = mutableListOf<LiveUpdateEvent>()
val receivedEvents2 = mutableListOf<LiveUpdateEvent>()

manager.registerLiveUpdateCallback { event ->
receivedEvents1.add(event)
}
manager.registerLiveUpdateCallback { event ->
receivedEvents2.add(event)
}

src.emit(HelloEvent(translationId = "test", permission = "WRITE"))

// Wait for events to be processed
waitUntil(predicate = { receivedEvents1.isNotEmpty() && receivedEvents2.isNotEmpty() }, timeoutMs = 3000L)

assertEquals(1, receivedEvents1.size)
assertEquals(1, receivedEvents2.size)
}

@Test
fun unregister_callback_stops_notifications() = runTest {
val src = FakeTranslationSource()
src.put("en_US", "greet", "Hello")
val manager = TranslationManager(src)
manager.loadTranslations()
advanceUntilIdle()

val receivedEvents = java.util.concurrent.CopyOnWriteArrayList<LiveUpdateEvent>()
val callback = LiveUpdateCallback { event ->
receivedEvents.add(event)
}

manager.registerLiveUpdateCallback(callback)

// Emit first event - should be received
src.emit(HelloEvent(translationId = "test", permission = "READ"))

// Poll with real time waiting since live updates run on Default dispatcher
waitUntil(predicate = { receivedEvents.isNotEmpty() }, timeoutMs = 3000L)
assertEquals(1, receivedEvents.size, "First event should be received")

// Unregister the callback
val removed = manager.unregisterLiveUpdateCallback(callback)
assertTrue(removed, "Callback should be removed")

// Emit second event - should NOT be received
src.emit(HelloEvent(translationId = "test", permission = "WRITE"))

// Wait a short time to allow any potential event processing
delay(200)
assertEquals(1, receivedEvents.size, "Second event should NOT be received after unregister")
}

@Test
fun unregister_nonexistent_callback_returns_false() = runTest {
val src = FakeTranslationSource()
val manager = TranslationManager(src)
manager.loadTranslations()
advanceUntilIdle()

val callback = LiveUpdateCallback { }
val removed = manager.unregisterLiveUpdateCallback(callback)
assertFalse(removed)
}
}
Loading