Skip to content

Commit 8219fbe

Browse files
committed
feat(core): load translations from weblate
1 parent 78d790f commit 8219fbe

File tree

1 file changed

+72
-200
lines changed
  • surf-api-core/surf-api-core-api/src/main/kotlin/dev/slne/surf/surfapi/core/api/messages/bundle

1 file changed

+72
-200
lines changed

surf-api-core/surf-api-core-api/src/main/kotlin/dev/slne/surf/surfapi/core/api/messages/bundle/SurfMessageBundle.kt

Lines changed: 72 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -2,240 +2,112 @@ package dev.slne.surf.surfapi.core.api.messages.bundle
22

33
import dev.slne.surf.surfapi.core.api.messages.BundlePath
44
import 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.*
810
import net.kyori.adventure.text.Component
9-
import net.kyori.adventure.text.TranslatableComponent
1011
import net.kyori.adventure.translation.GlobalTranslator
1112
import net.kyori.adventure.translation.TranslationRegistry
1213
import net.kyori.adventure.util.UTF8ResourceBundleControl
1314
import org.jetbrains.annotations.NonNls
14-
import java.net.URLClassLoader
15-
import java.nio.file.FileSystems
16-
import java.nio.file.Path
1715
import java.util.*
1816
import 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
*/
8535
class 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

Comments
 (0)