diff --git a/build.gradle.kts b/build.gradle.kts index 580ffb9..3ffe121 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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() diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/kotlin/cc/modlabs/klassicx/translation/TranslationManager.kt b/src/main/kotlin/cc/modlabs/klassicx/translation/TranslationManager.kt index 8e67e1d..34466fb 100644 --- a/src/main/kotlin/cc/modlabs/klassicx/translation/TranslationManager.kt +++ b/src/main/kotlin/cc/modlabs/klassicx/translation/TranslationManager.kt @@ -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 @@ -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() + 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. * @@ -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}") diff --git a/src/main/kotlin/cc/modlabs/klassicx/translation/Translations.kt b/src/main/kotlin/cc/modlabs/klassicx/translation/Translations.kt index fb2e88d..100ddb9 100644 --- a/src/main/kotlin/cc/modlabs/klassicx/translation/Translations.kt +++ b/src/main/kotlin/cc/modlabs/klassicx/translation/Translations.kt @@ -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 @@ -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 = mapOf()): String? { if (!Translations::manager.isInitialized) return null diff --git a/src/main/kotlin/cc/modlabs/klassicx/translation/interfaces/LiveUpdateCallback.kt b/src/main/kotlin/cc/modlabs/klassicx/translation/interfaces/LiveUpdateCallback.kt new file mode 100644 index 0000000..3c05e0a --- /dev/null +++ b/src/main/kotlin/cc/modlabs/klassicx/translation/interfaces/LiveUpdateCallback.kt @@ -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) + +} diff --git a/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationManagerTest.kt b/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationManagerTest.kt index 173767b..cb4fdb1 100644 --- a/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationManagerTest.kt +++ b/src/test/kotlin/cc/modlabs/klassicx/translation/TranslationManagerTest.kt @@ -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 @@ -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 @@ -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() @@ -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() + 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() + 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() + 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() + 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() + val receivedEvents2 = mutableListOf() + + 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() + 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) + } }