@@ -2,240 +2,112 @@ package dev.slne.surf.surfapi.core.api.messages.bundle
22
33import dev.slne.surf.surfapi.core.api.messages.BundlePath
44import dev.slne.surf.surfapi.core.api.messages.adventure.key
5- import dev.slne.surf.surfapi.core.api.util.mutableObjectListOf
6- import dev.slne.surf.surfapi.core.api.util.toObjectSet
7- import it.unimi.dsi.fastutil.objects.ObjectList
5+ import io.ktor.client.*
6+ import io.ktor.client.call.*
7+ import io.ktor.client.engine.okhttp.*
8+ import io.ktor.client.request.*
9+ import io.ktor.http.*
810import net.kyori.adventure.text.Component
9- import net.kyori.adventure.text.TranslatableComponent
1011import net.kyori.adventure.translation.GlobalTranslator
1112import net.kyori.adventure.translation.TranslationRegistry
1213import net.kyori.adventure.util.UTF8ResourceBundleControl
1314import org.jetbrains.annotations.NonNls
14- import java.net.URLClassLoader
15- import java.nio.file.FileSystems
16- import java.nio.file.Path
1715import java.util.*
1816import java.util.function.Supplier
19- import kotlin.io.path.*
2017
2118/* *
22- * A class for managing and loading message bundles used for translations in a plugin environment .
19+ * A message bundle that loads translations from a Weblate project .
2320 *
24- * This class provides functionality to:
25- * - Load resource bundles from both the classpath and the plugin's data folder.
26- * - Copy missing resource bundles from the classpath to the data folder.
27- * - Update resource bundles in the data folder with missing keys from the bundled resources.
28- * - Register all loaded bundles with the global Adventure translator.
29- * - Provide utilities to fetch messages in a translatable format using keys.
21+ * The bundle fetches the latest translations from a remote Weblate instance
22+ * using Ktor and registers them with Adventure's [GlobalTranslator]. A fallback
23+ * bundle packaged with the plugin is used for missing keys or when remote
24+ * loading fails. Bundles can be reloaded at runtime to obtain updated
25+ * translations.
3026 *
31- * The [SurfMessageBundle] ensures that translations are updated and available for use across
32- * the application by leveraging the Adventure library's translation capabilities.
33- *
34- * ### Optimal Usage Example
35- * ```
36- * // Create an object wrapper for the message bundle
37- * object MessageBundleExample {
38- * // Define a constant for the bundle's base name
39- * private const val BUNDLE = "messages.ExampleBundle"
40- * private val bundle = SurfMessageBundle(javaClass, BUNDLE, plugin.dataPath).apply { load() }
41- *
42- * // Retrieve a translatable message
43- * fun getMessage(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Component) = bundle.getMessage(key, *params)
44- *
45- * // Retrieve a lazily-evaluated translatable message
46- * fun lazyMessage(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Component) = bundle.lazyMessage(key, *params)
47- * }
48- *
49- * // Example usage
50- * fun main() {
51- * val message = MessageBundleExample.getMessage("example.key")
52- * println(message)
53- * }
54- * ```
55- * ### Basic Usage Example
56- * ```
57- * // Define the path to the message bundle
58- * private const val BUNDLE = "messages.ExampleBundle"
59- *
60- * // Create an instance of SurfMessageBundle and load it
61- * val bundle = SurfMessageBundle(javaClass, BUNDLE, plugin.dataPath).apply { load() }
62- *
63- * // Retrieve a translatable message
64- * val message = bundle.getMessage("example.key")
65- *
66- * // Use the message in your application
67- * println(message)
68- * ```
69- *
70- * @property bundleClazz The class used to locate the resource bundles. Typically, this is the class where the
71- * resource files are packaged or loaded.
72- * @property pathToBundle The relative path to the base name of the resource bundle files, excluding the file extension.
73- * For example, if the bundle is located at `messages/example.properties`, the path would be `messages.example`.
74- * @property dataFolder The directory where the plugin stores its data, including resource bundles. This is used to
75- * store and manage localized message files.
76- * @property classLoader The class loader used to load resource bundles from the classpath. Defaults to the class loader
77- * of [bundleClazz].
78- * @constructor Creates a new instance of [SurfMessageBundle].
79- *
80- * @param bundleClazz The class used for locating the resource bundles.
81- * @param pathToBundle The relative path to the bundle files, excluding the file extension.
82- * @param dataFolder The directory used for storing and managing resource bundles.
83- * @param classLoader The class loader used to load bundled resources. Defaults to the class loader of [bundleClazz].
27+ * @property bundleClazz Class used to locate the fallback resource bundle.
28+ * @property pathToBundle Base name of the bundle resources (e.g. `messages.Example`).
29+ * @property baseUrl Base URL of the Weblate download endpoint returning
30+ * `properties` files without the bundle name or locale, for example
31+ * `https://translations.example.com/api/download/example/`.
32+ * @property classLoader Class loader used to load the fallback bundle.
33+ * @property client Optional [HttpClient] instance for requests.
8434 */
8535class SurfMessageBundle @JvmOverloads constructor(
8636 val bundleClazz : Class <* >,
8737 val pathToBundle : @BundlePath @NonNls String ,
88- val dataFolder : Path ,
38+ val baseUrl : String ,
8939 val classLoader : ClassLoader = bundleClazz.classLoader,
40+ val client : HttpClient = HttpClient (OkHttp )
9041) {
91- private val bundleDir =
92- dataFolder.resolve(pathToBundle.substringBeforeLast(' .' , " " ).replace(' .' , ' /' ))
93-
94- fun load () {
95- // Ensure data folder exists
96- dataFolder.createDirectories()
42+ private val translatorKey = key(" surf" , " bundle-${pathToBundle.substringAfterLast(' .' )} " )
43+ private val fallbackBundles: Map <Locale , ResourceBundle > = loadFallbackBundles()
44+ private var registry: TranslationRegistry ? = null
45+ private val version: String = bundleClazz.`package`?.implementationVersion ? : " dev"
9746
98- // Load bundled and external bundles
99- val bundled = loadBundledBundles()
100- val external = loadExternalBundles()
101-
102- // Copy missing bundle files
103- copyMissingBundles(bundled, external)
104- // Update existing files with missing keys
105- syncMissingKeys(bundled.associateBy { it.name }, external)
106- // Register all external bundles for translation
107- registerBundlesWithTranslator(external)
47+ /* *
48+ * Loads translations from Weblate and registers them with Adventure.
49+ * If loading fails, only the fallback bundle is registered.
50+ */
51+ suspend fun load () {
52+ val remote = fetchRemoteBundles()
53+ val reg = TranslationRegistry .create(translatorKey).apply {
54+ defaultLocale(Locale .getDefault())
55+ }
56+ fallbackBundles.forEach { (locale, bundle) ->
57+ reg.registerAll(locale, bundle, true )
58+ }
59+ remote.forEach { (locale, bundle) ->
60+ reg.registerAll(locale, bundle, true )
61+ }
62+ registry?.let { GlobalTranslator .translator().removeSource(it) }
63+ GlobalTranslator .translator().addSource(reg)
64+ registry = reg
10865 }
10966
110- private fun loadBundledBundles () =
111- Locale .getAvailableLocales().mapNotNullTo(mutableObjectListOf()) { locale ->
112- val name = UTF8ResourceBundleControl .get().toBundleName(pathToBundle, locale)
67+ /* * Reloads the translations from Weblate. */
68+ suspend fun reload () = load()
69+
70+ private fun loadFallbackBundles (): Map <Locale , ResourceBundle > {
71+ val map = mutableMapOf<Locale , ResourceBundle >()
72+ Locale .getAvailableLocales().forEach { locale ->
11373 try {
11474 val bundle = ResourceBundle .getBundle(
11575 pathToBundle,
11676 locale,
11777 classLoader,
11878 UTF8ResourceBundleControl .get()
11979 )
120- LoadedBundle (name, locale, bundle)
80+ map[ locale] = bundle
12181 } catch (_: MissingResourceException ) {
122- null
123- }
124- }
125-
126- private fun loadExternalBundles (): ObjectList <LoadedBundle > {
127- if (dataFolder.notExists()) return mutableObjectListOf()
128- return dataFolder.walk(PathWalkOption .FOLLOW_LINKS )
129- .filter {
130- it.extension == " properties"
131- && it.name.startsWith(pathToBundle.substringAfterLast(' .' ))
82+ // ignore
13283 }
133- .mapNotNull { path ->
134- val name = dataFolder.relativize(path)
135- .toString()
136- .replace(FileSystems .getDefault().separator, " ." )
137- .substringBeforeLast(' .' )
138- val localeTag = name.substringAfterLast(' _' , " " ).replace(' _' , ' -' )
139- val locale = Locale .forLanguageTag(localeTag)
140- try {
141- val loader = URLClassLoader (arrayOf(dataFolder.toUri().toURL()))
142- val bundle = ResourceBundle .getBundle(
143- name, locale, loader, UTF8ResourceBundleControl .get()
144- )
145- LoadedBundle (name, locale, bundle)
146- } catch (_: Throwable ) {
147- null
148- }
149- }.toCollection(mutableObjectListOf())
150- }
151-
152- private fun copyMissingBundles (
153- bundled : ObjectList <LoadedBundle >,
154- external : ObjectList <LoadedBundle >,
155- ) {
156- val existing = external.map { it.name }.toObjectSet()
157- bundled.filter { it.name !in existing }.forEach { bundle ->
158- writeProperties(
159- bundle.name,
160- bundle.bundle.toProperties(),
161- header = " Generated by SurfAPI"
162- )
163- external + = bundle
16484 }
85+ return map
16586 }
16687
167- private fun syncMissingKeys (
168- bundledMap : Map <String , LoadedBundle >,
169- external : List <LoadedBundle >,
170- ) {
171- external.forEach { ext ->
172- val src = bundledMap[ext.name] ? : return @forEach
173- val props = Properties ().apply { putAll(ext.bundle.toProperties()) }
174- src.bundle.keys.asSequence()
175- .filter { it !in props }
176- .forEach { props[it] = src.bundle.getString(it) }
177- writeProperties(ext.name, props)
88+ private suspend fun fetchRemoteBundles (): Map <Locale , ResourceBundle > {
89+ val map = mutableMapOf<Locale , ResourceBundle >()
90+ for (locale in fallbackBundles.keys) {
91+ val code = locale.toLanguageTag()
92+ try {
93+ val text: String = client.get(" $baseUrl$code .properties" ) {
94+ parameter(" version" , version)
95+ accept(ContentType .Text .Plain )
96+ }.bodyAsText()
97+ map[locale] = PropertyResourceBundle (text.byteInputStream())
98+ } catch (_: Exception ) {
99+ // ignore individual failures
100+ }
178101 }
102+ return map
179103 }
180104
181- private fun registerBundlesWithTranslator (bundles : List <LoadedBundle >) {
182- val registry = TranslationRegistry .create(
183- key(
184- " surf" ,
185- " bundle-${pathToBundle.substringAfterLast(' .' )} "
186- )
187- ).apply { defaultLocale(Locale .getDefault()) }
188- bundles.forEach { b -> registry.registerAll(b.locale, b.bundle, true ) }
189- GlobalTranslator .translator().addSource(registry)
190- }
191-
105+ fun getMessage (key : String , vararg params : Component ) =
106+ Component .translatable(key, * params)
192107
193- private fun writeProperties (
194- name : String ,
195- props : Properties ,
196- header : String? = null,
197- ) {
198- dataFolder.createDirectories()
199- val file = dataFolder.resolve(" $name .properties" )
200- file.outputStream().use { props.store(it, header) }
201- }
108+ fun lazyMessage (key : String , vararg params : Component ) =
109+ Supplier { getMessage(key, * params) }
202110
203- private data class LoadedBundle (
204- val name : String ,
205- val locale : Locale ,
206- val bundle : ResourceBundle ,
207- )
208-
209- private fun ResourceBundle.toProperties (): Properties {
210- val p = Properties ()
211- keys.asSequence().forEach { k -> p[k] = getString(k) }
212- return p
213- }
214-
215- /* *
216- * Retrieves a translatable message as a [TranslatableComponent].
217- *
218- * @param key The key of the message in the resource bundle.
219- * @param params Optional parameters to format the message.
220- * @return The message as a translatable [TranslatableComponent].
221- */
222- fun getMessage (key : String , vararg params : Component ) = Component .translatable(key, * params)
223-
224- /* *
225- * Retrieves a lazily-evaluated message supplier as a [TranslatableComponent].
226- *
227- * @param key The key of the message in the resource bundle.
228- * @param params Optional parameters to format the message.
229- * @return A [Supplier] that provides the message as a translatable [TranslatableComponent].
230- */
231- fun lazyMessage (key : String , vararg params : Component ) = Supplier { getMessage(key, * params) }
232-
233- /* *
234- * Operator function for retrieving a translatable message as a [TranslatableComponent].
235- *
236- * @param key The key of the message in the resource bundle.
237- * @param params Optional parameters to format the message.
238- * @return The message as a translatable [TranslatableComponent].
239- */
240- operator fun get (key : String , vararg params : Component ) = getMessage(key, * params)
241- }
111+ operator fun get (key : String , vararg params : Component ) =
112+ getMessage(key, * params)
113+ }
0 commit comments